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.
Cookie banners
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 withNo usable sandbox!.--no-sandboxalso "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 withdf), and Chrome uses it for rendering; large pages crash. Raise it with--shm-size=1g, or launch withargs: ['--disable-dev-shm-usage']so Chrome writes to/tmpinstead. 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-emojior 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.