A full-page screenshot captures the entire scrollable height of a web page in one image instead of the visible viewport. It fails in three predictable ways: lazy-loaded images render as blank space, infinite feeds have no bottom to stop at, and very tall pages can take longer to rasterize than a request is allowed to run.

We found the first one in our own renderer this month, along with a fourth failure that is purely a Playwright usage mistake. On a test page built to reproduce it, the renderer left every lazy-loaded element blank. Pages taller than our 20,000px cap had a different problem: they came back as a single 1280×800 screen, billed as a successful render. Our language guides, meanwhile, had been describing a scroll step the renderer never performed. Both bugs were fixed on 2026-10-16 and are in the changelog. What follows is what we measured while fixing them, with code you can lift into your own Playwright setup.

Why do lazy-loaded images come out blank?

Pages defer images in two ways. A full-page capture defeats both.

Native loading="lazy". Chromium fetches a lazy image when it comes within a set distance of the viewport. Since July 2020 that distance has been 1,250px on fast connections and 2,500px on 3G or slower, and a page can't change it, according to web.dev. An image 4,000px down a page is outside both.

IntersectionObserver loaders. Libraries that swap data-src into src, and reveal-on-scroll animations that keep a section at opacity: 0 until it appears, wait for the element to actually intersect the viewport.

A full-page screenshot does render the page beyond the viewport. The scroll position, though, never changes, so neither mechanism has much reason to load anything before the pixels are taken.

To make this measurable we built a test page 12,800px tall, with four solid-colour targets below the fold: a native lazy image at 4,000px, an IntersectionObserver image, a reveal-on-scroll block, and a second native lazy image near the bottom. We loaded it the usual way (load, then up to five seconds of network idle), took a fullPage screenshot and sampled the pixel at the centre of each target. All four were white.

How do you make lazy content load before the capture?

Scroll the page the way a reader would, one screen at a time. Then go back to the top and wait for whatever images started loading:

import type { Page } from 'playwright'

const MAX_HEIGHT = 20_000

export async function scrollThrough(page: Page, budgetMs = 8_000) {
  const viewport = page.viewportSize()!.height
  const deadline = Date.now() + budgetMs
  let y = 0
  while (Date.now() < deadline) {
    const height = await page.evaluate(() => document.documentElement.scrollHeight)
    const last = Math.max(0, Math.min(height, MAX_HEIGHT) - viewport)
    if (y >= last) break
    y = Math.min(y + viewport, last)
    await page.evaluate(top => window.scrollTo({ top, behavior: 'instant' }), y)
    await page.waitForTimeout(100)
    if (y >= last) break // first bottom reached: don't chase an infinite feed
  }
  await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }))
  await page.waitForFunction(
    () => Array.from(document.images).every(img => img.complete || !img.currentSrc),
    undefined,
    { timeout: 3_000, polling: 100 },
  ).catch(() => {})
}

On the test page this turned four white samples into four correct colours, at a cost of 1.6 seconds. On real pages, run in the official Playwright Docker image, it cost 0.5 seconds on a 3,892px Wikipedia article, 1.4 on a 10,575px web.dev article and 2.2 on a 16,186px Wikipedia article.

Some details in that function look arbitrary and aren't:

  • The step is one viewport. Bigger steps finish sooner. But an IntersectionObserver with no root margin only fires for elements that actually enter the viewport, and a bigger step can jump right over them. ScreenshotOne's full_page_scroll_by also defaults to the viewport height.
  • The 100ms pause is pacing, not a readiness check. Playwright's docs say never to use waitForTimeout in production, because a fixed wait is a guess about readiness. This one isn't standing in for readiness: it gives observers a frame to run and requests a chance to start. The readiness check is the image wait at the end.
  • The image check skips images that never started. A lazy image the scroll never reached reports complete: false with an empty currentSrc, so checking complete alone would sit out the full timeout on every one of them. An IntersectionObserver placeholder with no src already reports complete: true. We read these values off the test page rather than the spec.
  • Don't wait for networkidle a second time. Playwright documents that waitForLoadState resolves immediately if the document "has already reached the required state". A page that went idle during the initial load has, so the second call returns at once and waits for nothing.
  • Don't declare named functions inside page.evaluate callbacks if your TypeScript runs through tsx, which compiles with esbuild's keepNames. A named helper, or even a const arrow function, fails in the browser with ReferenceError: __name is not defined; a two-line test hit both. Arrow functions passed straight to map or every, as above, are fine.

How do you screenshot an infinite scroll page?

Stop at the first bottom. Cap the height anyway.

Our scroll loop's first version re-measured the page on every step and kept going for as long as there was more to scroll. We pointed it at a synthetic feed that appends three blocks whenever its last block comes into view, and it never found a bottom: the page grew from 2,003px to 20,021px in 2.7 seconds and only stopped at our height cap. Worse, a loop like that can turn an ordinary page into a very tall one, and very tall pages are where captures get slow (more on that below).

With the first bottom reached line added, the same feed came back at 5,006px in 0.2 seconds. That is one batch of appended content, roughly what a reader sees after scrolling to the end once. Need more of a feed than that? Ask for a specific height, not "everything". Everything has no end.

Why does a tall page come back as one screen?

Two common causes, and the output looks the same for both.

The page doesn't scroll; a container inside it does. Plenty of single-page apps pin html and body to the viewport height and scroll an inner element with overflow: auto. As far as the browser is concerned, the document is exactly one viewport tall, and that is what fullPage captures. Our test page, 5,000px of content in such a container, came back as 1280×800. Fixing it takes per-site work (capture the container element, or override its styles), which is why a general screenshot API can't promise it.

clip without fullPage. This one was ours. Here is how our renderer handled pages taller than the 20,000px cap:

// Wrong: without fullPage, the clip is trimmed to the viewport
await page.screenshot({ clip: { x: 0, y: 0, width: 1280, height: 20_000 } })

// Right: with fullPage, the clip applies to the whole page
await page.screenshot({ fullPage: true, clip: { x: 0, y: 0, width: 1280, height: 20_000 } })

The Playwright docs describe clip only as "an object which specifies clipping of the resulting image". The trimming is in the source: unless fullPage is set, the rectangle gets clipped to the viewport size. On our 20,021px test page the first call returned 1280×800, the second 1280×20000.

In production, every page taller than 20,000px returned its first screen and was billed as a successful render. Running the pre-fix code against a 55,277px Wikipedia article confirmed it: 1280×800, in nine seconds.

Why are very tall full-page screenshots so slow?

Once the clip was fixed, that same article stopped returning the wrong image and started timing out instead. Our render limit is 30 seconds; the capture alone took about 25. We tried every cheaper route we could think of:

Approach on a 55,277px articleCapture timeResult
fullPage with a 20,000px clip25.8sCorrect top 20,000px
fullPage with a 10,000px clip24.9sCorrect top 10,000px
DevTools Protocol Page.captureScreenshot, captureBeyondViewport24.7sCorrect top 20,000px
CSS max-height with overflow: hidden on html and body0.5sContent in the first 2,000px only; the rest blank
The same cap on body only0.3sThe page collapsed to 800px
Viewport resized to 20,000px, plain screenshot0.9sSame 5.1 MB output as the correct capture, but 100vh sections would stretch to 20,000px

Clip size barely matters: half the height cost the same. Nor is the network to blame, since the page issued just six image requests across one run of the capture. For contrast, a synthetic text-only page of 54,972px returned its top 20,000px in 0.9 seconds, and a 16,186px Wikipedia article captured in 0.7. So the cost depends on the page, and on capturing beyond the viewport: resizing the viewport to cover the same 20,000px of the same article took 0.9 seconds. Where exactly Chromium spends the difference, we don't know.

The CSS caps are fast because they change the page. Whether that change is harmless depends on how each site lays out html and body, which a general renderer has no way of knowing in advance. Resizing the viewport gives correct pixels on this article and wrong ones on any page that sizes sections in viewport units.

We kept the honest version: the correct top 20,000px when it fits in the time budget, a render_timeout error when it doesn't. Failed renders are not billed. Section-by-section capture (scroll, take viewport-sized shots, stitch them together) is another way at this kind of page, and ScreenshotOne ships one as full_page_algorithm=by_sections. We haven't measured it against this article, so we can't tell you it is faster.

What should a full-page screenshot API do by default?

Four rules came out of this:

  1. Scroll before capturing, one viewport at a time, without being asked. Blank lazy images are the normal failure, not an edge case. ScreenshotOne enables full_page_scroll automatically whenever full_page is true.
  2. Stop at the first bottom and cap the height. An infinite feed has no full page.
  3. Budget time per stage, and give the capture the biggest share. Navigation, scrolling and capture all draw on one deadline, and on tall pages the capture needs most of it.
  4. Fail visibly rather than return a wrong image, and don't bill the failure. To every system downstream, a 1280×800 image of a 55,000px page looks like a success.

Since 2026-10-16, full_page=true on Shotium does all four. It steps one viewport at a time with a 100ms pause for up to eight seconds, stops at the first bottom, caps at 20,000px, and reserves more of the 30-second budget for the capture when a page is taller than the cap.

curl -H "Authorization: Bearer sk_live_…" \
  "https://api.shotium.com/v1/screenshot?url=https://web.dev/articles/browser-level-image-lazy-loading&full_page=true&format=webp&quality=85" \
  -o full-page.webp

Viewport screenshots (full_page=false) work exactly as before.


full_page=true is one parameter on GET /v1/screenshot, and the language guides show it in Python, Node, PHP, Ruby, Go, Java and cURL. If you are deciding whether to keep running a loop like the one above yourself, that arithmetic is here.

The synthetic test pages ran in Playwright 1.62.1's Chromium on a laptop. The Wikipedia and web.dev timings come from the official Playwright Docker image (v1.62.1, arm64), the image our renderer runs in. Absolute numbers will differ on other machines; each before-and-after pair was measured back to back. ScreenshotOne's options were read from its public documentation on 2026-10-16.