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.
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 themcr.microsoft.com/playwright:v1.64.1-nobleDocker image for the Linux and font sections. Every snippet ran againsthttps://example.comor 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
playwrightvs@playwright/test.playwrightis the library for scripts and services.@playwright/testis the test runner withexpect(page).toHaveScreenshot(). Install the runner only for tests; the visual comparisons guide covers it.playwright-coreis 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
.tsfiles withnode --experimental-strip-typesortsx. All snippets here are plain ESM JavaScript. - Python: the same API exists as
playwrighton 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:
deviceScaleFactorexists only on the context (and indevices[...]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,isMobileandhasTouch.
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'. Underchromium.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; launchwebkitfor 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()throwsstrict 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, becauseexample.comno 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', });
maskpaints 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 examplestyle: '.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
qualityis lossless, and at 2x it was 74% larger than PNG. Always setqualitywhen 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 aBuffer; callbuffer.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.
httpCredentialsanswers basic-auth challenges. Do not put credentials in the URL (https://user:pass@host); they leak into logs and referrers.addInitScriptis Playwright's name for what Puppeteer callsevaluateOnNewDocument. Use it to set consent flags, feature flags or a theme before the app reads them.routewithabort()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 withbrowser.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-matchresolved 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-uiis 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
pswhile contexts withexample.comwere 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 thanexample.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(ortini) so orphaned Chromium children are reaped. See zombie processes. - Shared memory. Run containers with
--ipc=hostor 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 guides
- Playwright full-page screenshots: lazy images, sticky headers, very long pages
- What to wait for in Playwright: load states,
networkidle, locator waits, timed - Playwright screenshots in Python: sync vs async, pytest
- Playwright visual comparisons:
toHaveScreenshot, Docker baselines, CI - Puppeteer vs Playwright for screenshots and the Puppeteer guide
Related in this topic
- How to Take Website Screenshots with Code: 6 Ways Take a screenshot of a website programmatically: DevTools, the Chrome CLI, Puppeteer, Playwright, Selenium or an API. Timed runs and code for each.
- Playwright Full Page Screenshot: Limits and Lazy Images Playwright full page screenshot guide: fullPage limits measured per engine, a wheel-scroll helper for lazy images and the style option for sticky headers.
- Playwright Python Screenshots: Sync and Async Take screenshots with Playwright for Python: sync vs async API side by side, full-page and element captures, pytest failure shots, measured timings.
- Playwright toHaveScreenshot: Visual Tests That Hold Set up Playwright visual comparisons with toHaveScreenshot: thresholds that mean something, OS font drift, Docker baselines and CI artifacts. Measured diffs.
- Playwright Wait for Page Load: What to Wait For How to make Playwright wait for a page to load before a screenshot: load states, why networkidle is discouraged, locator auto-waiting, and timed results.
- Playwright on AWS Lambda: Slim Chromium Screenshots Run Playwright on AWS Lambda with @sparticuz/chromium: handler code, package size vs Lambda limits, missing fonts, local testing with the runtime emulator.