← All posts

Full Page Screenshot API: Fixing What fullPage:true Breaks

September 26, 2026

Why fullPage:true Isn't Enough Anymore

Every guide to automated screenshots ends the same way: set fullPage: true and call it done. It works on a static test page with no navigation bar and no lazy images. Point the same script at a real production site and the output looks broken — a header floating mid-page, gray boxes where product photos should be, or a screenshot that cuts off hundreds of pixels above the actual footer.

None of this is a bug in Chromium. Headless Chrome resizes the viewport to the document's full height before capturing (documented in the Puppeteer team's own discussion of the v2.0.0 screenshot changes), and the browser renders exactly what that resized layout produces, clipped precisely with no padding or guesswork. That's correct behavior — the problem is that most real-world pages were never designed to render at an arbitrary, non-viewport height, and their CSS reacts badly when you try.

A full page screenshot API needs to account for three specific, recurring failure modes: duplicated sticky headers, blank lazy-loaded images, and stitching seams or incorrect final height. Each has a distinct root cause and a distinct fix — patching one won't solve the others.

Failure Mode 1: Sticky and Fixed Headers That Duplicate or Float Mid-Page

When Chromium expands the viewport to match document.scrollHeight before capturing, any element positioned with position: fixed or position: sticky is no longer anchored to the visible viewport — it's now positioned relative to a much taller box. The browser doesn't move the header off-screen; it recalculates its position within the new height, which is why it appears duplicated or stuck floating mid-page instead of pinned to the top.

This is documented in the DEV Community writeup on fixed headers disappearing or duplicating in Puppeteer, and it's the exact bug called out in the puppeteer-full-page-screenshot package's notes on sticky elements appearing multiple times. Smooth-scroll behavior compounds it further, since animated scrolling can leave a sticky element mid-transition when the resize happens.

The reliable fix is a sticky header screenshot fix applied before capture, not after: inject CSS that forces position: static (or hides the element outright) on any node matching position: fixed or position: sticky, then capture. This is the same technique behind most "remove sticky nav" workarounds, but targeting the header, cookie banner, or chat widget by selector is more reliable than a blanket CSS override, which can break layout elsewhere on the page.

Failure Mode 2: Lazy-Loaded Images Rendering as Blank Placeholders

Lazy loading depends on the browser knowing an element has entered the viewport — either through native loading="lazy" or a JavaScript IntersectionObserver. A headless browser taking a single screenshot never scrolls the way a real visitor does, so those triggers never fire. Below-the-fold images stay in their placeholder state — a gray box, a blurred low-res preview, or nothing at all — because the real src was never requested.

The ScreenshotRun writeup on lazy-loading and blank images explains that headless rendering is a single pass, not a scroll-through session, so anything gated behind scroll position never loads. Waiting for networkidle alone doesn't help, because the network is genuinely idle — the images were never requested.

The fix is to simulate a real scroll pass before capture: programmatically scroll from top to bottom (and often back up, since some lazy-load libraries only trigger on downward scroll) with small delays between steps, forcing each IntersectionObserver to fire. Afterward, confirm images have actually decoded and reached full opacity — many lazy-load libraries fade images in via CSS transition, and capturing mid-fade gives you a washed-out or half-transparent image instead of a blank one. This is a capture-timing problem closely related to the broader question of wait strategies, covered in more depth in Headless Browser Wait Strategies: A Practical Decision Tree.

Failure Mode 3: Viewport Stitching Seams and Wrong Final Height

Before fullPage:true matured, the standard workaround was manual viewport stitching: screenshot the visible area, scroll down by one viewport height, screenshot again, and stitch the images together in memory. It's fragile. Off-by-one pixel gaps appear at the seams, scrollbar overlays get captured inconsistently, and a single scrollHeight reading taken at the start can be wrong by the time you finish.

Pages with infinite scroll, lazy-loaded ads, cookie banners that collapse after dismissal, or animations that resize layout all change scrollHeight during capture. Measure height once and stitch based on that number, and you'll either cut the screenshot off before the real bottom of the page or leave extra blank space where content hasn't loaded yet — the two complaints developers report most often.

The correct approach re-measures document height after each scroll step rather than trusting a single snapshot, and only finalizes the capture height once two consecutive measurements match. For genuinely infinite-scroll pages, that means defining a stopping condition — a max scroll count or a content-not-growing check — rather than assuming the page has a natural end.

Solving It With a Full Page Screenshot API Instead of Hand-Rolled Scripts

Each of these fixes is solvable in raw Puppeteer or Playwright — plenty of teams have shipped internal versions of all three. The cost isn't writing the logic once; it's maintaining it. Chromium updates change resize and paint timing often enough that scroll-and-fix scripts silently regress, and Playwright's fullPage handling doesn't always match Puppeteer's behavior page-for-page, so a script tuned for one doesn't port cleanly to the other.

A dedicated full page screenshot API turns these three fixes into request parameters instead of maintained code:

POST /screenshot
{
  "url": "https://example.com",
  "fullPage": true,
  "hideSelectors": ["header.sticky", ".cookie-banner"],
  "scrollBeforeCapture": true,
  "settleDelayMs": 400
}

The response is a rendered image (or a URL to one) with the sticky header neutralized, lazy images pre-triggered, and height re-measured after scroll settles — no Chromium version pinning, no scroll-loop debugging at 2am. The full API documentation covers the complete parameter set, including selector removal versus hiding, custom viewport widths, and PDF output using the same capture pipeline.

Frequently Asked Questions

Why does my full-page screenshot cut off before the actual bottom of the page?

This usually happens because the page's document height was measured before all content finished loading — ads, lazy images, or infinite-scroll content that expands the page after the initial scrollHeight check. The fix is to re-measure height after a scroll-through pass and only finalize capture once the height stabilizes across two checks.

Why does the sticky header show up twice in my screenshot?

Chromium resizes the viewport to the full document height before capturing, and elements with position: fixed or position: sticky get repositioned relative to that new, taller box instead of staying pinned to the top. Neutralizing those elements — forcing position: static or hiding them by selector before capture — prevents the duplication.

How do you get lazy-loaded images to show up in a full-page screenshot?

Simulate a real scroll pass, since headless browsers render in a single pass and never trigger native loading="lazy" or IntersectionObserver events on their own. Scrolling from top to bottom with small delays forces each image to request its real source, and adding a short settle delay afterward lets fade-in transitions finish before the capture happens.

Is Playwright's fullPage screenshot better than Puppeteer's for long pages?

Neither handles the underlying issues — sticky headers, lazy images, dynamic height — automatically; both clip to the resized viewport and inherit the same rendering quirks. Playwright and Puppeteer differ slightly in resize and paint timing, so a scroll-and-fix script tuned for one often needs adjustment to work reliably on the other.

Can a full page screenshot API handle infinite-scroll pages?

Yes, provided it defines a stopping condition rather than assuming a fixed document height — typically a maximum scroll count or a check for content no longer growing between scroll steps. Without that logic, infinite-scroll pages will either time out or produce a screenshot cut off wherever the last measured height happened to land.

Why does my screenshot have extra blank space at the bottom?

Extra blank space usually means the capture height was set based on a page state that included a cookie banner, ad slot, or loading placeholder that later collapsed or shrank. Re-measuring scrollHeight after content settles, rather than trusting an initial reading, resolves this along with the opposite problem of premature cutoffs.

Try it directly against the screenshot endpoint in the docs with fullPage, hideSelectors for sticky headers, and scrollBeforeCapture set to true — no Chromium maintenance required. If you're weighing the cost of a managed API against keeping a self-hosted Puppeteer fleet running, the pricing page breaks down usage-based cost so you can compare before switching. Start at browsevra.