· Updated · Comparisons · By Simon Chiu

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.

Line illustration of the RenderScreenshot mascot between a puppeteer-string browser and a theatre-curtain browser

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 waitForSelector in 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.com is 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 in this topic