· Tutorials · By Simon Chiu

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.

Line illustration of the RenderScreenshot mascot capturing a screenshot from a browser on puppeteer strings

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 from node:22-bookworm-slim in 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 is https://example.com or 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 read window.innerWidth once 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: true and hasTouch: true change layout on responsive sites. For a phone screenshot, page.emulate() with one of the KnownDevices entries 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.
  • optimizeForSpeed halved 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() or respond(), or the page hangs. The isInterceptResolutionHandled() 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 called continue(), and Puppeteer threw Error: Request Interception is not enabled!. Remove the listener with page.off('request', handler) first, or use a fresh page for intercepted jobs, as we do above.
  • Blocking font speeds 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 in this topic