Puppeteer Full-Page Screenshots: The Complete Guide (and the 7 Things That Break in Production)

Puppeteer Full-Page Screenshots: The Complete Guide (and the 7 Things That Break in Production)

author

Snapshot Site Team

03 Aug 2026 - 13 Mins read

page.screenshot({ fullPage: true }) is a one-line API that works perfectly on the first page you try it on and then fails, quietly and in seven distinct ways, on the pages you actually care about. Nothing throws. You get a PNG. The PNG is just wrong — half the images are grey placeholders, the header appears four times down the strip, the chat widget is sitting in the middle of a pricing table, and the fonts are the browser's fallback serif instead of the ones the design uses.

This is the guide for both halves of that. First, how to get a full-page capture that is actually correct, with the launch and viewport options that matter and the waiting strategy that decides everything. Then the seven failures that show up once you point the script at real sites, each with a diagnosis and a fix.

Everything below is Node with ESM ("type": "module" in your package.json, or a .mjs file) and a current Puppeteer major. Where an API changed recently, I say so.

The five lines that work

import puppeteer from "puppeteer";

const browser = await puppeteer.launch();

try {
  const page = await browser.newPage();

  await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
  await page.goto("https://example.com", { waitUntil: "load", timeout: 30_000 });
  await page.screenshot({ path: "full.png", fullPage: true });
} finally {
  await browser.close();
}

puppeteer.launch() with no arguments is headless and downloads its own matching Chrome build at install time. Two things worth knowing before you start pasting flags from Stack Overflow. headless: true now means the real headless Chrome — the old, separately-built headless binary that rendered subtly differently from a visible browser is now opted into explicitly with headless: "shell", and you almost certainly don't want it for screenshots. And --no-sandbox, which appears in roughly every Docker snippet on the internet, is a container workaround, not a default: add it when your runtime forces you to, not on your laptop.

The try/finally is not stylistic. A thrown navigation error without it leaves a Chrome process alive, and a loop over a few hundred URLs leaves a few hundred of them alive.

One other recent change to be aware of: page.screenshot() returns a Uint8Array in current majors, where older versions returned a Node Buffer. If you're piping the result into something that type-checks for a Buffer, wrap it with Buffer.from().

Viewport, scale, and what fullPage actually does

setViewport() sets the CSS pixel dimensions of the window. In a full-page capture the width is the part that survives into the image dimensions — the height gets overridden by the document — but the height is not therefore irrelevant: it's what vh units resolve against, what IntersectionObserver measures its thresholds from, and, as failure three below depends on, the box that fixed elements think they're pinned to. Width matters because it selects which responsive breakpoint renders. 1440 gets you the desktop layout of most sites; 1280 is a common default; drop to 390 and you get the mobile layout, with all of that layout's different lazy-loading and different sticky behaviour. The same reasoning applies when you're deliberately capturing phone and tablet layouts — width and height presets for iPhone, Android and tablet screens covers what a viewport size does and does not reproduce.

deviceScaleFactor is the device pixel ratio. At 1, a 1440-CSS-pixel-wide page produces a 1440-pixel-wide image. At 2, the same page produces 2880 pixels of width and four times as many pixels overall. More on the cost of that in failure five.

What fullPage does under the hood is the source of most of the confusion in this article: Puppeteer measures the document, expands the capture region to the full content height, and grabs it in one shot. It does not scroll. The page is never moved. Whatever the page would have done in response to a user scrolling from top to bottom — loading images, firing entrance animations, appending more feed items, pinning a header — does not happen, or happens too late to be in the frame. Every one of the seven failures below is a consequence of that single fact.

Worth pausing on before you go further, because it reframes half of what follows: every failure in the second part of this article is something fullPage introduces. A viewport capture has no seams, no stretched fixed elements, no unbounded height, and nothing below the fold to lazy-load. If you turned fullPage on because it seemed like the more complete option rather than because a downstream consumer needs the whole document, turning it off removes four of the seven outright — the lazy-load gap, both positioning failures and the infinite-scroll problem — and takes the height ceiling in failure five off the table as well. Fonts and cookie banners it does not touch; the banner problem it arguably makes worse, since a viewport capture is all fold. Full page vs viewport works through when each is the right record.

Waiting is the part that decides whether the capture is any good

page.goto() takes a waitUntil option with four values, and the difference between them is the difference between a good screenshot and a grey rectangle:

  • domcontentloaded — the DOM is parsed. Stylesheets, images and most JavaScript have not necessarily run. Almost never right for a screenshot.
  • load — the load event fired: subresources referenced by the initial HTML are in. Reasonable default for server-rendered pages.
  • networkidle2 — no more than two network connections for at least 500 ms.
  • networkidle0 — no network connections at all for at least 500 ms.

networkidle0 is the one every tutorial recommends and the one that will waste the most of your time. It is a heuristic about the network, and plenty of perfectly normal pages never satisfy it: anything that long-polls, holds an open EventSource, keeps a heartbeat XHR running, or fires periodic analytics beacons will keep the connection count above zero indefinitely. Your script then sits there until the navigation timeout expires and throws TimeoutError: Navigation timeout of 30000 ms exceeded — on a page that finished rendering in 900 milliseconds.

It also fails in the other direction. A client-rendered app can go completely quiet after its initial bundle lands, satisfy networkidle0, and only then hydrate and start fetching the data that fills the page. You get a beautifully idle screenshot of an empty shell.

You can pass an array — waitUntil: ["load", "networkidle2"] — which waits for both, and that combination is a decent generic default. But the reliable pattern is to stop guessing about the network and wait for something you actually care about:

await page.goto(target, { waitUntil: "load", timeout: 30_000 });

// Wait for a real signal from the page itself.
await page.waitForSelector("[data-testid='pricing-table']", { timeout: 15_000 });

// Or wait for a condition you can express.
await page.waitForFunction(
  () => document.querySelectorAll(".product-card").length >= 12,
  { timeout: 15_000 },
);

And when you genuinely just need to let something settle, note that page.waitForTimeout() was deprecated and then removed. The replacement is a plain promise:

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
await wait(1000);

A fixed sleep is a bad primary strategy and a perfectly good backstop after a real wait condition. Use both.

clip, and capturing one element

clip takes a region in CSS pixels and captures exactly that:

await page.screenshot({
  path: "hero.png",
  clip: { x: 0, y: 0, width: 1440, height: 700 },
});

clip and fullPage are mutually exclusive — pass both and Puppeteer throws. If you want a region from further down a long page, you take the coordinates from the page itself rather than guessing them.

For a single component, don't compute coordinates at all. Element handles have their own screenshot(), and it scrolls the element into view for you:

const card = await page.waitForSelector(".pricing-card--pro");
await card.screenshot({ path: "card.png" });

That is the right tool for capturing components, table rows, chart widgets, or anything you'd otherwise be cropping by hand. The caveats are that an element with zero dimensions throws, and an element inside its own scroll container gives you the visible part of it, not its full scroll height.

Also useful on the screenshot() call itself: type accepts png, jpeg and webp; quality applies to the lossy two only; and omitBackground: true gives you a transparent PNG if the page doesn't paint its own background, which most do.

The seven things that break in production

1. Lazy-loaded images never load

The most common complaint behind "puppeteer full page screenshot not working," and a direct consequence of fullPage not scrolling. Images with loading="lazy", or hooked to an IntersectionObserver, only fetch when they approach the viewport. In a full-page capture the document is never scrolled through, so those images are never requested — or are requested at the last moment and don't come back before the shutter fires. You get the top of the page rendered correctly and everything below it as blank boxes.

Two fixes, and you generally want both. Force eager loading, then wait for the images to actually complete:

await page.evaluate(() => {
  for (const img of document.querySelectorAll('img[loading="lazy"]')) {
    img.loading = "eager";
  }
});

await page.evaluate(async (budgetMs) => {
  const timeout = new Promise((resolve) => setTimeout(resolve, budgetMs));
  const pending = Array.from(document.images)
    .filter((img) => !img.complete)
    .map(
      (img) =>
        new Promise((resolve) => {
          img.addEventListener("load", resolve, { once: true });
          img.addEventListener("error", resolve, { once: true });
        }),
    );

  await Promise.race([Promise.all(pending), timeout]);
}, 8000);

The Promise.race against a budget is not optional. One image behind a dead CDN with no error event will hang that evaluate forever otherwise.

Flipping the loading attribute doesn't help sites that lazy-load through their own observer or a data-src swap, and for those you have to actually move the page. That's the scroll helper in failure six. The full set of techniques, including the animation side of the same problem, is in waiting for lazy-loaded images and animations before screenshots.

2. The sticky header sits in the wrong place

A one-shot capture paints once, so a sticky header resolves exactly once too — at whatever scroll offset the page happens to be sitting at when the shutter fires.

From a clean navigation that offset is zero, the sticky bar sits in normal flow exactly where the layout puts it, and the capture is fine. The failure only appears once something has scrolled the page. If anything left it scrolled — most obviously the scroll helper in failure six, if you skip its return to the top — the bar has already detached and pinned itself, and it paints wherever it was pinned, which is somewhere in the middle of your 9,000-pixel image with a gap in the flow where it used to be.

Headers built with position: fixed rather than sticky behave differently and worse, because they reserve no layout space at all and are pinned to a viewport that fullPage has stretched to the height of the document. That's failure three, and the fix there is not the fix here.

The header appearing repeatedly down a long strip — at 0px, and again at 2,400px, and again at 4,800px — is a different failure with a different cause. That's the signature of scroll-and-stitch capture, where each viewport-sized tile catches the pinned bar and the tiles are pasted together afterwards. Puppeteer's fullPage path doesn't tile, so you won't see it here; you will see it the moment you or a library you're using falls back to stitching for a page too tall to capture in one shot.

The fix is to take the element out of sticky positioning before you capture, so there is no pinned state to resolve at all. Doing it by computed style catches the elements you don't have a selector for:

await page.evaluate(() => {
  for (const el of document.querySelectorAll("body *")) {
    if (getComputedStyle(el).position === "sticky") {
      el.style.position = "static";
    }
  }
});

That loop reads computed styles for every element, which is genuinely slow on a very large DOM — scope it to a container if you can. And it can reflow a layout that depended on the sticky element's offset, so look at the output before you trust it. Why sticky headers break full-page screenshots covers the duplication and overlap symptoms in more detail.

3. position: fixed elements land in the middle of the page

Same root cause, different fix. Chat launchers, cookie bars, back-to-top buttons and "3 people are viewing this" toasts are pinned to the viewport, and in a full-page capture the viewport has been stretched to the height of the document. So the widget that lives politely in the bottom-right corner of a 900-pixel window ends up in the bottom-right corner of a 9,000-pixel image, floating over whatever content happens to be there.

Do not reuse the static trick here. Setting a fixed element to static drops it back into normal document flow, which usually means it appears inline in the middle of your content — a worse outcome than where it started. Remove it instead:

const OVERLAY_SELECTORS = [
  ".intercom-lightweight-app",
  "#onetrust-banner-sdk",
  "[class*='back-to-top']",
];

await page.evaluate((selectors) => {
  for (const selector of selectors) {
    for (const el of document.querySelectorAll(selector)) {
      el.remove();
    }
  }
}, OVERLAY_SELECTORS);

A blanket "hide everything with computed position: fixed" is tempting and occasionally correct, but it also removes legitimate fixed layout — sidebars in app shells, modal content you actually wanted. A maintained selector list per site is uglier and gives better screenshots.

4. Fonts aren't loaded and the text is wrong

The subtler failure, because the screenshot looks fine until someone who knows the brand looks at it. Web fonts load asynchronously. If you capture during the gap, you bake FOIT (invisible text) or FOUT (fallback text) permanently into the image, and the line breaks are wrong too, because the fallback has different metrics.

load doesn't wait for fonts. networkidle0 usually does by accident, which is part of why people cargo-cult it. The precise signal exists:

await page.evaluate(async () => {
  await document.fonts.ready;
});

Note the wrapping async function. Returning document.fonts.ready directly from evaluate tries to serialise a FontFaceSet back across the protocol and fails; awaiting it inside and returning nothing is the correct shape.

Two related traps. A font that 404s never resolves as loaded, and document.fonts.ready still settles — so you can wait correctly and still capture fallback text. And in containers, the font may be fine while the system fallback is missing entirely, which is how you end up with tofu boxes for CJK, Arabic or emoji. That family of "the capture succeeded but the pixels are wrong" symptoms is catalogued in the field guide to debugging blank and broken screenshots.

While you're here, kill motion as well as waiting for fonts. Puppeteer can request reduced motion at the media-feature level, which stops most CSS entrance animations from being caught mid-transition:

await page.emulateMediaFeatures([
  { name: "prefers-reduced-motion", value: "reduce" },
]);

Sites that animate with JavaScript ignore it, and for those you're back to a settle delay.

5. deviceScaleFactor, and the ceiling nobody warns you about

At the default deviceScaleFactor: 1 your captures are soft on any retina display — fine for automated diffing, visibly cheap in a customer-facing report or a marketing asset. Bumping to 2 fixes that and quadruples the pixel count. A 1440 × 8000 page becomes 2880 × 16000: about 46 megapixels, which for a screenshot-flat PNG is typically a few megabytes and can be considerably more on an image-heavy page.

The ceiling is the part that surprises people. Chromium has a maximum texture dimension, commonly 16,384 pixels, and a full-page capture that exceeds it in device pixels can come back truncated, blank, or as a failed screenshot with an unhelpful error. At deviceScaleFactor: 1 you'd need a 16,000-pixel-tall document to hit that. At 2 you hit it at 8,000 CSS pixels, which is an ordinary long marketing page. If your 2× captures start failing on exactly the pages that worked at 1×, this is why.

The practical rule: use 2 for anything a human will look at, keep 1 for diffing and monitoring where you're comparing pixels to pixels rather than admiring them, and if you need both retina output and a very long page, capture in clip regions rather than one fullPage shot.

6. Infinite scroll has no correct answer

On a feed, fullPage measures the document once and captures what exists at that moment — typically the first screen or two of items. Scroll to load more and the document grows; scroll again and it grows again. There is no "full page." There is only the height you decided to stop at.

Accept that and make the cutoff explicit rather than emergent:

async function scrollThroughPage(page, { step = 600, pause = 150, maxPasses = 40 } = {}) {
  await page.evaluate(
    async (step, pause, maxPasses) => {
      const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

      // Smooth scrolling makes every position read a mid-animation guess.
      document.documentElement.style.scrollBehavior = "auto";

      let previous = -1;

      for (let pass = 0; pass < maxPasses; pass += 1) {
        window.scrollBy(0, step);
        await wait(pause);

        if (window.scrollY === previous) break; // reached the bottom
        previous = window.scrollY;
      }

      window.scrollTo(0, 0);
      await wait(pause);
    },
    step,
    pause,
    maxPasses,
  );
}

On a finite page this terminates when the scroll position stops moving, and it doubles as the fix for observer-driven lazy loading in failure one. On an infinite feed it terminates at maxPasses, and that number is a product decision you are now making on purpose: 40 passes of 600 pixels is roughly 24,000 pixels of feed, which is both a lot of screenshot and an arbitrary boundary. Two runs an hour apart will disagree about what the page contained. Why infinite scroll is the enemy of screenshots explains why a feed has no stable "full page" to capture in the first place.

The scroll-back-to-top at the end matters more than it looks. Leaving the page scrolled changes how sticky elements resolve and can shift where fixed overlays land in the final image.

7. Cookie banners cover the fold

Half your captures are a dark scrim with a consent dialog centred on it. Clicking "Accept" works exactly until the next site, because there is no shared selector, no shared markup and no standard.

Two approaches that hold up better than clicking. Set the consent cookie before you navigate, so the banner never renders:

await browser.setCookie({
  name: "cookie_consent",
  value: "accepted",
  domain: ".example.com",
  path: "/",
});

(browser.setCookie() is the current API. page.setCookie() is still present and still works — it's deprecated, not removed, so existing code isn't broken.) You have to find the right cookie name and value per site once, which is tedious but stable — and it's usually more robust than the click, because it also skips the dismissal animation.

Otherwise, remove the banner from the DOM after load. Which brings up the trap that catches everyone: consent banners commonly lock scrolling by setting overflow: hidden on <html> or <body>. Delete the banner and leave the lock in place, and the document height measures as one viewport — so your fullPage capture silently becomes a viewport capture, with no error anywhere. Undo both:

await page.evaluate(() => {
  document.querySelector("#onetrust-consent-sdk")?.remove();
  document.documentElement.style.overflow = "";
  document.body.style.overflow = "";
});

That overflow reset is the single most useful line in this article for anyone whose full-page captures come back exactly one screen tall. Why cookie consent banners break screenshot automation covers the rest of the pattern, including why the banner you see in a browser is often not the banner a headless client gets served.

Putting it in order

The sequence matters, and it's roughly the reverse of the order you discover the problems in:

  1. setViewport with the width and scale factor you want.
  2. emulateMediaFeatures for reduced motion, and consent cookies via browser.setCookie(), both before navigating.
  3. goto with load (or ["load", "networkidle2"]) and an explicit timeout.
  4. Remove the consent banner and reset overflow.
  5. Scroll through the page, then back to the top.
  6. Neutralise sticky elements, remove fixed overlays.
  7. Await document.fonts.ready and pending images, with a time budget.
  8. A short fixed settle wait.
  9. screenshot({ fullPage: true }).

Wrap it in a function, run it against twenty real URLs from your own list, and look at all twenty. The failures in this article are all silent — the only way you find them is by looking.

Where the pain actually starts

Nothing above is an argument against Puppeteer. Everything above is roughly forty lines of code, and once written it stays written. When the browser is the point — when you have to authenticate, drive an interaction, intercept a request, or reach for raw CDP — you want the browser, and Puppeteer is a good way to have it. If Node isn't your language, the same survey for Python lands in the same place via different libraries.

What gets expensive is a layer out from the script, and it has nothing to do with screenshots. Keeping Puppeteer and its Chromium in step is a standing maintenance item, and upstream picks the date. Then there's the resource shape — each open page holds a renderer process, so a flat loop over 400 URLs will run a container out of memory well before it runs out of URLs, and the answer is a bounded pool and disciplined page.close() rather than a bigger box. And then the deployment target, which is a whole article of its own that we've already written: running Puppeteer on AWS Lambda covers the packaging limits, the cold starts and what the arrangement actually costs over a year.

If maintaining that layer isn't a good use of your team, five of the seven failures above become request fields on a hosted endpoint — fullSize replaces fullPage, delay buys the settle time failures one and four need, hideCookie covers failure seven's banners generically, and hide on v2 accepts a selector list for the failure-two and failure-three overlays. Two don't translate. There is no scale or DPR parameter, so deviceScaleFactor-style retina output is something a local browser gives you and the API doesn't; and failure six is a judgement call about where a feed should stop, which no parameter can make for you. width (100–8000) and height (100–20000) set CSS-pixel dimensions, and neither multiplies pixel density. Plans run from a free tier of 50 captures a month through €7 for 1,000 on Pro, €20 for 15,000 on Ultra and €39 for 30,000 on Mega AI. If the honest reading of your situation is that you want the image rather than the browser, take a key from Snapshot Site and try the same URL both ways.

Related Articles

Why Sticky Headers Break Full-Page Screenshots

Why Sticky Headers Break Full-Page Screenshots

Sticky headers seem harmless until they duplicate, overlap, and pollute full-page screenshots. Here is why they are such a persistent capture problem....

- 02 Mins read

Subscribe to Snapshot Site API

Snapshot Site is a powerful API that allows you to capture full-page, high-resolution screenshots of any website with pixel-perfect accuracy.
Simply send a URL to the API to generate a complete snapshot — not just the visible area — covering entire web pages, scrolling content, landing pages, blogs, news articles, social media posts, videos, and more.
Designed for developers, designers, marketers, and journalists,
Snapshot Site makes it easy to integrate web page capture into your applications, workflows, and automation tools.

Subscribe Now
bg wave