Shotkiln Get a free key
← Website screenshot guidesGuide

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.

· Tested with Puppeteer 25.14.0 on Node 22 · 7 min read

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.

full-page.mjsnode full-page.mjs
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 pagePlain fullPage: trueFull script below
8 images with loading="lazy"4 of 8 loaded, images 5 to 8 grey8 of 8
8 images loaded by IntersectionObserver0 of 88 of 8
Cookie bar with position: fixed; bottom: 0Painted once, at the bottom of the first screenHidden
Rows scrolling inside <main>1280 × 800, the first rows onlyNeeds the container fix below: 1280 × 5640
Page 30,000 px tallOne PNG, correct top to bottomSame

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.

full-page.mjsnode full-page.mjs https://example.com
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.

  1. Viewport first. The image width is the viewport width, so set it before goto. Use deviceScaleFactor: 2 in setViewport for a retina image at twice the pixels.
  2. 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.
  3. Scroll back to the top. Sticky headers and scroll-linked animations then sit where a visitor first sees them.
  4. Wait twice. waitForNetworkIdle catches the image requests the scroll started; document.fonts.ready stops 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:

node
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:

node
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:

node
await page.screenshot({ path: 'full-page.webp', fullPage: true, type: 'webp', quality: 80 });
Wikipedia article, 1280 × 4487File size
PNG852 KB
JPEG, quality 80637 KB
WebP, quality 80404 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:

curlimage/png
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.png
response headers from our run
HTTP/2 200
content-type: image/png
x-cache: BYPASS
x-render-ms: 4177
x-scroll: complete
x-scroll-ms: 1318
x-target-status: 200

It 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.

Frequently asked questions

Why does my Puppeteer full-page screenshot have grey boxes?
Those are lazy-loaded images that were never requested, because nothing scrolled the page. Scroll down in half-screen steps with short pauses, call page.waitForNetworkIdle(), then take the screenshot.
Can I use clip with fullPage in Puppeteer?
No. Puppeteer throws "'clip' and 'fullPage' are mutually exclusive". Drop fullPage and pass a clip with the y offset you want: in our test, a clip starting 2,000 px down captured that region correctly.
How do I get a full-page screenshot as base64 in Puppeteer?
Pass encoding: 'base64' to page.screenshot and it returns a string instead of a buffer. Leave out path if you don't want a file written as well.
How do I take a screenshot with a transparent background?
Set omitBackground: true and save as PNG or WebP. It only shows through where the page has no background colour of its own, and most sites set one on the body.
How do I screenshot one element instead of the whole page?
Get a handle with page.$('selector') and call screenshot on it. Puppeteer scrolls the element into view and captures all of it, even past the viewport: our article element came out 4,049 px tall.

Skip the browser fleet

One GET request returns the full page as PNG, JPEG or WebP. 100 free screenshots a month, no card.

Related