The list is virtualized
Rows outside the viewport may be removed as new rows appear. No single DOM state contains the whole list, so one full-page image is the wrong artifact.
A full-page surface is not the same thing as a user scrolling through it. IntersectionObserver, native loading=lazy, virtualized lists and image decoding all attach different readiness semantics to viewport movement.
The page height is correct but image boxes below the fold are blank, low-resolution placeholders remain, or later list rows never exist in the DOM.
load so a previous browser run cannot hide the race.img.complete, naturalWidth, computed background images and the number of list nodes before and after a manual scroll.scrollHeight after every step. If it continues growing, the page needs a product-defined stopping point rather than a generic 'bottom'.Scroll one viewport at a time, wait for the page's own assets, stop on stability or a ceiling, and return to the top before capture.
async function settleLazyContent(page, maxHeight = 30_000) {
let stable = 0;
let previousHeight = 0;
for (let step = 0; step < 80; step++) {
const state = await page.evaluate(async (ceiling) => {
scrollBy(0, innerHeight);
await document.fonts?.ready;
await Promise.all([...document.images].map((img) => img.decode().catch(() => {})));
return { y: scrollY, height: document.documentElement.scrollHeight, ceiling };
}, maxHeight);
stable = state.height === previousHeight ? stable + 1 : 0;
previousHeight = state.height;
if (stable >= 2 || state.y + 900 >= Math.min(state.height, maxHeight)) break;
await page.waitForTimeout(400);
}
await page.evaluate(() => scrollTo(0, 0));
}The call above is small. Everything around it is the system — and the system is what you would be signing up to own.
Rows outside the viewport may be removed as new rows appear. No single DOM state contains the whole list, so one full-page image is the wrong artifact.
A successful HTTP response does not guarantee pixels are decoded. Waiting on img.decode() catches a class of blank placeholders that network idle misses.
Some applications scroll an inner container or animate with transforms. Moving window does not trigger the observer attached to that container.
Infinite feeds require an explicit row, selector, step or height limit chosen by the product consuming the artifact.
A bounded full-page PNG after the main document has entered each viewport band. The algorithm returns to the top so ordinary sticky headers paint in their initial position.
One successful, non-cached capture costs one credit. Failures, including platform failures, cost zero; cache hits cost zero.
fail_if_content_missing for a known required marker.full_page_max_height.A required marker is still absent after the render actions complete.
HTTP 500 · terminal
The requested document and scale exceed the output pixel ceiling.
HTTP 400 · terminal
Swap the example for a page you care about. The runner keeps every option from this guide, and carries the whole configuration into the playground — no retyping, no starting over.
Written by PageCapture Engineering. Reviewed by PageCapture render maintainers. Verified 2026-08-12 by running the published code against a fixture.
The recipe and snippets are checked in CI; the documented sequence matches `settle()` and the bounded `scrollThroughPage()` implementation used by the worker.
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.
Defines native and scripted lazy-loading behavior.
Defines the bounded scroll and slicing controls.
Shows the complete DIY capture around this readiness loop.
Why network idle never arrives on these pages, and which counting strategy a lazy list needs.
Lazy content that has not arrived yet is exactly what a phone visitor gets before scrolling.
Raw HTML returns the shell; rendering first is what makes the content appear, pixels or not.