Puppeteer vs Playwright for Screenshots, Measured
Puppeteer vs Playwright for screenshots: timings and memory measured on the same pages, the API differences that matter, and a decision table with tested code.
Puppeteer and Playwright are the two main Node.js libraries for driving a headless browser, and both take good screenshots. Playwright was started by engineers who had worked on Puppeteer, so the APIs look alike. The differences that matter for screenshots are smaller than most comparisons suggest, and one of them is not about the library at all: it is which Chrome binary each one launches by default.
This post compares the two on the same pages with the same settings. We measured capture time, cold start and memory, checked what each fullPage capture actually contains, and ran every snippet on current releases. For more head-to-head pages, see our tool comparisons.
Disclosure: RenderScreenshot is our product, a hosted screenshot API. We do not make either library, and the last section shows where an API fits and where it does not.
Tested with: Puppeteer 25.14.0 (Chrome 155.0.8059.79), Playwright 1.64.1 (Chromium 156.0.8078.4), Node 22.18.0, macOS on an Apple M1 Pro (32 GB), 2026-10-15. All code is ESM with top-level await.
Quick verdict
| If you need | Pick | Why |
|---|---|---|
| Python, Java or .NET as well as Node | Playwright | Official bindings with the same API design |
| WebKit (Safari engine) screenshots | Playwright | Ships Chromium, Firefox and WebKit builds |
| Many viewport or device variants per URL | Playwright | Viewport, scale, color scheme and device live on a context |
| The smallest dependency, Chrome only | Puppeteer | One browser download, close to the Chrome DevTools Protocol |
| Direct CDP access for unusual capture tricks | Puppeteer | CDP is its native layer; Playwright exposes it as a secondary API |
| Fastest default setup for headless screenshots | Either | Use the headless shell in both (see the numbers below) |
What we measured
We captured three pages 20 times each with one long-lived browser, after one warm-up capture: example.com, a local page with a 2,000-row table, and a local single-page app that renders its content 1,200 ms after load. We also measured a "cold" run (launch, one capture of example.com, close) 10 times. Memory is the peak summed RSS of every process under the Node process during the warm runs. Viewport 1280 x 800, scale 1, PNG.
Each library ran in two modes, because they launch different binaries by default: Puppeteer starts full Chrome in new headless mode, and Playwright starts chrome-headless-shell. Both can run the other mode (headless: 'shell' in Puppeteer, channel: 'chromium' in Playwright).
| Setup | example.com p50 / p95 | 2,000-row table p50 / p95 | SPA (1.2 s delay) p50 | Cold start p50 / p95 | Peak RSS |
|---|---|---|---|---|---|
| Puppeteer, default (headless Chrome) | 142 / 219 ms | 92 / 94 ms | 1,252 ms | 746 / 1,152 ms | 987 MB |
Puppeteer, headless: 'shell' |
103 / 198 ms | 72 / 74 ms | 1,250 ms | 523 / 566 ms | 589 MB |
| Playwright, default (headless shell) | 82 / 166 ms | 61 / 89 ms | 1,318 ms | 490 / 534 ms | 452 MB |
Playwright, channel: 'chromium' |
113 / 208 ms | 91 / 97 ms | 1,324 ms | 642 / 2,765 ms | 1,184 MB |
How to read this:
- The binary matters more than the library. Headless shell beats full headless Chrome in both libraries, by roughly 20 to 40 percent per capture and about half the memory. Between libraries in the same mode, the gap is small.
- The SPA row measures the page, not the tool. Both wait for the same element; the 1,200 ms app delay dominates. Playwright's locator wait added about 70 ms over Puppeteer's
waitForSelectorin this run. - Peak RSS overstates real memory. Chrome processes share memory, and summing RSS counts the shared part more than once. Use the numbers to compare setups, not to size servers.
- Treat p95 with care. With 20 runs, p95 is close to the slowest single capture, and
example.comis on the public internet. An earlier run with 15 captures per page gave the same ranking with different absolute numbers.
Full-headless Chrome is the right choice when you need behavior identical to desktop Chrome (some extensions, certain media and GPU paths). For plain page screenshots, the headless shell is lighter.
Basic screenshot
Puppeteer:
import puppeteer from 'puppeteer'; const browser = await puppeteer.launch(); // add { headless: 'shell' } for the lighter binary try { const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 2 }); await page.goto('https://example.com', { waitUntil: 'load' }); await page.screenshot({ path: 'pp.webp', type: 'webp', fullPage: true }); } finally { await browser.close(); }
Playwright:
import { chromium } from 'playwright'; const browser = await chromium.launch(); // add { channel: 'chromium' } for full headless Chrome try { const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 }); const page = await context.newPage(); await page.goto('https://example.com', { waitUntil: 'load' }); await page.screenshot({ path: 'pw.png', fullPage: true }); } finally { await browser.close(); }
The structural difference is the context. Puppeteer sets the viewport on the page. Playwright sets viewport, scale, color scheme, locale and device on a browser context, and every page in that context inherits them. If you capture the same URL at five sizes, five contexts are cleaner than resetting one page five times. try/finally matters in both: without it, a failed goto leaves a Chrome process behind.
Full page and lazy images
Both libraries take fullPage: true, and both produce the same height on the same page. Neither one scrolls the page for you. We tested a page with 20 loading="lazy" images, 400 px apart:
| Images loaded before the screenshot | Images in the fullPage capture |
|
|---|---|---|
| Puppeteer (default headless Chrome) | 3 | 3 |
| Playwright (default headless shell) | 6 | 6 |
| Either library, after the scroll helper below | 20 | 20 |
The 3 vs 6 difference comes from the binaries' different lazy-load distance thresholds, not from the screenshot call. The fix is the same in both libraries: scroll in steps, then wait for pending images.
// Works unchanged in Puppeteer and Playwright: page.evaluate has the same signature. async function loadLazyContent(page, step = 600) { await page.evaluate(async (step) => { for (let y = 0; y < document.body.scrollHeight; y += step) { window.scrollTo(0, y); await new Promise((r) => setTimeout(r, 100)); } window.scrollTo(0, 0); await Promise.all([...document.images].filter((i) => !i.complete) .map((i) => new Promise((r) => { i.onload = i.onerror = r; }))); }, step); } await page.goto(url, { waitUntil: 'load' }); await loadLazyContent(page); await page.screenshot({ path: 'full.png', fullPage: true });
Sticky headers, infinite scroll and very tall pages need more work. The Puppeteer full-page deep dive covers them, and the patterns apply to Playwright too.
Element screenshots
Puppeteer:
await page.goto(url, { waitUntil: 'domcontentloaded' }); const card = await page.waitForSelector('#ready', { visible: true, timeout: 10_000 }); await card.screenshot({ path: 'card.png' });
Playwright:
await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.locator('#ready').screenshot({ path: 'card.png', timeout: 10_000 });
Both versions wait for the element, so neither needs a null check. The older Puppeteer pattern, page.$(selector), returns null when the element is not there yet, and that is where most "Cannot read properties of null" errors come from. Playwright's locator also waits for the element to be visible and stable (not animating) before it captures, which removes a class of half-rendered element screenshots.
Viewport and device emulation
Puppeteer:
import puppeteer, { KnownDevices } from 'puppeteer'; const page = await browser.newPage(); await page.emulate(KnownDevices['iPhone 13']); await page.goto('https://example.com', { waitUntil: 'networkidle2' }); await page.screenshot({ path: 'iphone.png' });
Playwright:
import { chromium, devices } from 'playwright'; const context = await browser.newContext({ ...devices['iPhone 13'] }); const page = await context.newPage(); await page.goto('https://example.com'); await page.screenshot({ path: 'iphone.png' });
The same device name gives different images. Puppeteer's iPhone 13 descriptor uses the full 390 x 844 screen, so our capture was 1170 x 2532 px. Playwright's descriptor uses 390 x 664, the area left after Safari's browser UI, so the capture was 1170 x 1992 px. Neither is wrong, but switch libraries and your mobile screenshots change height.
Waiting for the page
| Goal | Puppeteer | Playwright |
|---|---|---|
| DOM parsed | waitUntil: 'domcontentloaded' |
waitUntil: 'domcontentloaded' |
| All resources loaded | waitUntil: 'load' |
waitUntil: 'load' |
| Network quiet for 500 ms | networkidle0 (0 requests) or networkidle2 (at most 2) |
networkidle (0 requests); its docs discourage it for tests |
| An element is visible | page.waitForSelector(sel, { visible: true }) |
page.locator(sel).waitFor() |
| A custom condition | page.waitForFunction(fn) |
page.waitForFunction(fn) |
| A fixed pause | await new Promise(r => setTimeout(r, ms)) |
page.waitForTimeout(ms) (discouraged) |
Puppeteer removed page.waitForTimeout in v22. If old code fails with "waitForTimeout is not a function", replace it with a real condition or the setTimeout promise above. For screenshots of apps you do not control, the most reliable signal is an element that appears only when data has loaded. Our guide to waiting for single-page applications goes deeper.
Formats, quality and dark mode
| Puppeteer 25.14 | Playwright 1.64.1 | |
|---|---|---|
| PNG, JPEG | Yes | Yes |
| WebP | type: 'webp' |
type: 'webp' (worked in our test; older releases rejected it) |
| Quality (JPEG/WebP) | quality: 0-100 |
quality: 0-100 |
| Transparent background | omitBackground: true |
omitBackground: true |
| Dark mode | page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]) |
newContext({ colorScheme: 'dark' }) |
| Freeze animations | Inject CSS | animations: 'disabled' |
| Mask elements | Inject CSS | mask: [locator] |
The last two rows are Playwright additions built for visual tests, and they are useful for screenshots too: a carousel stopped mid-slide or a clock in the header no longer changes your image. The dark mode screenshot guide shows both libraries side by side.
Browsers and languages
Playwright downloads its own builds of Chromium, Firefox and WebKit. WebKit is the reason teams pick it for screenshots: it is the closest thing to Safari you can run headless on Linux. Puppeteer drives Chrome, and it also drives Firefox over WebDriver BiDi, but it has no WebKit option.
For Python, Playwright has an official package with both sync and async APIs. This is the Python version of the basic capture, tested with playwright 1.64.0:
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch() try: context = browser.new_context(viewport={"width": 1280, "height": 800}, device_scale_factor=1) page = context.new_page() page.goto("https://example.com", wait_until="load") page.locator("p").first.wait_for() page.screenshot(path="playwright-full.png", full_page=True) finally: browser.close()
Puppeteer is Node only. Python ports such as pyppeteer are community projects and lag behind; if you are on Python, use Playwright or Selenium.
Which one to choose
Choose Puppeteer when you already run it and it works, when you only need Chrome, when you want the smallest install, or when you work close to the Chrome DevTools Protocol. Set headless: 'shell' if you only take screenshots.
Choose Playwright when you start fresh, need Python or another language, need WebKit, capture many device variants, or want auto-waiting locators and built-in masking for stable images.
Choose neither when screenshots are a feature of your product and not the product. Both libraries leave you with the same operations work: browser lifecycle, concurrency limits, zombie processes, fonts on Linux and timeouts. The scaling guide and the timeouts and memory guide describe that work.
The same capture as a single request to RenderScreenshot, with the lazy-image scroll and cookie-banner handling done on our side:
curl -X POST https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com", "viewport": { "width": 1280, "height": 800, "scale": 2 }, "capture": { "mode": "full_page", "scroll": { "simulate": true } }, "output": { "format": "webp", "quality": 80 }, "block": { "cookie_banners": true } }' \ --output example.webp
See capture options and wait options for the parameters, or try it with a free key (150 credits every month, no credit card).
Related guides
- Taking Screenshots with Puppeteer: the complete Puppeteer tutorial
- Taking Screenshots with Playwright: the complete Playwright tutorial
- Playwright vs Selenium for screenshots: the same measurements against Selenium
- Puppeteer Full Page Screenshot: long pages, sticky headers and lazy content
- Best Screenshot API Comparison: hosted options compared
- PuppeteerSharp vs Playwright for .NET: the same choice in C#
Related in this topic
- Screenshot Tool Comparisons Head-to-head comparisons of screenshot tools, browser libraries and rendering approaches, each with a short verdict and the trade-offs behind it.
- Playwright vs Selenium for Screenshots: Measured Playwright vs Selenium for screenshots: capture times, memory, default sizes and waits measured on the same pages, with tested Python and Node.js code.
- Satori vs Puppeteer for OG Images: One HTML, Two Engines Six OG card templates, one HTML source, rendered by Satori and by Chromium via Puppeteer: CSS support matrix, cold and warm timings, bytes and fonts.
- ScreenshotOne vs Urlbox: Parameters, Pricing, Test Script ScreenshotOne vs Urlbox compared by a third vendor: option-by-option mapping, plan prices at 1k, 10k and 100k checked October 2026, and a script to test both.
- Bannerbear vs Placid: Template Image APIs Compared Bannerbear vs Placid for generated images: API shape, sync vs async, signed image URLs, credit rules, prices at 1k to 100k images, and a script that tests both.
- Browserbase vs Browserless for Screenshot Workloads Browserbase vs Browserless for screenshots: session model, billing units, cost per capture at 1k to 100k a month, connection code and a timing harness.