· Updated · Tutorials · By Simon Chiu

Playwright Screenshots: Tested Recipes and Pitfalls

Take screenshots with Playwright: page.screenshot options, deviceScaleFactor, locators, masks, measured format sizes, auth, Linux fonts and production setup.

Line illustration of the RenderScreenshot mascot capturing a browser framed by a theatre curtain

To take a screenshot with Playwright, open a page and call page.screenshot:

import { chromium } from 'playwright';

const browser = await chromium.launch();
try {
  const page = await browser.newPage();
  await page.goto('https://example.com');
  await page.screenshot({ path: 'example.png' });
} finally {
  await browser.close();
}

That writes a 1280 x 720 PNG. Everything else in this guide changes one of four things: the browser context (size, pixel density, color scheme, auth), what you capture (page, full page, element, region), when you capture (waits), and how you encode it (format and quality). Each recipe has the minimal code, what the output was in our run, and the pitfall that usually bites. This page is the hub for our Playwright posts, and part of the wider guide to taking website screenshots.

Tested on 2026-10-18 with Node.js 22.18.0, [email protected] (Chromium 156.0.8078.4), macOS 27 on an Apple M1 Pro, plus the mcr.microsoft.com/playwright:v1.64.1-noble Docker image for the Linux and font sections. Every snippet ran against https://example.com or a local test server. Sizes and timings are from these runs.

Setup

Use ESM and pin the version. Screenshot output depends on the bundled Chromium build, and each Playwright release ships a new one.

mkdir pw-shots && cd pw-shots
npm init -y
npm pkg set type=module
npm i [email protected]
npx playwright install chromium
  • playwright vs @playwright/test. playwright is the library for scripts and services. @playwright/test is the test runner with expect(page).toHaveScreenshot(). Install the runner only for tests; the visual comparisons guide covers it.
  • playwright-core is the library without a browser download. Use it when you bring your own Chromium build, for example a slim Chromium on a serverless platform.
  • Headless shell. chromium.launch() runs Chromium's headless shell by default, and the first capture above came from it.
  • TypeScript works without a build step under the test runner. For plain scripts, run .ts files with node --experimental-strip-types or tsx. All snippets here are plain ESM JavaScript.
  • Python: the same API exists as playwright on PyPI, with a sync and an async flavor. See Playwright screenshots in Python.

The context is where screenshots are configured

A Playwright browser context is an isolated profile: its own cookies, storage, viewport, pixel density, locale, time zone and color scheme. Most screenshot settings belong on the context, not on page.screenshot. browser.newPage() creates a context with defaults behind the scenes; create one yourself as soon as you need a setting.

const context = await browser.newContext({
  viewport: { width: 1200, height: 630 },
  deviceScaleFactor: 2,
  colorScheme: 'light',
  locale: 'en-US',
  timezoneId: 'UTC',
});
const page = await context.newPage();

Contexts are cheap compared to browsers. One browser with a fresh context per job is the standard pattern for services. More background in the browser context glossary entry.

Recipe 1: exact size and retina output

Goal: a 1200 x 630 social card at 2x for sharp text on high-DPI screens.

const context = await browser.newContext({
  viewport: { width: 1200, height: 630 },
  deviceScaleFactor: 2,
});
const page = await context.newPage();
await page.goto('https://example.com');
await page.screenshot({ path: 'og-2x.png' });

Output: 2400 x 1260 pixels, 163,704 bytes as PNG.

Pitfalls:

  • deviceScaleFactor exists only on the context (and in devices[...] descriptors). There is no per-screenshot scale-up. page.screenshot({ scale: 'css' }) goes the other way: it renders one image pixel per CSS pixel on a high-DPI context.
  • page.setViewportSize() changes width and height later, but not pixel density.
  • Device descriptors set everything at once: browser.newContext({ ...devices['iPhone 15'] }) sets viewport, scale factor, user agent, isMobile and hasTouch.

The viewport parameter docs and the retina screenshots guide explain how pixel density affects file size.

Several devices in one run

Goal: the same page as a desktop, tablet and phone capture, for a responsive review or a store listing.

import { chromium, devices } from 'playwright';

const targets = [
  ['desktop', { viewport: { width: 1440, height: 900 } }],
  ['tablet', devices['iPad Mini']],
  ['phone', devices['iPhone 15']],
];

const browser = await chromium.launch();
try {
  for (const [name, options] of targets) {
    const context = await browser.newContext(options);
    try {
      const page = await context.newPage();
      await page.goto('https://example.com');
      await page.screenshot({ path: `${name}.png` });
    } finally {
      await context.close();
    }
  }
} finally {
  await browser.close();
}

Output: 1440 x 900, 1536 x 2048 and 1179 x 1977 pixels. The phone image is not 393 x 852 (the iPhone 15 screen) because the descriptor sets a 393 x 659 viewport, the area left after Safari's browser bars, at deviceScaleFactor: 3.

Pitfalls:

  • Descriptors carry a Safari user agent and defaultBrowserType: 'webkit'. Under chromium.launch() you get Chromium's layout engine with an iPhone user agent and touch support. That is close enough for layout checks, but not for Safari-specific rendering bugs; launch webkit for those.
  • Each descriptor is a plain object. Spread it and override what you need: { ...devices['iPhone 15'], colorScheme: 'dark' }.
  • A fresh context per device matters: a site that sets a "mobile" cookie on the first visit would otherwise serve the phone layout to the desktop capture.

Recipe 2: the full scrollable page

await page.screenshot({ path: 'full.png', fullPage: true });

Output: on the 1200 x 630 context at 2x, 2400 x 1504 pixels: example.com is 752 CSS pixels tall at that width, so the capture grew past the viewport.

Pitfalls: Playwright measures the document and captures it in one image. It does not scroll first, so images with loading="lazy" below the fold stay empty, and position: fixed headers appear once at the top (which is usually right). Infinite feeds have no end. The Playwright full-page screenshot post has a scroll-then-capture helper and the fixes for sticky headers and 100vh sections.

Recipe 3: one element, with a locator

Goal: capture one component, cropped to its box.

await page.locator('p[lang="zh"]').screenshot({ path: 'paragraph.png' });
await page.getByRole('link', { name: 'Learn more' }).screenshot({ path: 'link.png' });

Output: the paragraph at 2x was 832 x 90 pixels, 18,926 bytes.

Pitfalls:

  • Prefer role, text and test-id locators over CSS chains. They survive markup changes, and Playwright's locator docs recommend them.
  • Locators are strict: if the locator matches several elements, screenshot() throws strict mode violation ... resolved to 6 elements. Use .first(), .nth(i) or a narrower locator.
  • locator.screenshot() waits for the element to be visible and stable, and scrolls it into view. That built-in wait is often all you need.
  • Our first draft used getByRole('heading', { level: 1 }). It timed out after 30 seconds, because example.com no longer has a heading: the page now shows the same notice in six languages. Check selectors against the live page before you blame Playwright.

Recipe 4: a fixed region

await page.screenshot({
  path: 'clip.png',
  clip: { x: 0, y: 0, width: 600, height: 300 },
});

Output: 1200 x 600 pixels. clip is in CSS pixels; the 2x context doubled it. Combine clip with fullPage: true to cut a region below the fold. See the clip region guide for the API version of the same idea.

Recipe 5: hide what changes

Live timestamps, avatars, ads and blinking cursors make captures differ from run to run. page.screenshot has options for each:

await page.screenshot({
  path: 'masked.png',
  mask: [page.locator('p').first()],
  maskColor: '#111827',
  animations: 'disabled',
  caret: 'hide',
});
  • mask paints a solid box over each matched element, after layout. The page does not reflow.
  • animations: 'disabled' fast-forwards finite CSS animations and transitions to the end and cancels infinite ones.
  • caret: 'hide' hides the text cursor in focused inputs.
  • style (a CSS string) applies CSS only during the capture, for example style: '.chat-widget { visibility: hidden; }'.

Pitfall: a mask covers the element's current box, and that box changes with the text inside it. In our visual test, masking a live timestamp still produced a 72-pixel diff between two runs. Freeze time with page.clock.setFixedTime() instead. The details are in the visual comparisons guide.

Recipe 6: wait for the right moment

page.goto waits for the load event by default. That covers images and CSS in the initial HTML, but not data that JavaScript fetches afterwards. Wait for the content you want to see:

await page.goto('https://example.com/dashboard');
await page.getByTestId('revenue-chart').waitFor();
await page.screenshot({ path: 'dashboard.png' });

Do not reach for waitUntil: 'networkidle' by default. On our test pages it added 500 ms to every navigation and timed out on two of five pages (a status poller and a slow analytics beacon), where a locator wait returned in under 2 seconds. The full timing table, a "visually ready" helper for fonts and images, and a decision table are in what to wait for in Playwright. For lazy images, see screenshots of lazy-loaded images.

Recipe 7: formats and quality, measured

page.screenshot supports png (default), jpeg and webp, with quality (0 to 100) for JPEG and WebP. We captured the example.com viewport (1280 x 720 CSS pixels) seven times per variant and took the median time of the screenshot call:

const variants = [
  ['png', {}],
  ['jpeg q80', { type: 'jpeg', quality: 80 }],
  ['jpeg q95', { type: 'jpeg', quality: 95 }],
  ['webp default', { type: 'webp' }],
  ['webp q80', { type: 'webp', quality: 80 }],
];
for (const [name, options] of variants) {
  const start = performance.now();
  const buffer = await page.screenshot(options);
  console.log(name, buffer.length, Math.round(performance.now() - start));
}
Variant 1x bytes 1x time 2x bytes 2x time
PNG 78,918 35 ms 184,386 84 ms
JPEG, quality 80 58,172 30 ms 155,048 54 ms
JPEG, quality 95 89,385 35 ms 238,748 50 ms
WebP, no quality (lossless) 76,404 34 ms 320,746 112 ms
WebP, quality 80 35,048 50 ms 83,136 170 ms

What we learned:

  • WebP without quality is lossless, and at 2x it was 74% larger than PNG. Always set quality when you want WebP for size.
  • WebP at 80 was the smallest at both densities (55% below PNG at 2x) but the slowest to encode. For a few images it does not matter; for thousands per hour, encode time is CPU you pay for.
  • JPEG at 95 was larger than PNG at both densities on this flat, text-heavy page. JPEG wins on photos, not on UI.
  • There is no encoding: 'base64' option in Playwright. screenshot() returns a Buffer; call buffer.toString('base64') when you need a data URI.

Format trade-offs on other content types are in PNG vs JPEG vs WebP for screenshots.

Recipe 8: transparent background

await page.setContent('<h1 style="font:700 64px system-ui;color:#0f172a">Hello</h1>');
await page.screenshot({ path: 'transparent.png', omitBackground: true });

Output: an RGBA PNG, 8,038 bytes, with only the heading opaque. omitBackground removes the default white page background. It does nothing if the page paints its own background (example.com does, with background: light-dark(#eee, #222)), and JPEG has no alpha channel. More in the transparent PNG glossary entry.

Recipe 9: dark mode

const dark = await browser.newContext({ colorScheme: 'dark' });
const page = await dark.newPage();
await page.goto('https://example.com');
await page.screenshot({ path: 'dark.png' });

In our run, matchMedia('(prefers-color-scheme: dark)').matches returned true and example.com rendered its dark palette, because it declares color-scheme: light dark. Sites that switch theme with a class or a stored preference ignore colorScheme; set their storage key with addInitScript (next recipe) instead. The dark mode screenshots post covers both kinds.

Recipe 10: auth, headers, init scripts and blocking

These four context features handle most "the screenshot shows the wrong page" problems. We tested them together against a local server protected by HTTP basic auth:

const context = await browser.newContext({
  httpCredentials: { username: 'staging', password: process.env.STAGING_PASSWORD },
  extraHTTPHeaders: { 'X-Preview-Token': process.env.PREVIEW_TOKEN },
  locale: 'de-DE',
  timezoneId: 'Europe/Berlin',
});

// Runs before any page script, in every page and frame of the context.
await context.addInitScript(() => {
  localStorage.setItem('cookie-consent', 'dismissed');
});

// Abort requests you never want in a screenshot.
await context.route('**/*', (route) => {
  const request = route.request();
  const host = new URL(request.url()).hostname;
  if (request.resourceType() === 'media' || host.endsWith('doubleclick.net')) {
    return route.abort();
  }
  return route.continue();
});

const page = await context.newPage();
await page.goto('http://localhost:4175/');
await page.addStyleTag({ content: '#consent { display: none !important; }' });
await page.screenshot({ path: 'staging.png' });

In the page, localStorage.getItem('cookie-consent') returned 'dismissed', navigator.language returned 'de-DE' and the resolved time zone was Europe/Berlin.

  • httpCredentials answers basic-auth challenges. Do not put credentials in the URL (https://user:pass@host); they leak into logs and referrers.
  • addInitScript is Playwright's name for what Puppeteer calls evaluateOnNewDocument. Use it to set consent flags, feature flags or a theme before the app reads them.
  • route with abort() blocks by type or host. Write your own list; most "block ads" plugins for Puppeteer are not maintained and do not apply to Playwright.
  • Logged-in pages: sign in once, save the session with await context.storageState({ path: 'state.json' }), then create later contexts with browser.newContext({ storageState: 'state.json' }). Treat that file as a secret.

For cookie banners at scale, see blocking cookie banners. The authenticated pages guide shows the API equivalents.

Fonts and emoji on Linux

This is where most "works on my Mac" screenshot bugs come from. example.com is a useful test because it prints the same paragraph in English, Arabic, Chinese, French, Russian and Spanish.

  • macOS: all six rendered.
  • mcr.microsoft.com/playwright:v1.64.1-noble (Ubuntu): all six rendered. The image ships extra font packages; fc-match resolved Chinese to WenQuanYi Zen Hei and Arabic to FreeSerif.
  • AWS Lambda with a slim Chromium: the Arabic and Chinese paragraphs were blank, with no error. The slim build ships one Latin, Greek and Cyrillic font and nothing else.
  • Same page, macOS vs Ubuntu: the Latin text wrapped at different points, because the fallback for system-ui is a different font on each OS.

The reliable setup is to capture in one Docker image and install the font families your users' pages need:

FROM mcr.microsoft.com/playwright:v1.64.1-noble
RUN apt-get update \
 && apt-get install -y --no-install-recommends fonts-noto-cjk fonts-noto-color-emoji \
 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["node", "capture.mjs"]

We ran captures in the base image; the extra apt-get line for Noto CJK and color emoji was not part of our test run. Check fc-list :lang=zh inside the container after you build it. If your pages load web fonts, wait for them with await page.evaluate(() => document.fonts.ready). The consistent fonts guide and the emoji rendering guide go deeper.

Production: one browser, many contexts, bounded

A capture service needs four things the snippets above do not have: browser reuse, a concurrency limit, timeouts on every step, and a failure that sets a non-zero exit code. This worker pool does all four:

import { chromium } from 'playwright';

const urls = Array.from({ length: 12 }, (_, i) => `https://example.com/?page=${i}`);
const CONCURRENCY = 4;

const browser = await chromium.launch();
try {
  const queue = [...urls.entries()];
  const failures = [];

  async function worker() {
    for (let next = queue.shift(); next; next = queue.shift()) {
      const [i, url] = next;
      const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
      try {
        const page = await context.newPage();
        await page.goto(url, { timeout: 15_000 });
        await page.screenshot({ path: `out/batch-${i}.png`, timeout: 10_000 });
      } catch (error) {
        failures.push({ url, message: error.message });
      } finally {
        await context.close();
      }
    }
  }

  await Promise.all(Array.from({ length: CONCURRENCY }, worker));
  if (failures.length) {
    console.error(failures);
    process.exitCode = 1;
  }
} finally {
  await browser.close();
}

It captured 12 URLs in 2.7 seconds on our laptop. Notes:

  • One context per job isolates cookies and storage and frees the page's memory on close().
  • Memory, measured. We summed the resident memory of all Chromium processes with ps while contexts with example.com were open: 139 MB for the browser alone, 296 MB with one context, 640 MB with four and 1,094 MB with eight. After closing all eight contexts, 207 MB. The sum counts shared pages more than once, so treat it as an upper bound, and expect real pages to need much more than example.com.
  • Restart the browser periodically, for example every few hundred jobs or when browser.isConnected() is false. Long-lived Chromium processes grow.
  • Zombie processes. In Docker, run with --init (or tini) so orphaned Chromium children are reaped. See zombie processes.
  • Shared memory. Run containers with --ipc=host or a larger /dev/shm, or Chromium can crash on large pages.

For queue design, retries and scale-out, read scaling headless screenshots, which applies to Playwright with the same patterns, and headless Chrome production failures.

Debug a bad capture

When a screenshot looks wrong, record a trace instead of guessing:

await context.tracing.start({ screenshots: true, snapshots: true });
// ... navigate and capture ...
await context.tracing.stop({ path: 'trace.zip' });

Open it with npx playwright show-trace trace.zip. The trace shows every action, network request and a DOM snapshot at each step, so you can see what the page looked like when screenshot() ran. chromium.launch({ headless: false, slowMo: 250 }) shows the browser window while the script runs.

Troubleshooting

Symptom Cause Fix
Executable doesn't exist at .../chromium_headless_shell-... Package installed or upgraded without the browser npx playwright install chromium after every version change
locator.screenshot: Timeout 30000ms exceeded The locator matches nothing on the live page Check the selector in the trace or with npx playwright codegen
strict mode violation ... resolved to N elements Locator matched several elements .first(), .nth() or a narrower locator
Screenshot shows "Loading..." Data arrived after load Wait for a locator that only exists with data
page.goto: Timeout with networkidle Polling or beacons keep the network busy Use load plus a locator wait
Blank images in a full-page capture Lazy images never entered the viewport Scroll before capture; see the full-page post
Image is twice the expected size deviceScaleFactor: 2 on the context Expected; use scale: 'css' for CSS-size output
WebP file larger than PNG No quality, so WebP is lossless Set quality: 80
omitBackground has no effect The page paints its own background Override with style: 'html, body { background: transparent !important; }'
Empty boxes or blank text for CJK, Arabic, emoji No font for that script on the server Install Noto fonts in the image
Text wraps differently on CI Different fallback fonts per OS Capture in one Docker image
Chromium crashes in Docker on long pages Small /dev/shm --ipc=host or --shm-size=1gb
Memory climbs over hours Contexts not closed, browser never restarted context.close() in finally; restart the browser periodically
Cookie banner covers the page Consent stored in a cookie or localStorage Set it with addInitScript, or hide it with style

The same capture with one API call

Recipes 1, 5, 7 and 9 together, in Playwright:

const context = await browser.newContext({
  viewport: { width: 1200, height: 630 },
  deviceScaleFactor: 2,
  colorScheme: 'dark',
});
const page = await context.newPage();
await page.goto('https://example.com');
await page.screenshot({ path: 'card.webp', type: 'webp', quality: 80, animations: 'disabled' });

The same request with the RenderScreenshot Node SDK, with no browser, fonts or Docker image to maintain:

import { writeFile } from 'node:fs/promises';
import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);

const image = await client.take(
  TakeOptions.url('https://example.com')
    .width(1200)
    .height(630)
    .scale(2)
    .darkMode()
    .reducedMotion()
    .format('webp')
    .quality(80)
);
await writeFile('card.webp', image);

Waits map to the wait options (waitFor, waitForSelector), blocking to blockAds() and blockCookieBanners(), and auth to authBasic(), cookies() and headers(). Rendering runs on Cloudflare's browser infrastructure, and repeat requests come from the cache. You get 150 free credits every month: create a key and run the request above against your own page.

Related in this topic