ScreenshotNeo

BlogHow-to

How to Capture Lazy-Loaded Images in Website Screenshots

Scroll the page to trigger lazy loading, wait for images to settle, verify they loaded, then capture. Includes runnable Playwright, Puppeteer, Python and cURL examples.

By the ScreenshotNeo team4 October 20269 min read

To capture lazy-loaded images, scroll the page through its content in viewport-sized steps, wait for loading to settle, verify important images loaded, and only then take a full-page screenshot. A full-page capture includes content outside the current viewport, but it does not guarantee that the page has requested every offscreen image. Browser-native lazy loading and site-specific scripts often wait until content approaches or enters the viewport.

This guide shows a bounded Playwright workflow, a Puppeteer equivalent, ways to diagnose missing images, and the tradeoffs of browser automation. If you want the API option, ScreenshotNeo’s screenshot API documentation describes its full-page capture options; its capture can load lazy images, and the call is shown below.

1. Why a full-page screenshot can miss images

With loading="lazy", a browser can defer an offscreen image until it is within a browser-calculated distance of the viewport. That distance is not a universal fixed threshold: it can vary with the resource and effective connection type. A screenshot that captures the entire document can therefore include an image area before the browser has requested its image. Chrome’s browser-level image lazy-loading guidance explains this behavior.

Sites can also load content through JavaScript visibility checks, IntersectionObserver, or infinite scrolling. A page may append new sections only after you scroll. Google Search Central’s lazy-loading guidance describes these patterns.

The practical sequence is: establish the intended viewport, navigate, scroll progressively, wait for relevant requests and rendering, check image elements, and capture. Neither a page-load event nor network idle proves that every offscreen resource has been triggered.

2. Playwright: runnable full-page capture

Install Playwright and its Chromium browser in a Node.js project:

npm install playwright
npx playwright install chromium

Save this as capture.js. It scrolls repeatedly, including newly appended content, applies bounded waits, reports failed <img> elements, and saves the screenshot. Run it with node capture.js https://example.com.

const { chromium } = require('playwright');

const url = process.argv[2];
if (!url) throw new Error('Usage: node capture.js https://example.com');

(async () => {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage({
    viewport: { width: 1440, height: 900 },
    deviceScaleFactor: 1,
  });
  try {
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });

    const step = await page.evaluate(() => window.innerHeight);
    const maxPasses = 12;
    let previousHeight = -1;
    let stablePasses = 0;

    for (let pass = 0; pass < maxPasses && stablePasses < 2; pass++) {
      const height = await page.evaluate(() =>
        document.documentElement.scrollHeight
      );
      for (let y = 0; y < height; y += step) {
        await page.evaluate(y => window.scrollTo(0, y), y);
        await page.waitForTimeout(300); // Starting point; tune per site.
      }
      await page.waitForTimeout(700);
      const newHeight = await page.evaluate(() =>
        document.documentElement.scrollHeight
      );
      stablePasses = newHeight === previousHeight ? stablePasses + 1 : 0;
      previousHeight = newHeight;
    }

    // Give images already discovered a bounded chance to finish.
    await page.waitForFunction(() =>
      [...document.images].every(img => img.complete),
      { timeout: 15000 }
    ).catch(() => {});

    const imageReport = await page.evaluate(() =>
      [...document.images]
        .filter(img => !img.complete || img.naturalWidth === 0)
        .map(img => ({ src: img.currentSrc || img.src, complete: img.complete }))
    );
    if (imageReport.length) {
      console.warn('Images incomplete or failed:', imageReport);
    }

    await page.screenshot({ path: 'page.png', fullPage: true });
  } finally {
    await browser.close();
  }
})().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

The loop has a pass limit so an infinite-scroll page cannot keep the job alive forever. Increase maxPasses for a known long page, or stop when a specific target appears. The 300 ms and 700 ms waits are starting points, not guarantees or universal requirements. Slow networks, animations, image transformations, and site-specific loaders may need different waits.

Wait for particular images or content

When the page has a known image or section, wait for it directly instead of trying to prove that every image on the page has finished. For example:

await page.locator('.product-gallery img').first().waitFor({ state: 'attached' });
await page.waitForFunction(() => {
  const img = document.querySelector('.product-gallery img');
  return img && img.complete && img.naturalWidth > 0;
}, { timeout: 15000 });

An attached element is not necessarily loaded. The second check confirms that the selected image element completed with a nonzero intrinsic width.

3. Puppeteer: scroll, verify, and capture

Install Puppeteer with npm install puppeteer. This script uses the same bounded scroll-and-check approach:

const puppeteer = require('puppeteer');

const url = process.argv[2];
if (!url) throw new Error('Usage: node capture-puppeteer.js https://example.com');

(async () => {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
  try {
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
    const step = await page.evaluate(() => window.innerHeight);
    let lastHeight = -1;
    let stable = 0;

    for (let pass = 0; pass < 12 && stable < 2; pass++) {
      const height = await page.evaluate(() => document.documentElement.scrollHeight);
      for (let y = 0; y < height; y += step) {
        await page.evaluate(y => window.scrollTo(0, y), y);
        await new Promise(resolve => setTimeout(resolve, 300));
      }
      await new Promise(resolve => setTimeout(resolve, 700));
      const nextHeight = await page.evaluate(() => document.documentElement.scrollHeight);
      stable = nextHeight === lastHeight ? stable + 1 : 0;
      lastHeight = nextHeight;
    }

    await page.waitForFunction(() =>
      [...document.images].every(img => img.complete),
      { timeout: 15000 }
    ).catch(() => {});
    const failures = await page.evaluate(() =>
      [...document.images]
        .filter(img => !img.complete || img.naturalWidth === 0)
        .map(img => img.currentSrc || img.src)
    );
    if (failures.length) console.warn('Incomplete or failed images:', failures);
    await page.screenshot({ path: 'page.png', fullPage: true });
  } finally {
    await browser.close();
  }
})().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

Puppeteer also supports an element screenshot through ElementHandle.screenshot(), which attempts to scroll a hidden element into view. If only one section matters, use that option after locating the element and confirming its images have loaded. See the Puppeteer screenshot guide.

4. Verify what loaded before capture

For ordinary <img> elements, a useful check is img.complete && img.naturalWidth > 0. A completed image with width zero commonly indicates a failed or unusable image. Treat errors as completed for purposes of avoiding an endless wait, then report them separately as the scripts do.

This check does not cover every visual asset. CSS background images, canvas drawings, video posters, custom elements, and images inside nested scroll containers need their own checks. If the page uses a carousel or content gated behind a click, perform the required interaction before capture. If the page is infinite-scroll, keep scrolling until the target content appears or the document stops growing within your configured limit.

For responsive images, inspect currentSrc to see which candidate the browser selected. Keep the viewport and device scale fixed when comparing screenshots; changing them can select a different image source and alter layout.

5. Capture options and workflow choices

Need Approach Limit to remember
Entire document Playwright page.screenshot({ fullPage: true }) or Puppeteer page screenshot with fullPage: true Capture scope does not trigger all lazy-loading mechanisms by itself.
One section or element Locate the target, scroll it into view, wait for its images, and take an element screenshot Element screenshot behavior and clipping can differ from a full-page result.
Infinite scroll Repeat scrolling and recheck document height or the presence of target content Set a maximum pass count or overall deadline.
Diagnose timing Use browser developer tools and inspect network requests as the page is scrolled A diagnostic timeline is not a batch capture workflow.

Playwright documents full-page screenshots in its screenshot guide. Chrome DevTools can record screenshots during page load alongside network activity; this helps identify when a visual state appeared and whether an image request occurred. See Chrome DevTools network screenshots.

6. Troubleshooting missing images

Symptom Likely cause Fix
Image area is blank in a full-page screenshot The browser had not scrolled near the image, so lazy loading was not triggered. Scroll progressively through the document, wait after each step, then capture.
Some lower-page images are missing Scrolling happened before later content was appended, or the pass ended too early. Re-read document height after each pass; use a bounded repeat loop and verify the target section.
The script waits until timeout A broken image never fires a successful load event, or the page continually adds content. Bound waits, treat load errors as settled, report naturalWidth === 0, and cap scroll passes.
Images are still placeholders The page requires a longer decode/render interval, interaction, or a site-specific visibility trigger. Wait for a specific selector or image condition; click or expand the relevant UI before capture.
img checks pass but the screenshot lacks artwork The artwork may be a CSS background, canvas, video poster, or custom-rendered element. Inspect the element’s computed style or rendering path and create a site-specific readiness check.
The capture differs from the browser window Viewport, device scale, responsive source, sticky elements, or animation state differs. Set viewport and scale before navigation; stabilize or disable animation where appropriate and inspect sticky sections.
Navigation times out despite a usable page The site keeps connections open or never reaches the selected lifecycle event. Use domcontentloaded or a specific readiness condition, then perform explicit scroll and image checks.

7. Performance, reliability, and cost

Scrolling adds work: each step can trigger image downloads, scripts, layout, and newly appended content. Larger viewports mean fewer steps but can change responsive layout and image selection. Retina scale increases output dimensions and image processing. A full-page image can use substantial memory on very long documents.

For a reliable job, set navigation and overall deadlines, cap the number of scroll passes, record failed image URLs, and save a result only after the target checks pass. For repeat captures, keep viewport, device scale, user agent, and page state consistent. Do not assume a fixed delay or network-idle event is a complete readiness signal; use page-specific checks where completeness matters.

Browser automation cost depends on the compute and browser infrastructure you operate; this workflow has no fixed API price. It can be economical for an existing automation stack, but you maintain browser installation, execution capacity, timeouts, and failure handling yourself. When many pages need capture, a screenshot API may reduce that operational work.

8. Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. Its full-page capture loads lazy images. A single request returns an image or PDF, and it accepts the cookie or consent banner like a visitor before removing more than 60 known consent platforms, newsletter popups, and chat widgets; each of those steps can be turned off.

Here is the one-call cURL example; the same endpoint and parameters are documented in the ScreenshotNeo API docs:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python:

import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)

Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo removes cookie banners, popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are never billed; response headers report the page verdict and billing status. An MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots. Sign up for 1,000 free screenshots a month, with no card required.

9. Frequently asked questions

Does waitUntil: 'load' trigger every lazy image?

No. It waits for the page’s load lifecycle event, but an offscreen lazy image may not be requested until scrolling brings it near the viewport.

How long should I wait after each scroll?

There is no universal duration. Start with a short bounded pause, observe request and render timing for the target site, then tune the pause or wait for a specific image condition.

Can I use this for a single image or component?

Yes. Scroll the target into view, wait for its image to complete successfully, and take an element screenshot. For Puppeteer, the element screenshot method attempts to bring a hidden element into view.

Will checking every img prove the whole screenshot is ready?

No. It covers image elements only. Background images, canvas, video, nested scrollers, and interaction-driven content need separate readiness checks.