Web capture engineering

Why lazy-loaded content is missing from screenshots

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.

4 min readPublished Updated
What you actually see

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.

Reproduce it

  1. 01Disable cache and capture immediately after `load` so a previous browser run cannot hide the race.
  2. 02Inspect `img.complete`, `naturalWidth`, computed background images and the number of list nodes before and after a manual scroll.
  3. 03Record `scrollHeight` after every step. If it continues growing, the page needs a product-defined stopping point rather than a generic 'bottom'.
The DIY version

Start with the smallest thing that works

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.

settle-lazy-content.ts
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));
}
Where it breaks

The call is small. Everything around it is the system.

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.

Decode finishes after load

A successful HTTP response does not guarantee pixels are decoded. Waiting on `img.decode()` catches a class of blank placeholders that network idle misses.

A transform owns scrolling

Some applications scroll an inner container or animate with transforms. Moving `window` does not trigger the observer attached to that container.

There is no natural bottom

Infinite feeds require an explicit row, selector, step or height limit chosen by the product consuming the artifact.

Keep the DIY version when

  • The page uses native lazy images in the main document and has a stable finite height.
  • You can name a deterministic readiness selector and the screenshot is not expected to include a virtualized dataset in one image.
The recipe

avoid incomplete lazy-load screenshots

Enable the engine's bounded scroll pass and choose the maximum useful document height. The delay is per step in milliseconds; the total capture remains bounded by `timeout`.

Capture settings
Enter the source, choose your options, then run the capture.
Result
Your capture will appear here and stay in view.
waiting

No result yet

Complete the settings and run the tool. Images, PDFs, text, and video all preview in this panel.

Send this exact request
cURL, TypeScript and Python are generated from the same configuration as the demo.
curl --fail-with-body "https://api.pagecapture.dev/v1/take" \
  -H "X-Access-Key: $PAGECAPTURE_KEY" \
  -H "Content-Type: application/json" \
  --data '{"url":"https://example.com","format":"png","full_page":true,"full_page_scroll":true,"full_page_scroll_delay":400,"full_page_max_height":30000}' \
  -o capture.png
What you get, and what it costs

Output, limits, cost, failures

Output

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.

Cost

One successful, non-cached capture costs one credit. Failures, including platform failures, cost zero; cache hits cost zero.

1 creditper successful non-cached capture

Limits

  • The scroll pass targets the main page, not an unknown nested scroll container.
  • A successful capture cannot prove every application-specific item loaded; use `fail_if_content_missing` for a known required marker.
  • Infinite scroll must be bounded with `full_page_max_height`.

Common errors

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

Try it against your own page

The runner above carries this exact configuration into the playground — no retyping, no starting over.

Return to the configured runner
Tested and reviewed

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.

engine-crunknown-pwunknown-f2026-07-1-b2026-07-1StatusFull-page scrolling referenceEditorial method

Sources and verification basis

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.

  1. Lazy loadingMDN Web Docs

    Defines native and scripted lazy-loading behavior.

  2. Defines the bounded scroll and slicing controls.