· Updated · Tutorials · By Simon Chiu

Puppeteer Full Page Screenshot: Limits, Lazy Images, Fixes

Puppeteer full page screenshot guide: the 16,384 px limit we measured, captureBeyondViewport, lazy-load scrolling, sticky headers and a tested script.

Line illustration of the RenderScreenshot mascot beside a tall browser unrolling a long full-page screenshot

Puppeteer takes a full page screenshot with one option:

import puppeteer from 'puppeteer';

const url = process.argv[2] ?? 'https://example.com';
const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800 });
  await page.goto(url, { waitUntil: 'networkidle2' });
  await page.screenshot({ path: 'full-page.png', fullPage: true });
} finally {
  await browser.close();
}

The image is as wide as the viewport and as tall as the document. For short pages that is the whole story. For long pages we measured three problems: content repeats after 16,384 device pixels, lazy images stay blank, and fixed bars land in the wrong place. This guide shows each problem on a purpose-built test page, the fix, and one tested script that combines the fixes. It is a deep dive that follows our basic Puppeteer screenshot guide; for other tools, see full page screenshots compared.

Tested on 2026-10-15 with Puppeteer 25.14.0 (Chrome for Testing 155.0.8059.79 and chrome-headless-shell 155.0.8059.79), sharp 0.35.5 and Node 22.18.0 on macOS 27.0.1 (Apple Silicon). Times are medians of 3 runs against a local server.

Setup

npm i [email protected] [email protected]
# optional, for headless: 'shell'
npx puppeteer browsers install chrome-headless-shell

The examples are ES modules with top-level await. Each one closes the browser in finally, so an exception still ends the Chrome process and exits with a non-zero status.

Setting the right viewport width

You choose the width; the page decides the height. A wider viewport usually gives a shorter page, because content flows into more columns. A narrow viewport can make the same page several times taller, which matters for the height limit below.

await page.setViewport({ width: 1280, height: 800 });  // common desktop
await page.setViewport({ width: 1920, height: 1080 }); // full HD
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); // phone

The height still matters with fullPage: it sets the size of 100vh and decides which elements count as "in view" for lazy loaders.

For sharper output use deviceScaleFactor: 2. The image then has twice the width and twice the height in pixels, four times the pixel count, and it reaches the height limit at half the page length.

How tall can a Puppeteer full page screenshot be?

We built pages of 1,000 px colored bands with a magenta END band, from 8,000 to 100,000 px tall, and checked the color at the center of every band in the output.

Page height Default launch (new headless), 1x Default launch, deviceScaleFactor: 2 headless: 'shell', 1x
8,000 px Correct (1280 x 8,000) Correct (2560 x 16,000) Correct
8,500 px Correct Repeats after 16,384 device px —
16,000 px Correct Repeats —
16,500 px Repeats after 16,384 px Repeats —
20,000 px Repeats Repeats Correct
50,000 px Repeats — Correct
100,000 px Repeats (1,091 ms, 1.2 MB) — —

"Repeats" means the PNG has the right size, but from 16,384 device pixels down Chrome drew the top of the page again. Puppeteer reports no error. The same thing happened when we pointed Puppeteer at Google Chrome 155 and at Playwright's Chromium 156, so it is a property of full Chrome, not of one build.

Two Puppeteer captures of a 20,000 pixel page with repeating color bands. In the fullPage capture the first red band appears again at about 16,400 pixels and the magenta END band is missing. The capture made of 8,000 pixel clips ends with the magenta band.
20,000 px test page: fullPage: true with the default launch (left) and three 8,000 px clips joined with sharp (right). Puppeteer 25.14.0, Chrome 155, 2026-10-15.

We tried four workarounds on the 20,000 px and 50,000 px pages:

Workaround 20,000 px 50,000 px Time at 20,000 px
fullPage: true (default) Repeats Repeats 281 ms
fullPage: true, captureBeyondViewport: false Repeats Repeats 336 ms (one run)
setViewport({ height: pageHeight }), then a plain screenshot Repeats Repeats 291 ms (one run)
Clips of 8,000 px with captureBeyondViewport, joined with sharp Correct Correct 372 ms
puppeteer.launch({ headless: 'shell' }) Correct Correct not timed

Two options work. headless: 'shell' runs chrome-headless-shell, the old headless implementation, which did not have the limit in our tests. Clips work in any mode, including headed Chrome, because no single capture is taller than the limit. A clip of 8,000 CSS px stays below 16,384 device pixels at a device scale factor of 2.

captureBeyondViewport: what it changes

In current Puppeteer, captureBeyondViewport defaults to true: Chrome draws content outside the viewport without changing the page's layout. With false, Puppeteer resizes the viewport to the full page height before the capture. The difference shows on pages with sections sized in viewport units:

Mode Height of a 100vh hero on our sticky page
captureBeyondViewport: true (default) 800 px, as in a browser
captureBeyondViewport: false 4,604 px, almost the whole page
false, after freezing 100vh elements to pixel heights 800 px
Two captures of the sticky test page with captureBeyondViewport false. On the left the orange 100vh hero fills almost the entire image. On the right, after the hero height is frozen to 800 pixels, the hero is normal and the content sections follow it.
Sticky test page with captureBeyondViewport: false: as loaded (left) and after freezing 100vh elements (right). 2026-10-15.

Keep the default unless you have a reason to change it. One reason: some pages compute layout in JavaScript from window.innerHeight, and a real resize makes them render everything. If you set false, freeze viewport-height elements first; the neutralizeFixedElements function below does that.

Handling lazy-loaded content

fullPage does not scroll. Images that wait for an IntersectionObserver, a scroll listener or the browser's loading="lazy" stay as placeholders. Our lazy page has 20 images, each served after 800 ms. Completeness in the final PNG:

Before the screenshot loading="lazy" IntersectionObserver Scroll listener Median time
Nothing (waitUntil: 'load') 0% 0% 10% 0.2–1.0 s
scrollTo bottom, then top 0% 0% 10% 0.2–1.0 s
Scroll one viewport at a time, 100 ms pauses 60% 60% 60% 2.2–3.1 s
Same, then wait for images 100% 100% 100% 3.4–4.2 s
Set loading="eager", copy data-src to src, wait 100% 100% 100% 3.4–3.6 s

Scrolling without a wait lost the bottom 40%: the scroll was over before those images arrived. The older advice to scroll and then sleep a fixed two seconds works only when you guess the time right. Wait for a condition instead.

Puppeteer removed page.waitForTimeout in v22. If you need a pause, use await new Promise((r) => setTimeout(r, ms)), but prefer waiting for the thing you need. Here is the helper we use. It runs inside the page through page.evaluate:

// prepare.js: in-page helpers. Each one runs inside the page via page.evaluate().

// Scroll one viewport at a time so lazy loaders fire, then wait for images.
export async function preloadLazyContent({ pause = 150, maxHeight = 16000, timeout = 15000 } = {}) {
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  for (const img of document.querySelectorAll('img[loading="lazy"]')) img.loading = 'eager';
  let y = 0;
  while (y < Math.min(document.documentElement.scrollHeight, maxHeight)) {
    y += window.innerHeight;
    window.scrollTo(0, y);
    await sleep(pause);
  }
  window.scrollTo(0, 0);
  const pending = [...document.images].filter((img) => img.getAttribute('src') && !img.complete);
  await Promise.race([
    Promise.all(pending.map((img) => img.decode().catch(() => {}))),
    sleep(timeout),
  ]);
}

// Pin fixed and sticky elements to the top of the document; drop bottom bars.
// Also freeze elements sized to the viewport height (100vh heroes).
export function neutralizeFixedElements() {
  window.scrollTo(0, 0);
  const vh = window.innerHeight;
  const all = [...document.querySelectorAll('body *')];
  const boxes = all.map((el) => [el, getComputedStyle(el).position, el.getBoundingClientRect()]);
  for (const [el, position, box] of boxes) {
    if (Math.abs(box.height - vh) < 2) el.style.setProperty('height', `${vh}px`, 'important');
    if (position === 'fixed') {
      if (box.top > vh / 2) el.style.setProperty('display', 'none', 'important');
      else el.style.setProperty('position', 'absolute', 'important');
    } else if (position === 'sticky') {
      el.style.setProperty('position', 'static', 'important');
    }
  }
}

Two details came from failed runs. The first version filtered pending images on img.currentSrc; an image whose src was set a moment before has an empty currentSrc, so the helper did not wait for it and the PNG lost 2 of 20 images. The maxHeight cap stops infinite feeds, which grow as you scroll. page.waitForNetworkIdle({ idleTime: 500 }) is an alternative to the image wait, but it never settles on pages with analytics beacons or long polling, so give it a timeout. The lazy image guide compares more strategies, including background images.

With the default fullPage, Puppeteer does not stitch screens, so a fixed header appears once, at the top. The cookie bar fixed to the bottom of the window is the real problem: Chrome drew it across the content at 740 px, where the first viewport ended. Stitching viewport screenshots yourself is worse: our sticky page came out with 6 headers and 6 cookie bars.

neutralizeFixedElements above removes the cookie bar, pins the header to the top of the document and makes sticky navigation static. It reads every box before it writes any style, so the page reflows once, not once per element. The blunt older approach, hiding every fixed or sticky element with display: none, also removes the site header, which most people want to keep.

When you know the site, prefer exact selectors with page.addStyleTag:

await page.addStyleTag({
  content: `
    header.site-header { position: absolute !important; }
    #cookie-banner, .chat-launcher { display: none !important; }
  `,
});

The sticky header guide shows the four failure modes side by side.

The complete script

This script combines the fixes. It scrolls for lazy content, neutralizes fixed elements, and switches to clips when the page is taller than the limit.

import puppeteer from 'puppeteer';
import sharp from 'sharp';
import { preloadLazyContent, neutralizeFixedElements } from './prepare.js';

const url = process.argv[2] ?? 'https://example.com';
const out = process.argv[3] ?? 'full-page.png';
const SCALE = 1;
const MAX_TEXTURE = 16384; // device pixels; Chrome repeated content past this in our tests
const SEGMENT = Math.floor(8000 / SCALE);

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: SCALE });
  await page.goto(url, { waitUntil: 'networkidle2', timeout: 60_000 });
  await page.evaluate(preloadLazyContent, { pause: 150 });
  await page.evaluate(neutralizeFixedElements);

  const { width, height } = await page.evaluate(() => ({
    width: document.documentElement.scrollWidth,
    height: document.documentElement.scrollHeight,
  }));

  if (height * SCALE <= MAX_TEXTURE) {
    await page.screenshot({ path: out, fullPage: true });
  } else {
    // Capture in clips below the limit and join them with sharp.
    const parts = [];
    for (let y = 0; y < height; y += SEGMENT) {
      const clip = { x: 0, y, width, height: Math.min(SEGMENT, height - y) };
      parts.push({ input: await page.screenshot({ clip }), top: y * SCALE, left: 0 });
    }
    await sharp({
      create: { width: width * SCALE, height: height * SCALE, channels: 4, background: '#fff' },
      limitInputPixels: false,
    }).composite(parts).png().toFile(out);
  }
  console.log(`saved ${out} (${width}x${height} CSS px)`);
} finally {
  await browser.close();
}

Run it with node full-page.mjs <url> <file>. Results on our pages: all 20 bands of the 20,000 px page, 20 of 20 lazy images in 3 of 3 runs, and one header with no cookie bar on the sticky page. Puppeteer passes page.evaluate(fn, arg) the function source, so the helpers must not use variables from your Node module.

Infinite scroll pages

Feeds, search results and some product listings add content as you scroll, so "the bottom" moves away. Decide how much is enough: a height (the maxHeight option above), a number of items, or a time budget. Re-read document.documentElement.scrollHeight on every step, as the helper does, because it grows while you scroll. If you need a fixed number of items, wait for them with page.waitForFunction(() => document.querySelectorAll('.result').length >= 50) and then capture only that part with clip or an element screenshot.

Image size and format

Full pages make large files. PNG is lossless and the largest; JPEG and WebP at quality 80 to 85 are usually much smaller and look the same for documentation and archives. Puppeteer supports all three:

await page.screenshot({ path: 'full-page.webp', type: 'webp', quality: 80, fullPage: true });

How much you save depends on the page: our flat-color test pages are not a fair sample, so measure on your own URLs. If you only need the top of a long page, clip with a fixed height is cheaper than a full capture you crop later.

Production notes

  • Timeouts. Scroll time grows with page length: at 150 ms per 800 px step, a 16,000 px page spends 3 seconds scrolling before the capture. Set goto and page.setDefaultTimeout() with that in mind. The timeouts and memory leaks guide covers what to do when they fire.
  • Memory. A 1280 x 16,000 PNG is 20 million pixels before encoding; at deviceScaleFactor: 2 it is 80 million. Limit how many full-page captures run at once, and see scaling Puppeteer screenshots.
  • Cleanup. Close the browser in finally, as every example here does, or Chrome processes stay behind after an error.

Troubleshooting

Symptom Cause Fix
Bottom of the image repeats the top Full Chrome stops drawing after 16,384 device px headless: 'shell' or clips of 8,000 px
Repeats on a page of only 8,500 px deviceScaleFactor: 2 doubles the pixel height Clips of 8000 / scale CSS px
page.waitForTimeout is not a function Removed in Puppeteer 22 await new Promise((r) => setTimeout(r, ms)), or wait for a condition
Grey placeholders below the first screen Lazy loading never fired preloadLazyContent before the capture
A few images still missing Waited on currentSrc or a fixed sleep Wait on images that have a src attribute
Cookie bar across the middle Fixed bottom bar drawn at the end of the first viewport Remove it with addStyleTag or neutralizeFixedElements
Header repeated every screen You stitched viewport screenshots Use fullPage, or clips with captureBeyondViewport
Hero fills the whole image captureBeyondViewport: false resized the viewport Keep the default, or freeze 100vh elements
waitForNetworkIdle never resolves Beacons or long polling keep the network busy Pass a timeout, or wait for images instead
sharp throws "Input image exceeds pixel limit" Very tall composite limitInputPixels: false, as in the script

The same capture with one API call

If full-page captures are part of your product, the code above needs updates whenever Chrome, Puppeteer or the target sites change. RenderScreenshot runs the scroll on its side when you use the full_page preset:

curl -G "https://api.renderscreenshot.com/v1/screenshot" \
  -H "Authorization: Bearer $RS_API_KEY" \
  --data-urlencode "url=https://example.com" \
  --data-urlencode "preset=full_page" \
  --data-urlencode "remove=#cookie-banner" \
  -o full-page.png

capture.scroll.step, delay and max_height (default 16,000 px) tune the scroll in a POST body; the full-page API guide lists them, and capture parameters is the reference. Get a free API key to compare the result with your Puppeteer output.


Related in this topic