Puppeteer Screenshots: Tested Recipes for Puppeteer 25
Every Puppeteer screenshot recipe re-run on Puppeteer 25.14 and Chrome 155: viewport, full page, element, clip, waits, formats, auth, blocking and fonts.
To take a screenshot with Puppeteer, launch Chrome, open a page, set a viewport, navigate, and call page.screenshot(). Close the browser in finally, so a failed navigation does not leave Chrome running. This is the minimum script, in ESM, that we ran for this guide:
// shot.mjs import puppeteer from 'puppeteer'; const browser = await puppeteer.launch(); try { const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); await page.goto('https://example.com', { waitUntil: 'load' }); await page.screenshot({ path: 'example.png' }); } finally { await browser.close(); }
Run it with node shot.mjs. If goto throws, the error reaches Node, the process exits with a non-zero code, and the browser still closes. The rest of this page is the set of recipes we re-ran on the current release, the numbers we measured while we did it, and the production problems the short script hides. If you have not chosen a tool yet, the overview of ways to take website screenshots compares Puppeteer with the other options. For PDFs instead of images, see Puppeteer HTML to PDF.
Tested on 2026-10-18 with Node.js 22.18.0,
[email protected]and the Chrome it downloads (Chrome 155.0.8059.79), on macOS 27.0.1 on an Apple M1 Pro. The Linux numbers come fromnode:22-bookworm-slimin Docker 29.8.2 (OrbStack, arm64) with Debian's Chromium 155.0.8059.79 and[email protected]. Every number on this page comes from those runs. The target ishttps://example.comor a local HTML file we wrote for the test.
Setup
Install a pinned version. Puppeteer's behavior changes between majors, and the postinstall step downloads the Chrome build that matches that exact version.
npm init -y npm pkg set type=module npm install [email protected]
puppeteer downloads Chrome for Testing into ~/.cache/puppeteer. puppeteer-core downloads nothing; you pass executablePath to a Chrome or Chromium you installed yourself. Use puppeteer on a laptop and in CI. Use puppeteer-core in Docker images and on serverless platforms, where you install the browser in a separate, cacheable step. The Puppeteer in Docker guide and the Puppeteer on AWS Lambda guide show both setups.
TypeScript needs no extra package: Puppeteer ships its own type definitions. The code on this page is plain ESM JavaScript with top-level await, and it type-checks as .mts without changes.
Python developers will find old tutorials for pyppeteer. It has not kept up with Chrome; use Playwright for Python instead. Our Python website screenshot guide covers that path.
Three defaults that surprise people
Before the recipes, these are the defaults we confirmed on 25.14.0. Most "wrong screenshot" reports trace back to one of them.
| Default | Value we observed | What it means |
|---|---|---|
| Viewport | 800 x 600, scale 1 | page.viewport() on a new page returned { width: 800, height: 600 }. Your image is 800 px wide unless you set it. |
| Navigation and wait timeout | 30,000 ms | TimeoutSettings in the source sets DEFAULT_TIMEOUT = 30000. A missing selector blocks for 30 s before it throws. |
| Protocol timeout | 180,000 ms | A CDP call to a hung page waits three minutes. Set protocolTimeout in launch(). |
A fourth default is the color scheme, and it is less obvious. On our Mac, which uses Dark appearance, a fresh headless page reported matchMedia('(prefers-color-scheme: dark)').matches === true. Example.com paints its background with light-dark(#eee, #222), so the screenshot came out dark grey. The same script in a Debian container came out light. Pin the scheme in every script whose output you compare or publish (see the dark mode recipe below).
Recipe 1: viewport and device scale factor
Goal: a fixed CSS size, and a sharp image on high-density screens.
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 2 }); await page.goto('https://example.com'); await page.screenshot({ path: 'retina.png' });
The file is 2560 x 1600 pixels: the CSS layout is 1280 x 800, and every CSS pixel becomes four image pixels. On example.com the PNG grew from 63 KB (800 x 600, scale 1) to 189 KB at 1280 x 800, scale 2.
Pitfalls:
- Set the viewport before
goto. Pages that readwindow.innerWidthonce at start-up keep the first value. - A scale of 2 makes full-page captures four times larger in pixel count. See the memory table in the production section before you use it on long pages.
isMobile: trueandhasTouch: truechange layout on responsive sites. For a phone screenshot,page.emulate()with one of theKnownDevicesentries sets size, scale, touch and user agent together.
Recipe 2: full page
Goal: the whole scrollable document, not only the first screen.
await page.goto('https://example.com', { waitUntil: 'load' }); await page.screenshot({ path: 'full.png', fullPage: true });
Example.com is shorter than the 800 px viewport, so this gave a 1280 x 800 image. On a local 20,000 px test page it gave a 1280 x 20,000 PNG of 402 KB.
Pitfalls: images with loading="lazy" and content that loads on scroll do not appear, because Puppeteer resizes the capture area but does not scroll like a reader. Sticky headers can repeat or cover content. Those problems need their own techniques, which are in the Puppeteer full-page screenshot guide.
Recipe 3: one element
Goal: an image of a single card, chart or heading.
await page.goto('https://example.com'); const el = await page.waitForSelector('p'); await el.screenshot({ path: 'paragraph.png' });
The result was 416 x 77 pixels: the bounding box of the element. ElementHandle.screenshot() scrolls the element into view first, so it also works below the fold. For padding, shadow DOM and locators, see the Puppeteer element screenshot guide.
Pitfall from this test run: our first draft waited for h1. When we tested, example.com had no <h1>, only a <p>, and the script stopped after 30 s with TimeoutError: Waiting for selectorh1failed. Check selectors against the live page, and pass a shorter timeout to waitForSelector when a missing element should fail fast.
Recipe 4: a fixed region (clip)
Goal: a rectangle in page coordinates, for example the top 600 x 300 of the page.
await page.screenshot({ path: 'clip.png', clip: { x: 0, y: 0, width: 600, height: 300 }, });
Output: 600 x 300 pixels, 7.6 KB. With deviceScaleFactor: 2 the clip is still in CSS pixels and the image is 1200 x 600. Prefer the element recipe when the content can move; clips break when the layout shifts by a few pixels.
Recipe 5: wait for the right moment
page.waitForTimeout() was removed in Puppeteer 22. Many older tutorials still call it, and those scripts now throw TypeError: page.waitForTimeout is not a function. Replace fixed sleeps with a condition. This table is the order we try them in:
| You need | Use | Notes |
|---|---|---|
| Static HTML and CSS | goto(url, { waitUntil: 'load' }) |
The default. Waits for images and stylesheets in the initial HTML. |
| Fast text-only capture | waitUntil: 'domcontentloaded' |
Images may still be missing. |
| A page that fetches data after load | waitUntil: 'networkidle2' |
Two or fewer connections for 500 ms. Tolerates analytics beacons. |
| A quiet page with no long-polling | waitUntil: 'networkidle0' |
Zero connections for 500 ms. Took 646 ms on example.com; never settles on pages with a websocket. |
| One known element | await page.locator('.chart').wait() |
Locators wait for the element to be visible and stable. |
| Any JavaScript condition | await page.waitForFunction(() => window.appReady === true) |
Polls in the page. |
| Web fonts | await page.waitForFunction(() => document.fonts.status === 'loaded') |
Avoids text in fallback fonts. |
| An animation to finish | await new Promise(r => setTimeout(r, 300)) |
Last resort. Combine with a condition. |
await page.goto(url, { waitUntil: 'networkidle2', timeout: 20_000 }); await page.locator('[data-ready="true"]').setTimeout(10_000).wait(); await page.waitForFunction(() => document.fonts.status === 'loaded'); await page.screenshot({ path: 'ready.png' });
Single-page applications need more than this. The guide to screenshots of SPAs covers hydration and route changes.
Recipe 6: formats, quality and file size
Goal: the smallest file that still looks right. page.screenshot() accepts type: 'png' | 'jpeg' | 'webp', a quality from 0 to 100 for JPEG and WebP, and optimizeForSpeed for PNG.
const webp = await page.screenshot({ type: 'webp', quality: 80 }); const jpeg = await page.screenshot({ type: 'jpeg', quality: 80 }); const png = await page.screenshot({ type: 'png' });
We measured each variant on two targets at 1280 x 800: example.com, and a local "busy" page with a gradient header, an SVG noise block and three columns of text. Each value is the median of 7 captures of the same loaded page; the time is only the screenshot() call.
| Variant | example.com bytes | example.com time | Busy page bytes | Busy page time |
|---|---|---|---|---|
| PNG | 81,234 | 65 ms | 103,061 | 74 ms |
PNG, optimizeForSpeed: true |
105,620 | 35 ms | 345,366 | 52 ms |
| JPEG, quality 80 | 59,147 | 37 ms | 39,298 | 45 ms |
| JPEG, quality 60 | 44,536 | 47 ms | 31,774 | 37 ms |
| WebP, quality 80 | 35,984 | 80 ms | 21,534 | 87 ms |
| WebP, quality 60 | 29,784 | 84 ms | 18,242 | 83 ms |
What we take from it:
- WebP at quality 80 was the smallest in both cases, at less than half the PNG size, and the slowest to encode.
optimizeForSpeedhalved PNG encode time and made files up to 3.4 times larger. Use it for throwaway images, not for files you store.- Use PNG when you need exact pixels (visual diffs) or transparency. JPEG has no alpha channel.
The method has limits: one machine, two pages, warm browser. Your pages will give other numbers, so run the same loop on a sample of your own URLs before you pick a default.
Recipe 7: transparent background
Goal: a PNG with alpha, for a badge or a logo you will place on another background.
await page.setContent( '<div style="font:700 64px system-ui;padding:24px;color:#0b5">Badge</div>', ); const badge = await page.$('div'); await badge.screenshot({ path: 'badge.png', omitBackground: true });
omitBackground removes Chrome's default white backdrop. It cannot remove a background that the page paints itself. When we ran it on example.com, which sets a background on <html>, the PNG came back with no alpha channel at all. For pages you control, set html, body { background: transparent } first.
Recipe 8: dark mode
Goal: the same theme on every machine.
await page.emulateMediaFeatures([ { name: 'prefers-color-scheme', value: 'dark' }, ]); await page.goto('https://example.com');
With dark, example.com's <html> background was rgb(34, 34, 34). With light, it was rgb(238, 238, 238). Without any emulation, the result depended on the host, as described in the defaults section. Sites that store the theme in localStorage or a cookie ignore the media query; for those, set the stored value with page.evaluateOnNewDocument() before navigation. Our dark mode screenshot guide goes deeper.
Recipe 9: pages behind authentication
Three mechanisms, depending on how the site checks the visitor:
// 1. Session cookie. page.setCookie() is deprecated in Puppeteer 25; // set cookies on the browser (or a browser context) instead. await browser.setCookie({ name: 'session', value: process.env.SESSION_COOKIE, domain: 'app.example.com', secure: true, httpOnly: true, }); // 2. A header, for example a bearer token for an internal preview. await page.setExtraHTTPHeaders({ Authorization: `Bearer ${process.env.PREVIEW_TOKEN}`, }); // 3. HTTP basic auth. await page.authenticate({ username: process.env.BASIC_USER, password: process.env.BASIC_PASS, }); await page.goto('https://app.example.com/dashboard');
Keep the secrets in environment variables. setExtraHTTPHeaders sends the header to every origin the page loads, including third-party scripts; if that is a problem, add the header only for your origin with request interception (next recipe).
Recipe 10: block requests (ads, trackers, heavy media)
Goal: fewer third-party requests, faster loads, cleaner images. Write the blocking yourself; the popular ad-block plugins for Puppeteer are thin wrappers over filter lists, and you can keep a short list of your own.
const BLOCKED_TYPES = new Set(['media', 'font']); const BLOCKED_HOSTS = [/doubleclick\.net$/, /google-analytics\.com$/, /hotjar\.com$/]; const page = await browser.newPage(); await page.setRequestInterception(true); page.on('request', (request) => { if (request.isInterceptResolutionHandled()) return; const { hostname } = new URL(request.url()); if (BLOCKED_TYPES.has(request.resourceType()) || BLOCKED_HOSTS.some((re) => re.test(hostname))) { return request.abort(); } return request.continue(); }); await page.goto('https://example.com');
Pitfalls:
- Every request must get exactly one
abort(),continue()orrespond(), or the page hangs. TheisInterceptResolutionHandled()check keeps two handlers from colliding. - We hit this error during testing: we called
page.setRequestInterception(false)while the handler was still attached, the next request calledcontinue(), and Puppeteer threwError: Request Interception is not enabled!. Remove the listener withpage.off('request', handler)first, or use a fresh page for intercepted jobs, as we do above. - Blocking
fontspeeds up loads but changes text rendering. Do not block fonts for images you publish.
Cookie banners and chat widgets need more than request blocking; they are often first-party, inside iframes, or in shadow DOM. The guide to hiding cookie banners in Puppeteer tests four ways to remove them.
Recipe 11: a buffer or base64 instead of a file
Leave out path and Puppeteer returns the bytes. In 25.14.0 the value is a Node Buffer (a Uint8Array subclass). Pass encoding: 'base64' for a string.
const bytes = await page.screenshot({ type: 'webp', quality: 80 }); await uploadToStorage('shots/home.webp', bytes); // your own function const b64 = await page.screenshot({ encoding: 'base64' }); const dataUri = `data:image/png;base64,${b64}`;
Base64 is a third larger than the bytes. Use it only where the receiver needs a string.
Recipe 12: fonts and emoji on Linux
On macOS, Chrome finds system fonts for Chinese, Japanese, Korean and emoji. A slim Linux image has almost none. We rendered Hello 你好 こんにちは 안녕 🎉✅ in two Debian images: with only fonts-liberation, every CJK character and both emoji were empty boxes; with the Noto packages below, all of them drew correctly.
RUN apt-get update \ && apt-get install -y --no-install-recommends \ fonts-liberation fonts-noto-core fonts-noto-cjk fonts-noto-color-emoji \ && rm -rf /var/lib/apt/lists/*
The fonts cost space: in our image /usr/share/fonts was 144 MB, and the image without the Noto packages was 105 MB smaller (361 MB against 466 MB compressed). The Puppeteer Docker guide has the full Dockerfile, and the consistent fonts guide explains fallback order.
Production: what the short script leaves out
The ten-line script launches a browser per screenshot. That is correct for a CLI and wrong for a service. These are the changes we make, with the numbers from our Linux container (memory is PSS, summed over the Chrome process tree; PSS splits shared library pages fairly, so the sum does not double-count them).
Reuse one browser, isolate each job. Launching Chrome took 207 to 412 ms per run in our Docker tests. Keep one browser and give each job its own browser.createBrowserContext(), which gives fresh cookies and storage, and close the context when the job ends.
Close what you open. We ran 30 full-page captures of a 20,000 px page in one browser, three ways:
| Cleanup per job | After 10 jobs | After 20 jobs | After 30 jobs | Chrome processes at 30 |
|---|---|---|---|---|
| Never close the page | 886 MB | 1,032 MB | 1,168 MB | 38 |
page.close() |
393 MB | 409 MB | 456 MB | 8 |
New context, context.close() |
435 MB | 435 MB | 423 MB | 8 |
The browser alone started at about 320 MB. Each unclosed page added a renderer process that stayed alive.
Budget memory per capture type. In the same container, one capture raised the tree from about 320 MB idle to: 363 MB for example.com, 473 MB for a 20,000 px full page, 794 MB for the same page at scale 2, and 604 MB for a full-page capture of a 5,000-row table. Set the container limit from your worst page type, not the average.
Limit concurrency. Run no more jobs in parallel than memory allows, using the numbers above. A queue with a fixed number of workers is enough; the scaling Puppeteer guide compares pool designs.
Put a deadline on the whole job. The 30 s navigation timeout does not cover evaluate() or screenshot() on a page whose main thread is stuck. Those wait for protocolTimeout, 180 s by default. Race the job against your own timer and close the context in finally. The timeouts and memory leaks guide has the harness we tested against a page that never responds and a page that loops forever.
Run an init process in containers. Without one, Chrome's helper processes can stay as zombies. Use docker run --init or dumb-init. The headless Chrome production failures post reproduces the problem.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
TypeError: page.waitForTimeout is not a function |
Removed in Puppeteer 22 | Wait for a condition (Recipe 5), or await new Promise(r => setTimeout(r, ms)) |
TimeoutError: Navigation timeout of 30000 ms exceeded |
load or networkidle0 never fired |
Use networkidle2 or domcontentloaded plus a selector wait; lower the timeout so failures are fast |
TimeoutError: Waiting for selector ... failed after 30 s |
The selector does not exist on the live page | Check the selector; pass a short timeout |
| Image is 800 px wide | Default viewport | page.setViewport() before goto |
| Dark image on a laptop, light on the server | Headless Chrome follows the OS color scheme | page.emulateMediaFeatures() with prefers-color-scheme |
| Boxes instead of CJK text or emoji on Linux | No fonts with those glyphs | Install fonts-noto-cjk and fonts-noto-color-emoji |
No usable sandbox! in Docker |
Docker's default seccomp profile blocks the namespaces Chrome's sandbox needs | Run with a seccomp profile that allows them, or pass --no-sandbox inside a container you control; details in the Docker guide |
Error: Request Interception is not enabled! |
Interception was turned off while a request handler was still attached |
page.off('request', handler) first, or use a separate page |
| Page hangs with interception on | A request got neither continue() nor abort() |
Resolve every request exactly once |
| Lazy images blank in full-page shots | Images load on scroll | Scroll first; see the full-page guide |
| Memory grows with every job | Pages or contexts never closed | Close them in finally; recycle the browser every few hundred jobs |
omitBackground image is not transparent |
The page paints its own background | Inject html, body { background: transparent } on pages you control |
The same capture with one API call
Everything above runs on infrastructure you operate: the Chrome version, the fonts, the sandbox, the memory limits. If screenshots are a feature and not your product, RenderScreenshot runs the browser for you. This is Recipes 1, 5, 6, 8 and 10 in one request with the Node.js SDK:
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(1280) .height(800) .scale(2) .waitFor('networkidle') .format('webp') .quality(80) .darkMode() .blockAds() .blockTrackers(), ); await writeFile('example.webp', image);
The same request as a GET URL, for a quick test in a terminal (keep the key out of public HTML; use signed URLs there):
curl -o example.webp \ "https://api.renderscreenshot.com/v1/screenshot?url=https%3A%2F%2Fexample.com&width=1280&height=800&scale=2&format=webp&quality=80&dark_mode=true&block_ads=true&api_key=$RS_API_KEY"
The wait options and viewport options pages list every value, and the migration guide from Puppeteer maps each Puppeteer call to its parameter. Create a free account for 150 free credits every month, then paste these parameters into the playground in your dashboard to compare the result with your own Puppeteer output.
Related guides
- Puppeteer full-page screenshots: lazy images, sticky headers, very long pages.
- Puppeteer timeouts and memory leaks: a tested cleanup harness and measured memory per page type.
- Puppeteer in Docker: a non-root image with fonts, an init process and measured size.
- Puppeteer on AWS Lambda:
@sparticuz/chromium, version matching and warm reuse. - Hide cookie banners and chat widgets in Puppeteer.
- Puppeteer vs Playwright for screenshots.
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.
- Puppeteer on AWS Lambda with @sparticuz/chromium Run Puppeteer on AWS Lambda with @sparticuz/chromium: version matching, a container-image handler tested in the Lambda emulator, and warm reuse.
- Puppeteer in Docker: A Tested Dockerfile with Fonts A non-root Puppeteer Dockerfile with Chromium, CJK and emoji fonts and dumb-init, plus the sandbox error it hits, measured image sizes and launch times.
- Puppeteer Full Page Screenshot: Limits, Lazy Images, Fixes Puppeteer full page screenshot guide: the 16,384 px limit we measured, captureBeyondViewport, lazy-load scrolling, sticky headers and a tested script.
- Hide Cookie Banners and Chat Widgets in Puppeteer Remove cookie banners, consent iframes, shadow-DOM dialogs and chat widgets from Puppeteer screenshots: a tested module and what each method misses.
- Puppeteer HTML to PDF: page.pdf() Options Tested Generate PDFs with Puppeteer page.pdf(): paper size, margins, header and footer templates, page breaks and print CSS, with measured sizes and timings.