# 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 2026-11-04 · https://shareglint.com/en/guides/puppeteer-screenshot

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

```js
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

```js
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:

```js
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](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/CHANGELOG.md)); 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:

```js
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:

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

## Devices and dark mode

```js
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.

## Cookie banners

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

```js
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](https://easylist.to/) 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:

```dockerfile
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"]
```

```bash
docker run --init --cap-add=SYS_ADMIN --shm-size=1g my-screenshotter
```

- **`--cap-add=SYS_ADMIN`**: the [Puppeteer Docker guide](https://pptr.dev/guides/docker) 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](https://shareglint.com/en/screenshot-api), with the browser fleet, banner blocking and SSRF egress controls run for you:

```bash
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](https://shareglint.com/en/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](https://shareglint.com/en/screenshot-api/javascript), [Python](https://shareglint.com/en/screenshot-api/python), [PHP](https://shareglint.com/en/screenshot-api/php), [Ruby](https://shareglint.com/en/screenshot-api/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).
