Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

To capture lazy-loaded images with Puppeteer, navigate to the page, scroll through its content so off-screen images approach the viewport, wait for the image elements to settle, and then call page.screenshot({ fullPage: true }). A load event or network-idle wait alone is not proof that lazy images were fetched. Check each relevant image’s complete and, when successful image data is required, naturalWidth before taking the screenshot.

Why lazy-loaded images are missing

Browsers can defer an image request until the image is near the visual viewport. An <img loading="lazy"> element may therefore exist in the DOM while its image data has not been requested. Scrolling causes the browser’s native lazy-loading machinery, or a site’s JavaScript equivalent, to start loading content.

The window load event is not a complete-page image barrier. It covers resources that were loaded for the event, while lazy images farther down the document may still be pending. MDN specifically notes that lazy-loaded images may not be available when load fires. For an individual image, the browser exposes HTMLImageElement.complete; use naturalWidth > 0 as an additional check when a failed image must not be treated as successful.

Puppeteer screenshots capture the rendered state that exists at the moment of the call. The reliable general workflow is therefore:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  1. Open the URL with a wait condition appropriate to the site.
  2. Scroll downward in viewport-sized increments, pausing briefly after each move.
  3. Re-read document height because infinite-scroll pages can append content.
  4. Wait for the relevant image elements to finish or fail, then inspect their readiness.
  5. Capture the full page or a selected element.

This is a robust starting point, not a universal guarantee. Sites can lazy-load through custom JavaScript, CSS backgrounds, frames, shadow roots, or interactions that require a site-specific readiness condition.

Prerequisites and a safe capture plan

Install and launch Puppeteer

Use a current Puppeteer release compatible with your project’s Node.js version. The official screenshot guide currently appears under Puppeteer 25.12.0, but APIs can change, so verify details against the version installed in your project.

npm install puppeteer

Automate only pages and content you are authorized to access. Respect applicable terms, authentication boundaries, robots guidance, and rate limits. Do not use automation to bypass CAPTCHAs, bot checks, or access controls.

Choose the navigation wait condition

waitUntil: 'domcontentloaded' waits for the initial HTML to be parsed. It is often a good starting point when the page continues rendering after navigation. A network-idle option can be useful when you know the page has a finite burst of requests, but it does not trigger off-screen lazy loading. A selector or application-specific readiness predicate is more precise when the site exposes one.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Complete JavaScript example: scroll, verify, and capture

The following script uses a bounded scrolling loop, rechecks the document height, waits for every <img> to reach a terminal state, reports failures, and writes a full-page PNG. Adapt the URL, viewport, limits, and selectors to the site.

const puppeteer = require('puppeteer');

const url = 'https://example.com/gallery';

(async () => {
  const browser = await puppeteer.launch({
    // headless: true is the default in current Puppeteer releases
  });

  try {
    const page = await browser.newPage();
    await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });

    await page.goto(url, {
      waitUntil: 'domcontentloaded',
      timeout: 60000
    });

    await page.evaluate(async () => {
      const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
      const maxSteps = 100;
      const stableRoundsNeeded = 3;
      let previousHeight = 0;
      let stableRounds = 0;

      for (let step = 0; step < maxSteps && stableRounds < stableRoundsNeeded; step++) {
        const height = document.documentElement.scrollHeight;
        const viewportBottom = window.scrollY + window.innerHeight;
        const target = Math.min(viewportBottom + window.innerHeight, height);
        window.scrollTo(0, target);
        await pause(250);

        const nextHeight = document.documentElement.scrollHeight;
        if (nextHeight === previousHeight) {
          stableRounds++;
        } else {
          stableRounds = 0;
        }
        previousHeight = nextHeight;
      }

      window.scrollTo(0, 0);

      // Resolve on load or error so one broken image cannot hang forever.
      await Promise.all([...document.images].map(img => {
        if (img.complete) return Promise.resolve();
        return new Promise(resolve => {
          img.addEventListener('load', resolve, { once: true });
          img.addEventListener('error', resolve, { once: true });
        });
      }));
    });

    const imageReport = await page.evaluate(() => [...document.images].map((img, index) => ({
      index,
      src: img.currentSrc || img.src,
      complete: img.complete,
      naturalWidth: img.naturalWidth,
      naturalHeight: img.naturalHeight
    })));

    const failed = imageReport.filter(img => img.complete && img.naturalWidth === 0);
    if (failed.length) {
      console.warn(`Images with no decoded data: ${failed.length}`);
      console.warn(failed);
    }

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

What the scrolling loop does

  • It advances by approximately one viewport at a time, giving viewport-triggered loaders an opportunity to run.
  • It reads scrollHeight after each pause. This matters on feeds that append cards as the bottom approaches.
  • It stops after three unchanged heights or 100 iterations. Both limits prevent an infinite-scroll page from keeping the process alive forever.
  • It returns to the top before the screenshot so the page begins from a predictable scroll position.

The fixed 250 millisecond pause is only a starting point. Replace it with a selector, application state, or other predicate when the site provides a meaningful “items loaded” signal.

Waiting for image readiness correctly

complete means terminal state, not necessarily success

img.complete becomes true when the image has finished loading or has failed. That makes it useful for preventing an indefinite wait, but not for proving that pixels are available. If successful image data is required, require img.naturalWidth > 0 (and optionally naturalHeight > 0).

await page.waitForFunction(() => {
  const images = [...document.images];
  return images.every(img => img.complete);
}, { timeout: 30000 });

const status = await page.evaluate(() => ({
  total: document.images.length,
  loaded: [...document.images].filter(img => img.complete && img.naturalWidth > 0).length,
  failed: [...document.images].filter(img => img.complete && img.naturalWidth === 0).length
}));

Decide your policy explicitly. A documentation snapshot might log and skip failed images; a visual regression test may throw when any required image has zero natural width. If only a gallery matters, select those images instead of failing on an unrelated tracking pixel.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use a site-specific predicate when possible

If the application adds a class such as .gallery-ready, waits for a known item count, or exposes a loading indicator, wait for that condition with page.waitForSelector() or page.waitForFunction(). A predicate describing the page’s actual completion state is more reliable than an arbitrary sleep.

Capture a whole page or one element

Full-page screenshot

await page.screenshot({ path: 'page.png', fullPage: true });

Full-page capture is appropriate after scrolling and readiness checks. Very long or continuously growing documents can consume substantial memory and produce unwieldy files; set a height or item-count policy, or capture sections.

One image or component

const card = await page.$('.product-card');
if (!card) throw new Error('Product card not found');
await card.screenshot({ path: 'product-card.png' });

Puppeteer’s ElementHandle.screenshot() attempts to scroll a hidden element into view. Still verify that the image inside it has loaded: bringing an element into view is not the same as confirming its network request succeeded.

Selected images only

const images = await page.$$('main article img');
for (const [index, image] of images.entries()) {
  await image.screenshot({ path: `article-image-${index}.png` });
}

Use stable selectors and account for responsive layouts. An element can be present but have zero dimensions, be covered by an overlay, or be replaced after a framework re-render; reselect it if the page mutates.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Network-idle waits versus lazy loading

Need Technique What it does not prove
Trigger off-screen image loading Viewport-by-viewport scrolling with pauses That custom loaders, backgrounds, frames, or shadow roots are complete
Wait for general network quiet waitForNetworkIdle() or a network-idle navigation option That off-screen images were ever requested
Wait for a known application state waitForSelector() or waitForFunction() Anything outside the condition you wrote
Capture the rendered page page.screenshot({ fullPage: true }) That missing resources will be fetched automatically

Network activity can become quiet while lazy images remain untouched below the fold. Conversely, analytics, advertisements, or streaming requests can keep a page busy even after the images you need are ready.

Handling special page structures

Infinite scroll and dynamic height

Do not assume the initial scrollHeight is final. Keep a maximum step count, elapsed-time deadline, or expected item count. If content is intentionally unbounded, capture a defined number of screens or cards rather than waiting for a bottom that never arrives.

CSS background images

document.images sees <img> elements, not images assigned through background-image. Identify the relevant elements and inspect their computed styles, then wait for the resource in a way appropriate to the application. A screenshot can still show a missing background even when every <img> reports a positive naturalWidth.

Frames and shadow DOM

Images inside an iframe belong to that frame’s document. Obtain the frame and run selectors and readiness checks there. Open shadow roots likewise require traversal into the shadow tree. A top-level document.images query will not automatically cover either case.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Responsive and retina output

Set the viewport and deviceScaleFactor deliberately. A different width can select different sources from srcset, change which cards are rendered, or alter when a lazy loader activates. Record these settings alongside the screenshot if the image is used for testing or publishing.

Troubleshooting missing or blank images

The screenshot is taken before scrolling finishes

Symptom: Lower sections are blank while the script exits quickly. Fix: await the scrolling function, add a pause or site-specific readiness predicate, and re-read scrollHeight after each step.

All images report complete: true, but some are blank

Cause: complete also covers failed loads. Fix: inspect naturalWidth, log the URL and failure count, and decide whether to retry, skip, or fail the capture.

The loop never reaches a stable height

Cause: infinite scroll, rotating ads, or a layout that changes continuously. Fix: enforce a maximum number of steps or total time and capture a defined range.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Waiting for network idle times out

Cause: long-polling, analytics, advertisements, or other persistent requests. Fix: stop using network idle as the sole completion test; wait for the specific selector or image set you need.

Only background visuals are absent

Cause: the readiness query covered <img> elements but not CSS backgrounds. Fix: inspect the component’s styles and application state, then wait for that resource separately.

An image works in a normal browser but not in automation

Possible causes: authentication, referer or user-agent checks, geolocation, a bot challenge, or a request blocked by browser policy. Fix: use authorized credentials and the site’s supported access path; do not attempt to defeat a CAPTCHA or access control.

The process runs out of memory

Cause: an extremely tall page, high device scale, or many simultaneous browser pages. Fix: capture sections or elements, lower the device scale factor, close pages promptly, and avoid keeping large image buffers in memory.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Performance, reliability, and cost considerations

Scrolling every viewport costs time because it intentionally activates deferred work. Limit the route to the content you need, use a realistic pause, and prefer a page-provided readiness signal. Reuse a browser process for a controlled batch of URLs, but create a fresh page per capture and close it in a finally block. Set navigation and readiness timeouts so a broken page cannot consume a worker indefinitely.

For repeatable visual tests, fix viewport dimensions, device scale, timezone, locale, and authentication state. Record failed image URLs rather than silently presenting an apparently complete screenshot. Cache policy, request blocking, and resource interception can improve speed, but blocking an image request defeats the purpose of this capture.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server for developers. It loads the page, accepts cookie and consent banners like a visitor, and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Only clean shots are billed: bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and the response identifies the result with X-Page-Verdict and X-Billed headers. It can also load lazy images for full-page captures.

One GET request returns PNG, JPEG, WebP, or PDF. The service supports full-page capture, element selectors, dark mode, device presets and custom viewports, retina scale, PDF paper and page settings, custom CSS and JavaScript, clicks, selector waits, delays, network-idle waits, ad/tracker/request blocking, headers, cookies, user agents, Authorization, timezone, geolocation, transparent backgrounds, resizing, configurable-TTL caching, signed links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, a usage API, an OpenAPI specification, and compatible parameter names used by other screenshot APIs. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use the ScreenshotNeo documentation for the complete parameter list. The same request pattern works from common languages:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
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)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Plans are Free: 1,000 shots per month with no card; Starter: $5 for 3,000; Growth: $15 for 15,000; Pro: $39 for 60,000; Scale: $99 for 250,000; and Business: $249 for 1,000,000. Yearly billing gives two months free, and every feature is included on every plan. ScreenshotNeo is the practical shortcut when you do not want to maintain browser launch, scrolling, image checks, popup handling, and failure accounting yourself. Create a free ScreenshotNeo account with 1,000 screenshots a month and no card.

Frequently Asked Questions

Should I use waitUntil: 'networkidle0' for every page?

No. Persistent requests can prevent it from completing, and network quiet does not activate off-screen lazy loading. Scroll first, then wait for the specific images or application state you need.

Can Puppeteer capture images loaded by srcset?

Yes, once the browser selects and decodes the source. Set the intended viewport and check currentSrc, complete, and naturalWidth before capture.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

How do I know whether an image failed rather than merely being deferred?

After scrolling and waiting, inspect img.complete together with img.naturalWidth. A complete image with zero natural width has no usable decoded image data.

Why does an element screenshot not include a background image?

Element screenshots capture rendered pixels, but your readiness logic may not have waited for CSS background resources. Treat backgrounds separately from document.images.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.