How to Capture a Webpage Screenshot in ASP.NET with C#
Capture viewport, full-page, element, or clipped webpage screenshots in ASP.NET with C# and Playwright, then return image bytes from an API.
Use Microsoft Playwright for .NET to render the target URL in Chromium, call ScreenshotAsync, and return the resulting byte[] from an ASP.NET endpoint. Playwright supports viewport, full-page, element, and clipped captures in PNG, JPEG, or WebP. The example below is a complete Minimal API you can run locally.
1. Create a working ASP.NET project
- Install the .NET SDK supported by your deployment.
- Create an API project:
dotnet new web -n PageShotApi
cd PageShotApi
dotnet add package Microsoft.Playwright
Install the browser binaries required by Playwright. The generated Playwright script is under the package tools directory; run the equivalent install command for your shell:
dotnet tool install --global Microsoft.Playwright.CLI
playwright install chromium
Microsoft's Playwright screenshots guide and Page API reference document the screenshot options used here.
2. A complete Minimal API endpoint
Replace Program.cs with this example. It validates the URL, keeps all work asynchronous, sets a navigation timeout, captures a full page, and returns image bytes with the correct content type.
using Microsoft.Playwright;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/screenshot", async (HttpRequest request, CancellationToken cancellationToken) =>
{
var rawUrl = request.Query["url"].ToString();
if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out var target) ||
(target.Scheme != Uri.UriSchemeHttp && target.Scheme != Uri.UriSchemeHttps))
{
return Results.BadRequest(new { error = "url must be an absolute http or https URL" });
}
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
});
var page = await browser.NewPageAsync(new BrowserNewPageOptions
{
ViewportSize = new ViewportSize { Width = 1440, Height = 900 },
DeviceScaleFactor = 1
});
try
{
await page.GotoAsync(target.ToString(), new PageGotoOptions
{
WaitUntil = WaitUntilState.NetworkIdle,
Timeout = 30_000
});
var bytes = await page.ScreenshotAsync(new PageScreenshotOptions
{
Type = ScreenshotType.Png,
FullPage = true,
Animations = ScreenshotAnimations.Disabled,
Timeout = 30_000
});
return Results.File(bytes, "image/png");
}
catch (PlaywrightException ex)
{
return Results.Problem(
title: "Screenshot failed",
detail: ex.Message,
statusCode: StatusCodes.Status502BadGateway);
}
});
app.Run();
Run it with dotnet run, then request http://localhost:5000/screenshot?url=https%3A%2F%2Fexample.com. The endpoint sends the screenshot directly instead of writing a temporary file.
3. Choose the capture scope
| Need | Playwright option | Example |
|---|---|---|
| Visible viewport | Default screenshot options | await page.ScreenshotAsync() |
| Entire scrollable document | FullPage = true |
Useful for long pages; creates a tall image. |
| One component | Locator screenshot | await page.Locator(".header").ScreenshotAsync() |
| Rectangle | Clip |
Set X, Y, Width, and Height. |
Element screenshot
var card = page.Locator(".pricing-card");
await card.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible });
var bytes = await card.ScreenshotAsync(new LocatorScreenshotOptions
{
Type = ScreenshotType.Webp,
Quality = 85,
Animations = ScreenshotAnimations.Disabled
});
return Results.File(bytes, "image/webp");
Use a stable selector and wait for it before capturing. A locator screenshot captures the element's bounding box, including content that is outside the viewport when Playwright can scroll it into view.
Clipped region
var bytes = await page.ScreenshotAsync(new PageScreenshotOptions
{
Type = ScreenshotType.Jpeg,
Quality = 82,
Clip = new Clip { X = 0, Y = 120, Width = 900, Height = 500 }
});
4. Formats, dimensions, and rendering controls
- PNG: lossless; do not set
Quality. - JPEG: set
Qualitywhen a smaller file is preferable. - WebP: supports quality and often reduces transfer size.
- Scale: choose CSS-pixel or device-pixel output with the documented scale setting.
- Path: set
Path = "screenshot.png"when an artifact must be stored; omit it to receive a byte buffer. - Timeout: set a navigation timeout and screenshot timeout appropriate to your targets.
- Caret: hide the text caret for stable captures.
- Animations: disable animations to avoid inconsistent frames.
- Masking: mask dynamic elements when visual comparison requires deterministic output.
- Omit background: use background omission when a transparent result is required and the format supports it.
- Style: provide an inline stylesheet for repeatable presentation changes.
var bytes = await page.ScreenshotAsync(new PageScreenshotOptions
{
Type = ScreenshotType.Png,
FullPage = false,
Scale = ScreenshotScale.Css,
Caret = ScreenshotCaret.Hide,
Animations = ScreenshotAnimations.Disabled,
Style = ".cookie-banner, .chat-widget { display: none !important; }"
});
5. Wait for the page you actually want
Navigation completion does not guarantee that application data, fonts, or lazy images are ready. Pick the narrowest wait that matches the page:
await page.GotoAsync(url, new PageGotoOptions
{
WaitUntil = WaitUntilState.DOMContentLoaded,
Timeout = 30_000
});
await page.Locator("main[data-ready='true']").WaitForAsync(
new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 15_000 });
await page.WaitForTimeoutAsync(300);
Use a short explicit delay only for a known animation or delayed paint. For pages that load content after network idle, wait for a page-specific selector instead. For lazy-loaded images in a full-page capture, scroll through the document or trigger the site's lazy-load mechanism before taking the screenshot.
6. Return an image from MVC or a controller
The same byte[] can be returned from an MVC action:
[ApiController]
[Route("api/pages")]
public sealed class PageShotsController : ControllerBase
{
[HttpGet("screenshot")]
public async Task Screenshot(string url)
{
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync();
var page = await browser.NewPageAsync();
await page.GotoAsync(url, new PageGotoOptions { WaitUntil = WaitUntilState.Load });
var bytes = await page.ScreenshotAsync(new PageScreenshotOptions
{
Type = ScreenshotType.Png,
FullPage = true
});
return File(bytes, "image/png");
}
}
Set response caching deliberately. Public, stable URLs can use a cache header; user-specific or authenticated pages should use Cache-Control: no-store.
7. Security for URL screenshot endpoints
An endpoint that accepts arbitrary URLs can become a server-side request forgery (SSRF) proxy. Before deploying it:
- Allow only
httpandhttps. - Resolve hostnames and block loopback, link-local, private, metadata-service, and internal network addresses.
- Restrict redirects and re-check the destination after each redirect.
- Apply request authentication, rate limits, maximum URL length, and an overall capture deadline.
- Run Chromium with the least privilege available and isolate jobs from sensitive network resources.
- Do not pass arbitrary user-supplied headers or cookies to another site without an explicit policy.
- Limit full-page dimensions and output bytes to prevent memory exhaustion.
8. Production performance and reliability
- Reuse browsers: launching Chromium for every request adds process startup cost. Reuse a browser process or managed browser pool, while creating isolated contexts or pages per job.
- Bound concurrency: each page consumes CPU and memory. Use a queue or semaphore and measure your deployment before increasing parallelism.
- Set two timeouts: navigation and screenshot timeouts should both be finite. Cancel work when the ASP.NET request is canceled.
- Choose the smallest capture: viewport or element captures use less memory and bandwidth than a very tall full-page image.
- Control bytes: WebP or JPEG with an appropriate quality can reduce response size; retain PNG for lossless text and UI details.
- Retry selectively: retry transient navigation failures with a limit, but do not blindly retry blocked pages or invalid URLs.
- Observe outcomes: record duration, status, target host, output size, and failure category without logging credentials or page secrets.
9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser executable not found | Playwright browser binaries were not installed in the runtime image. | Run the Playwright Chromium install step during image build and use the same user at runtime. |
| Navigation timeout | Slow origin, blocked resource, or an overly strict timeout. | Check the URL from the server, set a suitable timeout, and wait for a page selector instead of network idle when appropriate. |
| Blank or partial image | Client-rendered content or lazy loading had not completed. | Wait for a visible ready selector, trigger lazy loading, and disable animations. |
| Element not found | Selector changed, frame is different, or content is conditional. | Use a stable locator, wait for it, and switch into the correct frame when the element is inside an iframe. |
| Fonts differ in production | Font files are unavailable, blocked, or still loading. | Ensure the runtime can reach font resources and wait for document.fonts.ready before capture. |
| Screenshot is too large | Full-page capture or device-pixel scaling produced a tall, high-resolution image. | Use a locator or clip, choose CSS scale, or use WebP/JPEG. |
| Works locally but fails in a container | Missing OS libraries, sandbox restrictions, or different network policy. | Use a Playwright-supported base image, install dependencies, and verify outbound DNS and HTTPS access. |
| Private page cannot be captured | Authentication state was not supplied. | Create a context with the required storage state, cookies, or headers, and protect those secrets. |
10. Or skip the browser setup
ScreenshotNeo provides a hosted website screenshot API. One GET request returns PNG, JPEG, WebP, or PDF, with options for full-page and element captures, waits, custom CSS and JavaScript, device settings, headers, cookies, blocking, caching, signed links, async jobs, bulk capture, and more. See the ScreenshotNeo API documentation for the complete option list.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
const bytes = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', bytes));
Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing result. An MCP server lets Claude, Cursor, and other MCP clients call screenshot, page-info, and PDF tools. The Free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000 shots.
Create a free ScreenshotNeo account and get 1,000 screenshots a month with no card.
11. cURL and client integration patterns
For a service-to-service API, return the bytes from your ASP.NET action after calling ScreenshotNeo, or stream the response directly. Keep the access key in configuration or a secret manager; never place it in browser JavaScript or source control.
var client = httpClientFactory.CreateClient();
var endpoint = "https://api.screenshotneo.com/v1/shot" +
"?access_key=" + Uri.EscapeDataString(configuration["ScreenshotNeo:AccessKey"]!) +
"&url=" + Uri.EscapeDataString(url);
using var response = await client.GetAsync(endpoint, cancellationToken);
response.EnsureSuccessStatusCode();
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
return Results.File(bytes, response.Content.Headers.ContentType?.MediaType ?? "image/webp");
12. FAQ
Can ASP.NET return a screenshot without saving a file?
Yes. Omit Path; Playwright returns a buffer that ASP.NET can send with Results.File or MVC's File result.
What is the difference between full page and viewport capture?
Viewport capture records the current browser viewport. Full-page capture records the complete scrollable document as one image.
Can I capture an iframe?
Yes. Locate the frame, wait for its content, then locate the element within that frame before taking its screenshot.
Which format should an API use?
Use PNG for lossless UI details, JPEG when photographic content and size matter, and WebP when clients support it and you want a compact response.
Should I launch a browser per request?
For low traffic it is simple, but production services generally benefit from reusing a browser process and limiting concurrent pages.


