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_byalso defaults to the viewport height. - The 100ms pause is pacing, not a readiness check. Playwright's
docs say never to use
waitForTimeoutin 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: falsewith an emptycurrentSrc, so checkingcompletealone would sit out the full timeout on every one of them. An IntersectionObserver placeholder with nosrcalready reportscomplete: true. We read these values off the test page rather than the spec. - Don't wait for
networkidlea second time. Playwright documents thatwaitForLoadStateresolves 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.evaluatecallbacks if your TypeScript runs through tsx, which compiles with esbuild'skeepNames. A named helper, or even aconstarrow function, fails in the browser withReferenceError: __name is not defined; a two-line test hit both. Arrow functions passed straight tomaporevery, 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 article | Capture time | Result |
|---|---|---|
fullPage with a 20,000px clip | 25.8s | Correct top 20,000px |
fullPage with a 10,000px clip | 24.9s | Correct top 10,000px |
DevTools Protocol Page.captureScreenshot, captureBeyondViewport | 24.7s | Correct top 20,000px |
CSS max-height with overflow: hidden on html and body | 0.5s | Content in the first 2,000px only; the rest blank |
The same cap on body only | 0.3s | The page collapsed to 800px |
| Viewport resized to 20,000px, plain screenshot | 0.9s | Same 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:
- 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_scrollautomatically wheneverfull_pageis true. - Stop at the first bottom and cap the height. An infinite feed has no full page.
- 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.
- 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.