Web capture engineering

Full-page screenshots with Puppeteer — and where they break

`page.screenshot({fullPage: true})` is correct for a finite document whose content already exists. Real pages often create content only after it enters the viewport, keep fixed elements attached while the viewport moves, or grow while you measure them.

4 min readPublished Updated
What you actually see

The output has blank image slots below the first fold, a sticky header repeated through the image, or a blank/truncated tail on a very tall document.

Reproduce it

  1. 01Open a page whose images use IntersectionObserver and call `screenshot({fullPage:true})` immediately after `load`.
  2. 02Compare the result with a manual scroll: the browser screenshot expands the surface, but it does not guarantee every lazy element entered the viewport first.
  3. 03Repeat with an infinite feed or a document above 16,000 CSS pixels and record height, output dimensions and whether the final rows contain pixels.
The DIY version

Start with the smallest thing that works

Walk the viewport before capture, bound the walk, return to the top and only then ask Puppeteer for the document image. This is a useful baseline, not a universal algorithm.

full-page.mts
import puppeteer from "puppeteer";

const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 900 });
await page.goto("https://example.com", { waitUntil: "load" });

for (let step = 0; step < 80; step++) {
  const done = await page.evaluate(() => {
    const before = scrollY;
    scrollBy(0, innerHeight);
    return scrollY === before || scrollY + innerHeight >= document.documentElement.scrollHeight;
  });
  await new Promise((resolve) => setTimeout(resolve, 400));
  if (done) break;
}

await page.evaluate(() => scrollTo(0, 0));
await page.screenshot({ path: "full.png", fullPage: true });
await browser.close();
Where it breaks

The call is small. Everything around it is the system.

The document grows while you walk it

A feed can append another viewport on every intersection. Without a maximum height or step count, the supposedly finite capture never becomes ready.

Fixed elements are stateful

Sticky headers, chat launchers and animations can paint differently at each scroll position. A scroll-through changes page state; taking one final native screenshot does not undo every mutation.

The output exceeds a browser surface

Very tall captures can return blank or truncated pixels even when navigation succeeded. Slice-and-stitch is a separate algorithm with overlap and memory tradeoffs.

Readiness is not network silence

Analytics, polling and websockets make `networkidle0` wait forever, while lazy images may start after a network-idle window already passed.

Keep the DIY version when

  • You own one finite page, its assets are eager, and a failed image is visible to an operator.
  • The browser already exists for another core workflow and the screenshot does not need a queue, tenant isolation or external delivery.
The recipe

how to take a full-page screenshot with Puppeteer

The request makes scrolling, delay, maximum height and slicing part of the public input. The engine bounds infinite pages and switches from native full-page capture to slices before the tall-surface failure point.

Capture settings
Enter the source, choose your options, then run the capture.
Result
Your capture will appear here and stay in view.
waiting

No result yet

Complete the settings and run the tool. Images, PDFs, text, and video all preview in this panel.

Send this exact request
cURL, TypeScript and Python are generated from the same configuration as the demo.
curl --fail-with-body "https://api.pagecapture.dev/v1/take" \
  -H "X-Access-Key: $PAGECAPTURE_KEY" \
  -H "Content-Type: application/json" \
  --data '{"url":"https://example.com","format":"png","full_page":true,"full_page_scroll":true,"full_page_scroll_delay":400,"full_page_max_height":30000}' \
  -o capture.png
What you get, and what it costs

Output, limits, cost, failures

Output

One PNG of the bounded document. Above the native threshold the worker captures bands and stitches them; `full_page_slices=true` can also expose the individual bands.

Cost

One successful, non-cached capture costs one credit. Failures, including platform failures, cost zero; cache hits cost zero.

1 creditper successful non-cached capture

Limits

  • `full_page_max_height` cannot exceed 60,000 CSS pixels.
  • A slice is at most 16,000 pixels high; overlap must be smaller than the slice height.
  • Scroll-through triggers lazy content but cannot make an infinite feed finite without a height ceiling.

Common errors

Viewport, scale or final dimensions exceed the pixel budget.

HTTP 400 · terminal

timeout_errornever billed

The page or its chosen readiness state consumes the total capture budget.

HTTP 500 · retryable

Try it against your own page

The runner above carries this exact configuration into the playground — no retyping, no starting over.

Return to the configured runner
Tested and reviewed

Written by PageCapture Engineering. Reviewed by PageCapture render maintainers. Verified 2026-08-12 by running the published code against a fixture.

The API recipe is normalized in unit tests; generated cURL, TypeScript and Python pass syntax checks; full-page fallback and scroll bounds are asserted against the render implementation.

engine-crunknown-pwunknown-f2026-07-1-b2026-07-1StatusFull-page contract referenceEditorial method

Sources and verification basis

Sources support the browser and API behaviors named above. PageCapture-specific limits and billing are taken from the public contract; external sources are used for the underlying browser behavior.

  1. Defines the native Puppeteer screenshot call and `fullPage` behavior.

  2. Documents the public scroll, height and slice contract used by the recipe.