PuppeteerSharp vs Playwright .NET for Screenshots

PuppeteerSharp vs Playwright for .NET, measured: cold start, capture time, bytes and pixel diffs, with tested C# for both and an ASP.NET Core service.

By Simon Chiu · Updated

Disclosure: I build RenderScreenshot, a hosted screenshot API. Neither library is ours, and we have no .NET SDK, so this page has no product to sell you inside the comparison. The API appears once, near the end, as plain HttpClient code.

Short answer: for new .NET screenshot code, pick Playwright for .NET. It is maintained by Microsoft, it isolates each capture in a cheap browser context, its locators wait for elements by themselves, and it was a little faster per capture in our test. Pick PuppeteerSharp if you already have Puppeteer knowledge or code to port, if you want to fetch the browser from your own code at runtime, or if you want Puppeteer's API one-to-one in C#. Whichever you choose, the biggest cold-start difference we measured came from the headless mode, not the library.

This page is part of the comparison hub. For the Node.js version of the same choice, see Puppeteer vs Playwright for screenshots.

Tested on October 15, 2026 on macOS (Apple Silicon) with the .NET 10 SDK (10.0.401): PuppeteerSharp 25.13.1 with Chrome for Testing 154.0.8037.57, and Microsoft.Playwright 1.63.0 with Chromium 156.0.8078.4.

The decision table

Choose PuppeteerSharp if Choose Playwright for .NET if
You are porting Puppeteer (Node.js) code and want the same method names You are starting fresh
You want the app to download its own browser at startup with BrowserFetcher You prefer a build-time or image-time browser install
You only need Chrome (Firefox is also listed in SupportedBrowser) You want Chromium, Firefox and WebKit from one API
You are comfortable with a community-maintained port You want a vendor-maintained library with test-runner packages (NUnit, MSTest, xUnit)
You manage element waits yourself You want locators that wait for visible, stable elements

What we measured

Four local HTML pages, opened as file:// URLs so that network time does not count:

  • example: a saved copy of example.com. The live page now asks people not to use it for testing, and it no longer has an <h1>, so we keep a local copy.
  • layout: CSS grid, flexbox, :has(), a container query, shadows and gradients.
  • js-chart: a bar chart that an inline script draws at load.
  • long: twelve 600 px sections (about 7,200 px) under a sticky header.

Each library launched one browser, rendered once to warm up, then made 10 captures per page and mode at 1280x800, device scale factor 1, waiting for the load event. PuppeteerSharp opened a new page per capture. Playwright opened a new browser context and page per capture, which is how you would isolate requests in a service. Cold start is measured inside the process, from launch to the first PNG in memory, three runs each. Every one of the 240 warm captures succeeded.

Results

Warm capture time, median of 10 runs in milliseconds (the maximum is in brackets):

Page and mode PuppeteerSharp (default) PuppeteerSharp (HeadlessMode.Shell) Playwright
example, viewport 94.5 (356) 90 (93) 72 (78)
example, full page 93 (97) 88.5 (92) 72 (80)
layout, viewport 93 (97) 100.5 (102) 82 (86)
layout, full page 94 (109) 98 (104) 81 (99)
js-chart, viewport 93.5 (96) 93.5 (132) 70.5 (106)
js-chart, full page 92.5 (94) 86 (110) 72.5 (89)
long, viewport 93 (95) 87 (94) 70 (72)
long, full page 177.5 (195) 171.5 (211) 173.5 (186)

Cold start, launch to first screenshot, three runs:

Setup Run 1 Run 2 Run 3
PuppeteerSharp, default (Headless = true) 1,662 ms 1,396 ms 842 ms
PuppeteerSharp, default, second series 3,020 ms 897 ms 733 ms
PuppeteerSharp, HeadlessMode.Shell 501 ms 338 ms 371 ms
Playwright 666 ms 406 ms 482 ms

Output bytes (PNG, DPR 1):

Page PuppeteerSharp (default) PuppeteerSharp (Shell) Playwright
example 18,566 18,497 18,497
layout 51,271 64,954 64,954
long, full page 164,367 165,190 165,190

What this means for you:

  • The headless mode explains most of the gap. PuppeteerSharp's default launches full Chrome in its new headless mode. Playwright launches chrome-headless-shell. Set HeadlessMode = HeadlessMode.Shell in PuppeteerSharp and both the cold start and the output bytes match Playwright's. Headless vs headful Chrome explains what differs between the modes.
  • Playwright was 13 to 23 ms faster per short capture, even with a new context per capture. On a long full-page capture the difference disappeared into the screenshot itself.
  • On real sites these numbers are small. A page that loads in two seconds makes a 20 ms library difference noise. The numbers matter when you capture your own HTML templates at volume.

The layout test page captured by PuppeteerSharp in headless-shell mode and by Playwright for .NET: grid cards, flex boxes and the green container-query box look the same in both

layout.html at 1280x800 and device scale factor 2, captured by the two programs below. The two 2560x1600 PNGs are identical: magick compare -metric AE reports 0 different pixels. The element captures of .card were 824x238 (PuppeteerSharp) and 824x240 (Playwright), so do not compare element screenshots from the two libraries pixel for pixel.

The same capture in both libraries

Both programs take a URL and a CSS selector, then save a viewport capture, a full-page capture and an element capture at device scale factor 2. Unhandled exceptions end the process with a non-zero exit code.

PuppeteerSharp

dotnet new console -n PptrShot && cd PptrShot
dotnet add package PuppeteerSharp --version 25.13.1
// PuppeteerSharp screenshot: viewport + full page + one element.
//   dotnet run -- https://example.com h1
using PuppeteerSharp;

var url = args.Length > 0 ? args[0] : "https://example.com";
var selector = args.Length > 1 ? args[1] : "h1";

// Downloads the Chrome builds that match this PuppeteerSharp version
// (once; later runs find them in the local cache).
await new BrowserFetcher().DownloadAsync();

// HeadlessMode.Shell launches chrome-headless-shell, which started about twice as fast
// as the default (full Chrome in new headless mode) in our tests.
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions { HeadlessMode = HeadlessMode.Shell });
await using var page = await browser.NewPageAsync();
await page.SetViewportAsync(new ViewPortOptions { Width = 1280, Height = 800, DeviceScaleFactor = 2 });

await page.GoToAsync(url, new NavigationOptions
{
    WaitUntil = [WaitUntilNavigation.Networkidle0],
    Timeout = 30_000,
});

await page.ScreenshotAsync("pptr-viewport.png");
await page.ScreenshotAsync("pptr-full.png", new ScreenshotOptions { FullPage = true });

var element = await page.WaitForSelectorAsync(selector, new WaitForSelectorOptions { Timeout = 5_000 })
    ?? throw new InvalidOperationException($"{selector} not found");
await element.ScreenshotAsync("pptr-element.png");

Console.WriteLine($"Saved 3 screenshots of {url}");

BrowserFetcher().DownloadAsync() in 25.13.1 fetched two builds: Chrome for Testing (a 191 MB zip, 364 MB unpacked) and chrome-headless-shell (99 MB, 205 MB unpacked), into a Chrome and a ChromeHeadlessShell folder next to your binaries. In a container, run the download during the image build, not on the first request. The default HttpClient timeout for the download is 100 seconds, so a slow network fails the first launch.

Playwright for .NET

dotnet new console -n PwShot && cd PwShot
dotnet add package Microsoft.Playwright --version 1.63.0
dotnet build
pwsh bin/Debug/net10.0/playwright.ps1 install chromium
// Playwright for .NET screenshot: viewport + full page + one element.
//   dotnet run -- https://example.com h1
using Microsoft.Playwright;

var url = args.Length > 0 ? args[0] : "https://example.com";
var selector = args.Length > 1 ? args[1] : "h1";

// Installs the Chromium build pinned to this Playwright version (no-op when present).
// The usual route is: pwsh bin/Debug/net10.0/playwright.ps1 install chromium
var installExit = Microsoft.Playwright.Program.Main(["install", "chromium"]);
if (installExit != 0) throw new Exception($"Playwright browser install failed with exit code {installExit}");

using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new() { Headless = true });

// Viewport and device scale factor belong to the context, not the page.
await using var context = await browser.NewContextAsync(new()
{
    ViewportSize = new() { Width = 1280, Height = 800 },
    DeviceScaleFactor = 2,
});
var page = await context.NewPageAsync();

await page.GotoAsync(url, new() { WaitUntil = WaitUntilState.NetworkIdle, Timeout = 30_000 });

await page.ScreenshotAsync(new() { Path = "pw-viewport.png" });
await page.ScreenshotAsync(new() { Path = "pw-full.png", FullPage = true });

// Locators auto-wait for the element to be visible and stable.
await page.Locator(selector).First.ScreenshotAsync(new() { Path = "pw-element.png", Timeout = 5_000 });

Console.WriteLine($"Saved 3 screenshots of {url}");

Playwright installs browsers into a shared cache (~/Library/Caches/ms-playwright on macOS): Chromium (366 MB) and the headless shell (204 MB) for this version. The build output also carries a 130 MB .playwright folder with the Node.js driver that the .NET package talks to. If you cannot run PowerShell in your build, call Microsoft.Playwright.Program.Main(["install", "chromium"]) from a setup step, as above.

API differences that matter for screenshots

Task PuppeteerSharp 25.13.1 Playwright for .NET 1.63.0
Get a browser new BrowserFetcher().DownloadAsync() at runtime playwright.ps1 install at build time, or Program.Main(["install", ...])
Browsers SupportedBrowser: Chrome, ChromeHeadlessShell, Chromium, Firefox Chromium, Firefox, Webkit
Isolate one request browser.CreateBrowserContextAsync() browser.NewContextAsync(...)
Viewport and scale page.SetViewportAsync(new ViewPortOptions { DeviceScaleFactor = 2 }) Context options ViewportSize, DeviceScaleFactor
Wait conditions WaitUntilNavigation: Load, DOMContentLoaded, Networkidle0, Networkidle2 WaitUntilState: Load, DOMContentLoaded, NetworkIdle, Commit
Wait for an element WaitForSelectorAsync returns a handle (or null) Locators wait on every action
Formats ScreenshotType: Jpeg, Png, Webp ScreenshotType: Png, Jpeg, Webp
Element capture IElementHandle.ScreenshotAsync(path) ILocator.ScreenshotAsync(new() { Path = ... })
Headless modes HeadlessMode: True (full Chrome), Shell, False Headless shell by default for headless Chromium

The two most common porting mistakes: setting the device scale factor on the page in Playwright (it belongs to the context), and keeping Networkidle0 everywhere. For your own HTML, Load plus a selector your template sets when it is ready is faster than waiting for 500 ms of network silence. Our viewport parameters page explains what device scale factor does to output size.

Running it inside ASP.NET Core

A screenshot endpoint needs three things: one shared browser, a cap on concurrent captures, and a fresh context per request so cookies and storage never leak between users. This minimal API does that with Playwright. We sent it six parallel requests; all returned 200, and the last two waited for a free slot.

// Minimal ASP.NET Core endpoint: one shared Playwright browser, a concurrency cap,
// and a fresh browser context per request.
using Microsoft.Playwright;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ScreenshotService>();
var app = builder.Build();

app.MapGet("/shot", async (string url, ScreenshotService shots, CancellationToken ct) =>
{
    if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != "https" && uri.Scheme != "http"))
        return Results.BadRequest("url must be an absolute http(s) URL");
    // In production, also block private and link-local addresses here (SSRF).
    var png = await shots.CaptureAsync(uri, ct);
    return Results.File(png, "image/png");
});

app.Run();

sealed class ScreenshotService : IAsyncDisposable
{
    private readonly SemaphoreSlim _slots = new(4); // max concurrent captures
    private readonly Lazy<Task<(IPlaywright, IBrowser)>> _browser = new(async () =>
    {
        var pw = await Playwright.CreateAsync();
        var browser = await pw.Chromium.LaunchAsync(new() { Headless = true });
        return (pw, browser);
    });

    public async Task<byte[]> CaptureAsync(Uri url, CancellationToken ct)
    {
        await _slots.WaitAsync(ct);
        try
        {
            var (_, browser) = await _browser.Value;
            await using var context = await browser.NewContextAsync(new()
            {
                ViewportSize = new() { Width = 1280, Height = 800 },
            });
            var page = await context.NewPageAsync();
            await page.GotoAsync(url.ToString(), new() { WaitUntil = WaitUntilState.Load, Timeout = 30_000 });
            return await page.ScreenshotAsync(new() { FullPage = true, Timeout = 30_000 });
        }
        finally
        {
            _slots.Release();
        }
    }

    public async ValueTask DisposeAsync()
    {
        if (!_browser.IsValueCreated) return;
        var (pw, browser) = await _browser.Value;
        await browser.CloseAsync();
        pw.Dispose();
    }
}

The scheme check rejects file:// URLs (we tested that a request for file:///etc/passwd gets a 400). It does not stop requests to internal hosts such as http://169.254.169.254/. Resolve the host and refuse private ranges before you capture anything a user sends you. The PuppeteerSharp version has the same shape: replace the context with CreateBrowserContextAsync() and close it in the finally block.

What this sample leaves out: restarting the browser after a crash (handle browser.Disconnected), a per-capture timeout that covers the whole request, and fonts. On Linux, install the fonts your targets use, including an emoji and a CJK font, or text renders as empty boxes. The official Playwright .NET Docker images include the browser dependencies.

When to use neither

If screenshots are a feature of your app and not the product, the browser is a second service to run: a few hundred megabytes per build, security updates, crash recovery, fonts and memory limits. RenderScreenshot runs the browser for you. We have no .NET SDK, so this is plain HttpClient:

// The same capture through the RenderScreenshot HTTP API (no browser on your server).
// There is no official .NET SDK, so this is plain HttpClient + System.Net.Http.Json.
using System.Net.Http.Headers;
using System.Net.Http.Json;

var apiBase = Environment.GetEnvironmentVariable("RS_API_BASE") ?? "https://api.renderscreenshot.com";
var apiKey = Environment.GetEnvironmentVariable("RS_API_KEY")
    ?? throw new InvalidOperationException("Set RS_API_KEY");

using var http = new HttpClient { BaseAddress = new Uri(apiBase), Timeout = TimeSpan.FromSeconds(90) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);

using var response = await http.PostAsJsonAsync("/v1/screenshot", new
{
    url = args.Length > 0 ? args[0] : "https://example.com",
    viewport = new { width = 1280, height = 800, scale = 2 },
    capture = new { mode = "full_page" },
    output = new { format = "png" },
});

if (!response.IsSuccessStatusCode)
{
    // Errors come back as JSON with a code, a message and a retryable flag.
    Console.Error.WriteLine($"{(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}");
    return 1;
}

await File.WriteAllBytesAsync("rs-full.png", await response.Content.ReadAsByteArrayAsync());
// X-Cache-URL is a public CDN link to the same image (no key in it).
Console.WriteLine(response.Headers.TryGetValues("X-Cache-URL", out var cdn) ? cdn.First() : "saved rs-full.png");
return 0;

We ran this program against a local mock of the API that checks the path, the Bearer key and the parameter names. It returned exit code 0 with a valid key and 1 with a bad one. The capture options and error codes pages list everything you can send and get back. To try it on your own pages, get a free API key.

Stay with a library when you need to log in through a multi-step flow, click through a page before the capture, or keep screenshots inside your own network. Those are browser automation jobs, and both libraries do them well.

Frequently asked questions

Is Playwright faster than PuppeteerSharp in .NET?
Slightly, in our test. With a warm browser, Playwright took about 70 to 82 ms per capture on short local pages and PuppeteerSharp about 86 to 101 ms in headless-shell mode. On a 7,200 px full-page capture the two were within 5 ms. The larger difference is cold start, and it comes from the headless mode, not the library.
Why does PuppeteerSharp start more slowly?
By default PuppeteerSharp 25 launches full Chrome in its new headless mode. Playwright launches chrome-headless-shell. When we set HeadlessMode = HeadlessMode.Shell in PuppeteerSharp, its cold start fell from 733 to 3,020 ms to 338 to 501 ms, in the same range as Playwright.
Do the two libraries produce the same screenshot?
In headless-shell mode, yes for page captures: our 2560x1600 viewport captures of the same page were identical pixel for pixel, and the PNG files had the same size. Element captures differed by 2 px in height for the same selector.
Does Playwright for .NET support WebP screenshots?
Version 1.63.0 does. ScreenshotType has Png, Jpeg and Webp, and a test capture came back as a WebP file. PuppeteerSharp 25.13.1 also supports Jpeg, Png and Webp.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.