Menu
Sign up

Puppeteer screenshots: full page, element, wait, retina, cookie banners

Tested Puppeteer screenshot code: fullPage, element capture, waiting without waitForTimeout, retina, lazy images, cookie banners and Chrome in Docker.

Published November 4, 2026 · 6 min read

A Puppeteer screenshot is await page.screenshot({ path: 'shot.png' }) after page.goto(url); add fullPage: true for the whole scrollable page, call screenshot() on an element handle for one element, and set deviceScaleFactor: 2 for retina. The hard part is everything around it: waiting for the right moment (page.waitForTimeout is gone since v22), lazy images, 100vh heroes, cookie banners and running Chrome in Docker. Each snippet below was run with Puppeteer 25.7 in the official ghcr.io/puppeteer/puppeteer image against a real public page.

Viewport, full page and retina

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 2 });
  await page.goto('https://example.com', { waitUntil: 'networkidle2', timeout: 30_000 });

  await page.screenshot({ path: 'viewport.png' });                 // 2560×1600
  await page.screenshot({ path: 'full.png', fullPage: true });     // whole page, 2× wide
  await page.screenshot({ path: 'full.jpg', fullPage: true, type: 'jpeg', quality: 80 });
} finally {
  await browser.close(); // always: a leaked Chrome keeps hundreds of MB
}

deviceScaleFactor multiplies every dimension: the 1280×800 viewport above produced a 2560×1600 PNG. type can be png, jpeg or webp; quality applies to the last two. The try/finally is not decoration — an exception between launch() and close() otherwise leaves a Chrome process behind on every failed capture.

One element

const card = await page.waitForSelector('#pricing', { visible: true });
await card.screenshot({ path: 'pricing.png' });

waitForSelector returns the element handle, so you wait and capture in one step. The newer locator API does the same: await (await page.locator('#pricing').waitHandle()).screenshot(...). The capture is the element's border box; for breathing room, screenshot a parent or pass a clip to page.screenshot.

Waiting for the right moment

waitUntil decides when goto resolves. load and domcontentloaded fire before most single-page apps render anything useful. networkidle0 waits until there are no network connections for 500 ms, networkidle2 until there are at most two — the better default, because analytics beacons and long-polling connections often never let networkidle0 happen, and you hit the timeout instead.

Network silence still does not mean "drawn". Wait for the thing you want in the picture, and for web fonts:

await page.waitForSelector('.chart svg', { visible: true });
await page.evaluate(() => document.fonts.ready);

page.waitForTimeout(ms) appears in many tutorials but was deprecated in 2022 and removed in Puppeteer 22.0.0 (changelog); in 25.7 page.waitForTimeout is undefined. If a fixed pause is truly needed — a CSS animation with no end event — write it out: await new Promise((resolve) => setTimeout(resolve, 1000)).

Lazy images, 100vh heroes, fixed headers

Images with loading="lazy" or an IntersectionObserver load only near the viewport, so a full-page capture straight after goto has blank boxes below the fold. Scroll through the page first:

await page.evaluate(async () => {
  for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight) {
    window.scrollTo(0, y);
    await new Promise((resolve) => setTimeout(resolve, 250));
  }
  window.scrollTo(0, 0);
});
await page.waitForNetworkIdle({ idleTime: 500 });

On Wikipedia's "Cat" article, 46 images were still unloaded after networkidle2; after this loop, none. Scroll back to the top: headers that shrink or appear on scroll are captured in whatever state the last scroll left them.

100vh sections behave well in current Puppeteer. With the default captureBeyondViewport: true, a height: 100vh hero on a 600 px viewport stayed 600 px tall in our full-page test, and a position: fixed header appeared once, at the top. With captureBeyondViewport: false, Puppeteer resizes the viewport to the page height instead — the same hero stretched to 3,600 px. If you see giant heroes, check for that option (or an old Puppeteer). Sticky elements that JavaScript repositions on scroll can still land mid-page; pin them before the capture:

await page.addStyleTag({ content: `
  .site-header, .sticky-cta { position: absolute !important; }
  *, *::before, *::after { animation: none !important; transition: none !important; }
` });

Devices and dark mode

import { KnownDevices } from 'puppeteer';

await page.emulate(KnownDevices['iPhone 15']); // 393×659, pixel ratio 3, touch, iOS user agent
await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]);
await page.goto('https://example.com');

Call emulate() before goto, so the page is laid out as mobile from the start. Note the descriptor's 659 px height: Puppeteer models the visible area under the browser bars, not the full screen.

Two approaches, usually combined. Click the platform's accept button when it exists, then hide what is left:

const accept = await page.$('#onetrust-accept-btn-handler'); // OneTrust's accept button
if (accept) await accept.click();

await page.addStyleTag({ content: `
  #onetrust-banner-sdk, #CybotCookiebotDialog, .cc-window { display: none !important; }
` });

Hiding alone can leave a dimmed overlay or a page with overflow: hidden on <body>; clicking alone misses in-house banners. Selector lists per consent platform go stale as vendors ship new markup, which is why filter lists such as the EasyList Cookie List exist. Load them with an ad-blocking library if you capture many unknown sites.

Running Chrome in Docker

The official image bundles Chrome for Testing, its libraries and Puppeteer:

FROM ghcr.io/puppeteer/puppeteer:25.7.0
WORKDIR /home/pptruser/app
COPY --chown=pptruser:pptruser package*.json shot.mjs ./
RUN npm ci
CMD ["node", "shot.mjs"]
docker run --init --cap-add=SYS_ADMIN --shm-size=1g my-screenshotter
  • --cap-add=SYS_ADMIN: the Puppeteer Docker guide requires it because Chrome runs sandboxed. Without it our container died with No usable sandbox!. --no-sandbox also "works", and the Puppeteer docs call it strongly discouraged — doubly so when the URLs come from users.
  • --init: reaps Chrome's zombie child processes.
  • Shared memory: Docker gives a container 64 MB of /dev/shm (we checked with df), and Chrome uses it for rendering; large pages crash. Raise it with --shm-size=1g, or launch with args: ['--disable-dev-shm-usage'] so Chrome writes to /tmp instead. Both worked in our test.
  • Fonts: the image has Liberation, DejaVu, CJK, Thai and Arabic fonts but no colour emoji font. Install fonts-noto-color-emoji or emoji render as empty boxes.

Then the operational part: about one Chrome process tree per concurrent capture (budget a few hundred MB each), a timeout on everything, a queue in front so a burst does not start fifty browsers, Chrome updates every few weeks, and — if users supply URLs — server-side request forgery. A user-supplied http://169.254.169.254/ or a public domain that redirects to 10.0.0.5 reaches your cloud metadata or internal services through your browser. Block private addresses after DNS resolution, for redirects and subresources too, typically with an egress proxy.

When an API is the low-ops option

If screenshots are a feature rather than your product, the same capture is one HTTP request to a screenshot API, with the browser fleet, banner blocking and SSRF egress controls run for you:

curl -X POST "https://shareglint.com/v1/screenshots?wait=true" \
  -H "Authorization: Bearer $SHOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "full_page": true,
    "lazy_load": true,
    "scale": 2,
    "block_cookie_banners": true,
    "wait_for": ".chart svg",
    "device": "iphone-15"
  }'

lazy_load does the scroll-through, block_cookie_banners clicks "accept" on the common consent platforms and hides the rest with the EasyList Cookie List, and selector captures one element. A viewport capture uses 1 render of the quota, a full page 2, and the same URL with the same options within 24 hours is free — see pricing. Your own Puppeteer stays the better choice for pages behind a login, on your intranet, or when you need scripted interaction beyond clicks; the API only reaches public pages. Language examples: JavaScript, Python, PHP, Ruby.

FAQ

How do I take a full-page screenshot with Puppeteer?

Call page.screenshot({ path: 'full.png', fullPage: true }) after page.goto. Scroll the page first if it lazy-loads images, otherwise the lower half comes out with empty placeholders.

What replaced page.waitForTimeout in Puppeteer?

Nothing built in: waitForTimeout was removed in Puppeteer 22.0.0. Wait for a condition instead (page.waitForSelector, page.waitForFunction, waitUntil: 'networkidle2'), or, if you really need a fixed pause, await new Promise((resolve) => setTimeout(resolve, 1000)).

How do I take a retina (2x) screenshot in Puppeteer?

Set deviceScaleFactor: 2 in page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 2 }). A 1280×800 viewport then gives a 2560×1600 PNG.

Why does Chrome crash or fail to launch in Docker?

Two usual causes: no usable sandbox (run the official image with --cap-add=SYS_ADMIN, or configure a sandbox; --no-sandbox is discouraged) and Docker's 64 MB /dev/shm (raise it with --shm-size=1g or launch Chrome with --disable-dev-shm-usage).