· Updated · Research · By Simon Chiu

Lazy Loading and Full-Page Screenshots: A Study

How complete is a full-page screenshot of a lazy-loading page? Five capture strategies tested on four lazy-load patterns, with data and scripts.

Line illustration of the RenderScreenshot mascot scrolling a page where images load as it goes

A full-page screenshot of a page that lazy-loads its images often comes back with gray boxes where the images should be. We built a test page with four common lazy-loading patterns and captured it five ways. A plain full-page capture after the load event showed 11 of 30 images. Waiting for network idle showed the same 11, in twice the time. Resizing the viewport to the page height reached 25 of 30. Only a slow scroll through the page, one viewport at a time, reached 30 of 30, and it took about ten times as long as the plain capture.

This page publishes that fixture, its raw results and the scripts. It also gives the protocol and crawler for the larger question: how often does this happen on real sites? That run has not been published yet; see What we have not measured yet. This study is part of how we built RenderScreenshot.

Disclosure: RenderScreenshot sells a screenshot API. Every number on this page comes from local Chromium, not from our API, so the results describe browser behavior, not any vendor.

Method box. Date: 2026-10-15. Machine: Apple M1 Pro, macOS 27.0.1, local network only. Browser: Chrome headless shell 148.0.7778.97 via Puppeteer 24.43.1, Node 22.18.0. Viewport 1280x800, device scale factor 1. Fixture: 30 images (6 per pattern) on a 9,000 px page, each image served with a 150 ms delay. Runs: 3 per strategy, each in a fresh browser context. Completeness: a slot counts as complete when the pixel at its center has the image's color in the final PNG. Raw data: fixture-results.csv. Scripts: fixture.mjs, study.mjs, crawl.mjs.

Why full-page captures miss images

A browser draws a full-page screenshot by laying out the whole document and painting it into one tall image. It does not scroll. Lazy loading is built on the opposite assumption: an image is fetched only when it is about to scroll into view. So the capture and the page disagree about what "in view" means.

How badly they disagree depends on how the page lazy loads. There are four common patterns, and each has a different trigger:

Pattern How it decides to load Trigger a capture must cause
Native loading="lazy" The browser loads the image when it is within a distance threshold of the viewport Viewport near the image
IntersectionObserver with data-src A script swaps data-src into src when the element intersects the viewport (plus a margin) Element intersects the viewport
Scroll-event listener A script checks positions inside a scroll handler A real scroll event
CSS background via observer A script adds a class or inline background-image on intersection Element intersects the viewport

The fixture puts all four patterns on one page, plus eager images as a control, interleaved from top to bottom so every pattern has images near the top and far below the first screen.

The fixture

Each slot is a 300x200 box with a gray background. Each image is a solid color, one color per pattern, so the screenshot itself says whether a slot loaded: color means loaded, gray means not. This is the core of the page (the full file, with the server, is fixture.mjs):

<!-- fragment: one of each pattern -->
<img class="slot" src="/img/eager/0.png" width="300" height="200">
<img class="slot" src="/img/native/0.png" loading="lazy" width="300" height="200">
<img class="slot io" data-src="/img/observer/0.png" width="300" height="200">
<img class="slot sl" data-src="/img/scroll_listener/0.png" width="300" height="200">
<div class="slot bg" data-bg="/img/css_background/0.png"></div>

<script>
  // IntersectionObserver: how most lazy-load libraries work
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) if (e.isIntersecting) {
      const el = e.target;
      if (el.dataset.src) el.src = el.dataset.src;
      if (el.dataset.bg) el.style.backgroundImage = `url(${el.dataset.bg})`;
      io.unobserve(el);
    }
  }, { rootMargin: '200px' });
  document.querySelectorAll('.io, .bg').forEach((el) => io.observe(el));

  // Scroll listener: how older jQuery-era plugins work
  function check() {
    document.querySelectorAll('.sl:not([src])').forEach((el) => {
      const r = el.getBoundingClientRect();
      if (r.top < innerHeight + 200 && r.bottom > -200) el.src = el.dataset.src;
    });
  }
  addEventListener('scroll', check, { passive: true });
  check();
</script>

The server delays every image by 150 ms. That small delay is realistic for a CDN and it matters: without it, some images would finish before the capture by luck.

The five strategies

All five end with the same call, page.screenshot({ fullPage: true }). They differ only in what happens before it:

  1. load, fullPage. Navigate with waitUntil: 'load', capture.
  2. networkidle0, fullPage. Navigate with waitUntil: 'networkidle0' (no requests for 500 ms), capture.
  3. Viewport resized to page height. Navigate with load, set the viewport height to scrollHeight, wait for network idle, capture.
  4. Scroll-through, 800 px steps, 400 ms pauses. Navigate with load, scroll one viewport at a time with a 400 ms pause, return to the top, wait for network idle, capture.
  5. Fast scroll-through, 2,400 px steps, 50 ms pauses. The same with bigger, quicker steps.

The scroll helper is short. It reads the height on every step, because lazy content can make the page grow while you scroll:

// fragment from study.mjs
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function scrollThrough(page, { step = 800, delay = 400 } = {}) {
  let y = 0;
  for (;;) {
    const height = await page.evaluate(() => document.documentElement.scrollHeight);
    if (y >= height) break;
    await page.evaluate((top) => window.scrollTo(0, top), y);
    await sleep(delay);
    y += step;
  }
  await page.evaluate(() => window.scrollTo(0, 0));
}

Results

Images visible in the final screenshot, out of 6 per pattern. Every run of a strategy gave the same counts, so the table shows one row per strategy with the median time of 3 runs.

Strategy Eager Native lazy Observer Scroll listener CSS background Complete Median time
load, fullPage 6 3 1 1 0 11/30 600 ms
networkidle0, fullPage 6 3 1 1 0 11/30 1,284 ms
Viewport resized to page height 6 6 6 1 6 25/30 1,563 ms
Scroll-through, 800 px / 400 ms 6 6 6 6 6 30/30 5,964 ms
Fast scroll-through, 2,400 px / 50 ms 6 6 4 4 3 23/30 1,508 ms

The times include navigation and the PNG encode of a 1280x9000 image on a local server, so only compare them with each other.

What the numbers say

Network idle does not help. This is the most common wrong fix. networkidle0 waits until in-flight requests stop. The lazy images below the fold never start, so the network goes quiet with them still missing. It cost twice the time of load and added nothing.

Native lazy loading is half solved by the browser. Chrome loads loading="lazy" images that are within a distance of the viewport, so the first 3 of 6 loaded even in the plain capture. The rest were too far down.

Resizing the viewport fixes three patterns of four. When the viewport is as tall as the page, every element intersects it. Native lazy images, IntersectionObserver images and CSS backgrounds all loaded. The scroll-listener images did not, because nothing scrolled, so no scroll event fired. This is also why a "full page" mode that silently resizes the viewport can look fine on one site and fail on another.

There is a second cost the table does not show: a viewport that is 9,000 px tall changes the layout of any page that uses vh units or full-height hero sections. The hero becomes 9,000 px tall. For pages with that kind of design, resizing trades missing images for a broken layout.

Only a real scroll reaches everything, and speed matters. The slow scroll-through loaded all 30. The fast one, with 2,400 px jumps and 50 ms pauses, skipped past observer targets before their callbacks ran and missed 7. The pause has to be long enough for the observer callback, the request and the decode. In our fixture, 400 ms per 800 px was enough; 50 ms per 2,400 px was not.

Completeness has a price. The full scroll took about ten times as long as the plain capture on a 9,000 px page. For a screenshot service, that time is billed browser time. That is why scrolling should be a choice per request, not the default for every capture.

Choosing a strategy

Your situation Use Why
You control the page (your own app or template) Make images eager for the capture, or set a "ready" flag Cheapest and exact. No scrolling needed.
Unknown third-party pages, completeness matters Scroll-through with a pause of a few hundred ms per viewport The only strategy that handled all four patterns
Unknown pages, speed matters more than the last images Viewport resized to page height Fixes 3 of 4 patterns at a quarter of the scroll time; watch for vh layouts
Above-the-fold only (social cards, thumbnails) Plain viewport capture Lazy loading does not affect the first screen

Two more rules from the fixture:

  • Wait after the scroll, not before. Our scroll strategies end with a network-idle wait. The last images are requested at the end of the scroll and need time to arrive.
  • Cap the height. A page with infinite scroll grows while you scroll. Stop at a maximum height. Our crawler stops at 16,000 px, the same default our API documents for capture.scroll.max_height.

If you capture with Puppeteer yourself, the Puppeteer full-page guide covers sticky headers and Chrome's maximum image height. For pages that render their content with JavaScript after load, see screenshots of single-page applications, and for picking a wait condition, the wait strategies guide.

What we have not measured yet

The question behind this page is "how often does this happen on real sites?" The fixture cannot answer it. It tells you which strategy handles which pattern, not how common the patterns are.

The protocol for the crawl:

  • Sample: 1,000 sites from a public popularity list (for example the Tranco list), home pages only, deduplicated by registrable domain. Home pages are a biased sample; a second run on article pages is planned.
  • Per site: two captures in fresh browser contexts. Naive: load, then full page. Scrolled: load, scroll-through (800 px, 400 ms), network idle, then full page. Both capped at 16,000 px.
  • Metrics: number of laid-out <img> elements; how many use loading="lazy"; how many carry data-src-style attributes; images not decoded at capture time (naive and scrolled); output heights; percentage of pixels that differ between the two captures over the overlapping height; time for each.
  • Rules: 4 sites at a time, one visit per capture, a normal desktop Chrome user agent, no retries on blocked sites (they are reported as errors, not dropped).
  • Reporting: the raw CSV, the list of sites, the failure rate, and results for our own API's full-page capture on the same fixture and sites, including where it does worse.

The crawler is ready and published as crawl.mjs. It reads one URL per line and writes one CSV row per URL:

// crawl.mjs: naive vs scrolled full-page capture for a list of URLs.
// Usage: node crawl.mjs urls.txt > results.csv
// Node 22, [email protected], pixelmatch@7, pngjs@7. One URL per line in urls.txt.
import { readFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';

const VIEWPORT = { width: 1280, height: 800 };
const MAX_HEIGHT = 16000;
const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Images that are in the layout but have no decoded pixels yet.
const imageStats = () => {
  const imgs = [...document.images].filter((i) => i.getBoundingClientRect().width > 0);
  return {
    total: imgs.length,
    nativeLazy: imgs.filter((i) => i.loading === 'lazy').length,
    dataSrc: imgs.filter((i) => i.hasAttribute('data-src') || i.hasAttribute('data-lazy-src') || i.hasAttribute('data-srcset')).length,
    incomplete: imgs.filter((i) => !i.complete || i.naturalWidth === 0).length,
  };
};

async function scrollThrough(page, step = VIEWPORT.height, delay = 400) {
  for (let y = 0; ; y += step) {
    const h = await page.evaluate(() => document.documentElement.scrollHeight);
    if (y >= Math.min(h, MAX_HEIGHT)) break;
    await page.evaluate((top) => window.scrollTo(0, top), y);
    await sleep(delay);
  }
  await page.evaluate(() => window.scrollTo(0, 0));
  await page.waitForNetworkIdle({ idleTime: 500, timeout: 10_000 }).catch(() => {});
}

async function capture(browser, url, scrolled) {
  const context = await browser.createBrowserContext();
  const page = await context.newPage();
  try {
    await page.setViewport(VIEWPORT);
    const t0 = performance.now();
    await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
    if (scrolled) await scrollThrough(page);
    const stats = await page.evaluate(imageStats);
    const height = Math.min(await page.evaluate(() => document.documentElement.scrollHeight), MAX_HEIGHT);
    const png = PNG.sync.read(
      await page.screenshot({ type: 'png', clip: { x: 0, y: 0, width: VIEWPORT.width, height } }),
    );
    return { stats, png, ms: Math.round(performance.now() - t0) };
  } finally {
    await context.close();
  }
}

function overlapDiff(a, b) {
  const height = Math.min(a.height, b.height);
  const width = Math.min(a.width, b.width);
  const crop = (p) => {
    const out = Buffer.alloc(width * height * 4);
    for (let y = 0; y < height; y++) p.data.copy(out, y * width * 4, y * p.width * 4, y * p.width * 4 + width * 4);
    return out;
  };
  const changed = pixelmatch(crop(a), crop(b), null, width, height, { threshold: 0.1 });
  return ((changed / (width * height)) * 100).toFixed(2);
}

const urls = (await readFile(process.argv[2], 'utf8')).split('\n').map((s) => s.trim()).filter(Boolean);
const browser = await puppeteer.launch({ headless: 'shell' });
console.log('url,status,img_total,img_native_lazy,img_data_src,naive_incomplete,scrolled_incomplete,naive_height,scrolled_height,pixel_diff_pct,naive_ms,scrolled_ms');
let failures = 0;
const queue = [...urls];
await Promise.all(
  Array.from({ length: CONCURRENCY }, async () => {
    for (let url = queue.shift(); url; url = queue.shift()) {
      try {
        const naive = await capture(browser, url, false);
        const scrolled = await capture(browser, url, true);
        console.log([
          url, 'ok', scrolled.stats.total, scrolled.stats.nativeLazy, scrolled.stats.dataSrc,
          naive.stats.incomplete, scrolled.stats.incomplete, naive.png.height, scrolled.png.height,
          overlapDiff(naive.png, scrolled.png), naive.ms, scrolled.ms,
        ].join(','));
      } catch (err) {
        failures++;
        console.log(`${url},error:${String(err.message).split('\n')[0].replaceAll(',', ' ')},,,,,,,,,,`);
      }
    }
  }),
);
await browser.close();
if (failures === urls.length) process.exitCode = 1;

We smoke-tested it on 2026-10-15: it wrote a complete row for https://example.com and an error row for a URL that timed out, and it exits non-zero only if every URL fails. Install with npm i [email protected] pixelmatch@7 pngjs@7.

Two known limits of the metrics. incomplete counts only <img> elements, so it misses CSS backgrounds; the pixel difference catches those but also catches carousels and animations that change between captures. Read the two numbers together, and look at the images for any site where they disagree.

Limitations of the fixture

  • One browser, one machine. Chrome's native lazy-load distance depends on the browser version and connection type. Other versions can load more or fewer native lazy images in the plain capture.
  • Synthetic latency. Every image took 150 ms. Real pages mix fast cached images with slow third-party ones.
  • Four patterns. Real sites also lazy load with <picture> sources, srcset swaps, iframes, and content inserted by infinite scroll. The crawl measures these indirectly through the pixel difference.
  • Center-pixel scoring. A slot counts as complete when its center has the right color. That is strict for solid images and fine for this fixture; it is not a general visual-completeness metric.

Reproduce it

npm i [email protected] pngjs@7
curl -O https://renderscreenshot.com/blog/lazy-loading-study/fixture.mjs
curl -O https://renderscreenshot.com/blog/lazy-loading-study/study.mjs
CSV=results.csv node study.mjs

The scripts are ES modules, so run them in a directory whose package.json has "type": "module". On some macOS setups, the new headless mode times out on Page.captureScreenshot; the scripts use headless: 'shell' (the headless shell build) for that reason.

If you capture pages with our API, the capture parameters document capture.mode: "full_page" and the capture.scroll options. When we publish the crawl, we will include our own API's result on this fixture next to the local strategies. To test your own pages now, create a free account and compare a full-page capture with the browser view.

Related in this topic