How to take a full-page screenshot in Puppeteer
Pass fullPage: true and Puppeteer captures the whole page. On real sites that isn't enough: lazy images come out grey and cookie bars land mid-image. Here's the script we tested, what broke without it, and the one-request version.
The short version
Call page.screenshot({ fullPage: true }). Puppeteer measures the page's full scroll height and saves it as one image at the viewport width you set. On pages with lazy-loaded images, scroll through the page first and wait for the network to settle, or the lower half of the image comes out grey.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto('https://example.com', { waitUntil: 'networkidle0' });
await page.screenshot({ path: 'full-page.png', fullPage: true });
await browser.close();Install with npm i puppeteer, which also downloads a browser for it to drive. Puppeteer 25 needs Node 22.12 or later. The code uses top-level await, so keep the .mjs extension.
What went wrong without fixes
We ran the script above against test pages built to show the problems people report most, at a 1280 × 800 viewport. Here is what came back:
| Test page | Plain fullPage: true | Full script below |
|---|---|---|
8 images with loading="lazy" | 4 of 8 loaded, images 5 to 8 grey | 8 of 8 |
| 8 images loaded by IntersectionObserver | 0 of 8 | 8 of 8 |
Cookie bar with position: fixed; bottom: 0 | Painted once, at the bottom of the first screen | Hidden |
Rows scrolling inside <main> | 1280 × 800, the first rows only | Needs the container fix below: 1280 × 5640 |
| Page 30,000 px tall | One PNG, correct top to bottom | Same |
Waiting for load instead of networkidle0 made the lazy-image result worse: none of the 8 had loaded when the screenshot started.
The full script
This is the version we use for real sites. It scrolls to trigger lazy loading, waits for the network and web fonts, hides bars that stick to the bottom of the screen, then captures.
import puppeteer from 'puppeteer';
const url = process.argv[2] ?? 'https://example.com';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(url, { waitUntil: 'networkidle0' });
// Scroll down in half-screen steps so lazy images and scroll-triggered content load.
await page.evaluate(async () => {
const step = window.innerHeight / 2;
for (let y = 0; y < document.documentElement.scrollHeight; y += step) {
window.scrollTo(0, y);
await new Promise((r) => setTimeout(r, 150));
}
window.scrollTo(0, 0);
});
await page.waitForNetworkIdle({ idleTime: 500 });
await page.evaluate(() => document.fonts.ready);
// Hide fixed bars below the top edge (cookie notices, chat bubbles), which would
// otherwise be painted once in the middle of the image. Top-fixed headers stay.
await page.evaluate(() => {
for (const el of document.querySelectorAll('body *')) {
if (getComputedStyle(el).position === 'fixed' && el.getBoundingClientRect().top > 0) {
el.style.setProperty('display', 'none', 'important');
}
}
});
await page.screenshot({ path: 'full-page.png', fullPage: true });
await browser.close();On the Wikipedia article on screenshots it took 4.3 seconds, browser start included, and wrote a 1280 × 4487 PNG with every image loaded, the footer logos too.
- Viewport first. The image width is the viewport width, so set it before
goto. UsedeviceScaleFactor: 2insetViewportfor a retina image at twice the pixels. - Scroll in half-screen steps. Native lazy loading and IntersectionObserver both react to what is near the viewport. Jumping straight to the bottom skips the middle.
- Scroll back to the top. Sticky headers and scroll-linked animations then sit where a visitor first sees them.
- Wait twice.
waitForNetworkIdlecatches the image requests the scroll started;document.fonts.readystops text from rendering in a fallback font.
Lazy-loaded images come out grey
A full-page capture doesn't scroll. The browser paints the whole page in one pass, so anything that waits for a scroll event or an IntersectionObserver callback is still a placeholder. Pages using native loading="lazy" are less predictable: in our test the first four images loaded and the rest didn't.
The scroll loop in the full script fixes both kinds. If a site uses a custom scroll container, scroll that element instead of window.
Cookie bars and fixed headers
An element with position: fixed is painted once, where it sits in the first screen. A header fixed to the top looks right. A cookie bar fixed to the bottom ends up as a stripe across the middle of a long image, 800 px down at this viewport.
The full script hides every fixed element that isn't touching the top edge. When you know the selector, hiding that one element is safer:
await page.addStyleTag({ content: '#cookie-banner { display: none !important; }' });Pages that scroll inside a container
Some apps set height: 100vh on the body and scroll a <main> or .content element instead of the page. The document is then exactly one screen tall, so fullPage returns 1280 × 800. Let the container grow before the capture:
await page.addStyleTag({
content: 'html, body { height: auto !important; } main { overflow: visible !important; height: auto !important; }',
});
await page.screenshot({ path: 'full-page.png', fullPage: true });On our test page this went from 1280 × 800 to 1280 × 5640, with all 40 rows. Change main to whatever element has the scroll bar.
Very tall pages
Older answers on forums suggest capturing in tiles and stitching them. With Puppeteer 25.14.0 we didn't need to: a 30,000 px page came out as one 497 KB PNG with the right pixels at the top, middle and bottom.
One trap: don't make the viewport as tall as the page to get everything in one screen. Anything sized in vh grows with it. Our 800 px hero became 3,770 px tall and the page grew from 3,770 to 6,740 px.
JPEG and WebP
Long pages make big PNGs. Puppeteer can write JPEG or WebP with a quality setting:
await page.screenshot({ path: 'full-page.webp', fullPage: true, type: 'webp', quality: 80 });| Wikipedia article, 1280 × 4487 | File size |
|---|---|
| PNG | 852 KB |
| JPEG, quality 80 | 637 KB |
| WebP, quality 80 | 404 KB |
The same capture with one request
Running Puppeteer on a laptop is easy. Running it in production means keeping browsers alive, capping memory and timeouts, and refusing user-submitted URLs that point at your internal network. If you'd rather not own that, send the URL to Shotkiln:
curl -D - "https://shotkiln.com/v1/screenshot?url=https://en.wikipedia.org/wiki/Screenshot&full_page=true&format=png&cache_ttl=0" \
-H "x-api-key: $SHOTKILN_KEY" -o full-page.pngHTTP/2 200
content-type: image/png
x-cache: BYPASS
x-render-ms: 4177
x-scroll: complete
x-scroll-ms: 1318
x-target-status: 200It came back as the same 1280 × 4487 image, footer logos included, in 4.2 seconds of render time. The API runs the same kind of scroll pass as the script above before every full-page capture: x-scroll: complete means it reached the bottom, here after 1.3 seconds. Add scroll=false to capture the page as it first loads. Across five runs of this page, with and without the scroll pass, the render took 4.2 to 6.1 seconds. For pages behind a login, keep your own script, since the API renders public pages only.