ScreenshotNeo

BlogHow-to

How to Capture a Website Screenshot After a Lazy-Loaded Image Appears

Scroll the image into view, wait until it loads, then capture. Use these Playwright and Puppeteer examples to avoid missing images in screenshots.

By the ScreenshotNeo team4 October 20269 min read

A page’s load event does not guarantee that every lazy-loaded image is ready. Scroll the target image into view, wait for that image to finish loading, and only then capture the page or element. For an <img>, a useful readiness check is img.complete && img.naturalWidth > 0. The first condition says loading has finished; the second helps distinguish a successfully loaded image from a broken one.

This guide shows runnable Playwright and Puppeteer examples, how to capture one image or a full page, and what to do when a site uses a custom loader. A full-page screenshot option controls the capture area; do not assume it will trigger every site’s lazy-loading behavior.

1. Why lazy-loaded images are missing

Lazy loading defers fetching non-critical images until they approach the viewport. A site may use the browser’s loading="lazy" behavior, or implement its own scroll or intersection-based loader. In either case, an image far below the initial viewport may not have been requested when navigation finishes. MDN documents that the window load event can fire while lazily loaded media remains unloaded. See MDN: HTMLImageElement.complete and MDN: the img loading attribute.

Network-idle navigation waits can be useful for initial page work, but they are not proof that images which have not yet been requested are ready. Trigger the page’s loading behavior first, then check the image itself.

2. Choose the capture scope

Need Capture What to verify
One image or component Element screenshot Scroll the target into view and wait for its image to load.
The whole document Full-page screenshot Trigger lazy loading down the page and wait for the images you need.
Images throughout a very long page Progressive scroll, then full-page screenshot Visit successive viewport positions; check relevant images or wait for loading to settle at each position.

Playwright supports both full-page and locator screenshots. Its fullPage option captures the full scrollable page, but that option alone does not establish that the site’s lazy-loading code ran. Puppeteer supports page and element screenshots; its element screenshot attempts to scroll a hidden element into view, which can trigger viewport-based loading, but you should still verify the image state. Refer to the Playwright screenshot guide, Playwright Page.screenshot API, and Puppeteer screenshot guide.

3. Playwright: wait for the image, then capture

Install Playwright and its Chromium browser in your project using the official installation instructions. Save this as capture-lazy-image.mjs and run it with node capture-lazy-image.mjs. Set TARGET_IMAGE_SELECTOR to a selector for the image you want. The example scrolls the image into view, waits for successful image loading, then captures the element and, optionally, the full page.

import { chromium } from 'playwright';

const url = process.env.TARGET_URL ?? 'https://example.com/gallery';
const selector = process.env.TARGET_IMAGE_SELECTOR ?? 'img.hero-image';
const browser = await chromium.launch({ headless: true });

try {
  const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });

  const image = page.locator(selector).first();
  await image.waitFor({ state: 'attached', timeout: 15_000 });
  await image.scrollIntoViewIfNeeded();

  // Wait for the image element's own load/error state. A broken image is an error,
  // not a successful capture of the requested image.
  const state = await image.evaluate((img) => {
    if (!(img instanceof HTMLImageElement)) {
      throw new Error('The selector must match an <img> element.');
    }
    if (img.complete) return img.naturalWidth > 0 ? 'loaded' : 'failed';
    return new Promise((resolve) => {
      img.addEventListener('load', () => resolve('loaded'), { once: true });
      img.addEventListener('error', () => resolve('failed'), { once: true });
    });
  });
  if (state !== 'loaded') throw new Error(`Image did not load successfully: ${selector}`);

  await image.screenshot({ path: 'image.png' });
  await page.screenshot({ path: 'full-page.png', fullPage: true });
} finally {
  await browser.close();
}

Run it with an explicit URL and selector as needed:

TARGET_URL='https://example.com/article' TARGET_IMAGE_SELECTOR='article img[data-role="hero"]' node capture-lazy-image.mjs

The script intentionally treats a broken image as a failure instead of silently saving a screenshot that appears successful. If you only need the full page, remove the element screenshot call. If you only need the image, remove the full-page call.

4. Puppeteer: wait for the image, then capture

Install Puppeteer following its official installation guide. Save this as capture-lazy-image.cjs, set the URL and selector, and run node capture-lazy-image.cjs. The image’s own load or error event is observed after scrolling it into view.

const puppeteer = require('puppeteer');

(async () => {
  const url = process.env.TARGET_URL || 'https://example.com/gallery';
  const selector = process.env.TARGET_IMAGE_SELECTOR || 'img.hero-image';
  const browser = await puppeteer.launch({ headless: true });

  try {
    const page = await browser.newPage();
    await page.setViewport({ width: 1440, height: 1000 });
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });

    const image = await page.waitForSelector(selector, { timeout: 15_000 });
    await image.evaluate((el) => el.scrollIntoView({ block: 'center' }));

    const state = await image.evaluate((img) => {
      if (!(img instanceof HTMLImageElement)) {
        throw new Error('The selector must match an <img> element.');
      }
      if (img.complete) return img.naturalWidth > 0 ? 'loaded' : 'failed';
      return new Promise((resolve) => {
        img.addEventListener('load', () => resolve('loaded'), { once: true });
        img.addEventListener('error', () => resolve('failed'), { once: true });
      });
    });
    if (state !== 'loaded') throw new Error(`Image did not load successfully: ${selector}`);

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

Puppeteer’s element screenshot may scroll a hidden target into view automatically, but making the scroll explicit helps ensure the intended loading trigger occurs before the readiness check. Its screenshot guide demonstrates navigation with networkidle2; that can be appropriate for some pages, but it still cannot prove that an off-screen lazy resource was requested and loaded.

5. Handle long pages and custom loaders

On a long page, scrolling directly to one target is enough when you only need that target. If the screenshot must include many lazy images, scroll through the page in increments so each viewport can trigger loading. Pause at each position and wait for the images that matter to finish. Then capture the full page.

async function triggerLazyImages(page) {
  await page.evaluate(async () => {
    const step = Math.max(300, Math.floor(window.innerHeight * 0.75));
    for (let y = 0; y < document.documentElement.scrollHeight; y += step) {
      window.scrollTo(0, y);
      await new Promise((resolve) => setTimeout(resolve, 150));
    }
    window.scrollTo(0, 0);
  });
}

await triggerLazyImages(page);
// Follow with checks for required images, then take the full-page screenshot.

The short pause gives scroll handlers a chance to run; it is a starting point, not a universal readiness guarantee. For a known set of image selectors, explicitly wait for each to load or fail, as in the earlier examples. A page may also replace an image node, change its src, render a CSS background image, or load content only after an interaction. In those cases, identify the site’s actual observable completion condition: for example, wait for the final image URL, a component state, or a visible loaded marker. The HTMLImageElement check applies to <img> elements, not CSS backgrounds or arbitrary canvas rendering.

6. Common problems and fixes

Symptom Likely cause Fix
The screenshot contains an empty image area. The image was below the viewport and had not been requested. Scroll it into view, then wait for its load state before capture.
load or navigation completed, but the image is absent. The document event covers eagerly loaded resources; deferred images may load later. Wait on the target image itself, not only the page event.
naturalWidth is zero after completion. The image request failed, was blocked, or returned unusable data. Treat it as a failed image; inspect the URL, browser console, network response, authentication, and request blocking rules.
The selector times out. The selector is wrong, the image is inserted later, or the content is inside a frame or shadow root. Confirm the selector against the rendered DOM; wait for the component; target the correct frame or use the site’s supported shadow-DOM locator.
The image check passes, but the component still looks unfinished. The site renders overlays, captions, or animation after the image loads. Wait for the component’s own ready state or a stable visual condition in addition to the image state.
Full-page capture still misses images. The full-page option expanded the capture area but did not trigger the site’s loader. Progressively scroll first, then wait for required images and capture.
The image URL changes after scrolling. The page uses a responsive source, placeholder replacement, or custom loader. Scroll first; read the final currentSrc and verify the resulting image after the change.

7. Reliability, performance, and cost

Use the narrowest readiness condition that matches the result you need. Waiting on one target image is faster and more reliable than waiting for every network request to stop on a busy page. For a complete long-page image, progressive scrolling adds work proportional to the page length; avoid repeated full-page captures while debugging if an element capture will answer the question.

Set navigation and selector timeouts so a broken site cannot hang the job indefinitely. Handle image errors explicitly and decide whether a missing image should fail the whole capture or produce a partial result. For pages that change over time, capture only after the site’s visible state is stable. Browser automation consumes compute and time according to page complexity, network conditions, and the amount of scrolling and rendering; there is no universal duration or fixed cost for this procedure.

8. Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server for developers. One GET request can return a PNG, JPEG, WebP, or PDF. Its full-page capture loads lazy images; it also supports waiting for a selector, a delay, or network idle. The API accepts the parameter names used by other screenshot APIs, which can make switching straightforward. See the ScreenshotNeo website and API documentation.

cURL

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

Python

import requests

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

Node.js

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://example.com/gallery',
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(({ writeFile }) => writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));

Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed; response headers say the page verdict and whether the request was billed. 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 screenshots. Every feature is on every plan.

Create a free ScreenshotNeo account to get 1,000 screenshots a month with no card.

9. Frequently asked questions

Does loading="lazy" mean the image will always load as soon as it enters the viewport?

No. The browser loads it when it approaches a browser-determined distance from the viewport; custom site code can use different triggers. Check the image’s actual state.

Should I wait for every image on the page?

Only if your output requires every image. For a single target, wait for that image. For a full-page result with many deferred images, trigger loading down the page and verify the images that matter.

Can I screenshot an image before it has loaded?

You can capture the page at any time, but the output may contain an empty area or placeholder. Wait for successful image completion when the real image must appear.

What if the page uses a CSS background instead of an image element?

The complete and naturalWidth checks do not apply. Wait for the background URL or its containing component’s ready state, then capture that element or the page.