ScreenshotNeo

BlogHow-to

Puppeteer Screenshot Full Page with Lazy-Loaded Images

Scroll through a page to trigger lazy-loaded images, verify they loaded, then capture the full page with Puppeteer.

By the ScreenshotNeo team4 October 20268 min read

To capture lazy-loaded images in a full-page Puppeteer screenshot, first scroll through the page so its lazy-loading logic requests images as they approach the viewport. Wait for those images to load, return to the top, then call page.screenshot({ fullPage: true }). A navigation wait such as networkidle2 alone does not trigger scrolling and cannot guarantee that lazy images have loaded.

This workflow uses Puppeteer’s documented screenshot API, screenshot options, and page interaction primitives. The scroll-and-check helper below is an implementation pattern, not a built-in Puppeteer lazy-image feature. Sites can load images in different ways, so adapt the checks to the page.

Runnable example

Install Puppeteer in a new project:

npm init -y
npm install puppeteer

Save this as screenshot.js. It scrolls in viewport-sized steps, waits for images near each step, repeats if loading grows the document, checks ordinary image elements, and captures the whole page.

const puppeteer = require('puppeteer');

async function waitForImagesNearViewport(page, timeoutMs = 5000) {
  await page.evaluate(async (timeoutMs) => {
    const nearViewport = [...document.images].filter((img) => {
      const rect = img.getBoundingClientRect();
      return rect.bottom >= -window.innerHeight &&
        rect.top <= window.innerHeight * 2;
    });

    await Promise.all(nearViewport.map((img) => {
      if (img.complete) return Promise.resolve();
      return new Promise((resolve) => {
        const timer = setTimeout(resolve, timeoutMs);
        img.addEventListener('load', () => { clearTimeout(timer); resolve(); }, { once: true });
        img.addEventListener('error', () => { clearTimeout(timer); resolve(); }, { once: true });
      });
    }));
  }, timeoutMs);
}

async function scrollToTriggerLazyContent(page, { stepDelayMs = 200, imageTimeoutMs = 5000, maxPasses = 3 } = {}) {
  const viewportHeight = page.viewport().height;

  for (let pass = 0; pass < maxPasses; pass += 1) {
    const heightBefore = await page.evaluate(() => document.documentElement.scrollHeight);
    for (let y = 0; y < heightBefore; y += viewportHeight) {
      await page.evaluate((top) => window.scrollTo(0, top), y);
      await waitForImagesNearViewport(page, imageTimeoutMs);
      await new Promise((resolve) => setTimeout(resolve, stepDelayMs));
    }
    const heightAfter = await page.evaluate(() => document.documentElement.scrollHeight);
    if (heightAfter <= heightBefore) break;
  }
  await page.evaluate(() => window.scrollTo(0, 0));
}

(async () => {
  const url = process.argv[2] || 'https://example.com';
  const browser = await puppeteer.launch({ headless: true });
  try {
    const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
    await scrollToTriggerLazyContent(page);

    const imageReport = await page.evaluate(() => [...document.images].map((img) => ({
      src: img.currentSrc || img.src,
      complete: img.complete,
      naturalWidth: img.naturalWidth,
    })));
    const missing = imageReport.filter((img) => !img.complete || img.naturalWidth === 0);
    if (missing.length) {
      console.warn(`${missing.length} image element(s) did not load successfully`);
    }

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

Run it with node screenshot.js https://example.com. The script intentionally treats a failed image as a reportable condition rather than silently claiming the screenshot is complete. Its per-image timeout keeps a broken image from blocking the entire capture indefinitely.

Why scrolling and image checks both matter

fullPage: true asks Puppeteer to capture beyond the current viewport; it does not itself promise to activate every site’s lazy-loading behavior. Scrolling gives viewport-triggered mechanisms, including native lazy loading and many intersection-observer implementations, a chance to request content. Puppeteer provides page interaction and scrolling APIs, but the page determines how its content loads. See the official page interactions guide.

After scrolling, checking HTMLImageElement.complete and naturalWidth helps identify ordinary <img> elements that remain unfinished or failed. A completed image with naturalWidth === 0 did not produce a usable image. The example waits for images around each scroll position, then reports any remaining failures.

These checks do not cover every visual asset. A site may use CSS background images, canvas, video posters, or JavaScript that swaps sources after a custom event. For those cases, inspect the relevant DOM or application state and add a site-specific wait condition.

Choosing a navigation and network wait

Navigation readiness and image readiness are different conditions. Puppeteer documents networkidle2 as no more than two network connections for at least 500 ms; networkidle0 waits for no active connections for at least 500 ms. These lifecycle conditions can be useful, but neither means that every lazy image has been requested, loaded, and decoded. See Puppeteer lifecycle events and network-idle options.

Wait strategy Useful when Limit
domcontentloaded You want the document parsed and will apply explicit waits afterward. Images and client-rendered content may still be pending.
networkidle2 The page settles while a small number of connections remain open. It does not trigger scroll-based loading or prove all images are ready.
networkidle0 The page can reach a completely quiet network. Analytics, polling, or persistent connections may prevent the condition.
Scroll plus image checks Images load as they approach the viewport. Requires tuning for site-specific content and can take longer.

You can use a network-idle navigation wait if it behaves well on the target site, then still scroll and check images:

await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
await scrollToTriggerLazyContent(page);
await page.screenshot({ path: 'page.png', fullPage: true });

Options to tune

  • Viewport size: Set a realistic width and height before navigation. Lazy loading can depend on the viewport, and responsive layouts may change which images are selected.
  • Scroll step: One viewport at a time is a practical default. Use smaller steps if the page triggers content only after an element is close to view.
  • Step delay: Increase stepDelayMs when the site debounces scroll events or waits before requesting content. A fixed delay is a heuristic, not proof of completion.
  • Image timeout: Increase imageTimeoutMs for slow sources. Lower it when a capture job has a strict deadline.
  • Pass count: Repeat the scroll if images or inserted content extend page height. Set a finite maximum to avoid an endlessly growing page keeping the job alive.
  • Screenshot format: The example writes PNG. Puppeteer also supports screenshot options such as image type and quality for supported formats; check the installed version’s ScreenshotOptions reference.
  • Capture dimensions: fullPage: true captures the full document; omit it for viewport-only output. Very long pages can create large images and consume substantial memory.

Edge cases and reliability

  • Page height changes while loading: The helper measures height on each pass and repeats when it grows. Pages that append content continuously should have a known stopping condition, such as a maximum number of items or a target selector.
  • Images already marked complete: A cached or failed image can have complete === true. Check naturalWidth as well, as the example’s report does.
  • CSS backgrounds: document.images does not enumerate background images. Scroll to trigger them and wait for a page-specific selector, state change, or request completion signal.
  • Responsive source selection: srcset and sizes may choose a different asset at another viewport. Set the viewport before loading and scrolling.
  • Sticky headers and scroll handlers: Scrolling may change layout or obscure content. The final full-page capture is separate from viewport-by-viewport stitching, but page scripts can still affect layout during preparation.
  • Animations and carousels: Animated or rotating content can change during a long capture. If a stable frame matters, disable animations with site-specific CSS or wait for a known state.
  • Very tall pages: Browser and operating-system limits can constrain extremely large screenshots. Capture sections separately when a single image is too large to handle reliably.
  • Authentication and consent: Supply the required session state or cookies before navigation when the target page requires them. Respect the site’s access rules.

Troubleshooting

Symptom Likely cause Fix
Images are missing below the fold The page was captured before scrolling triggered lazy loading. Run the scroll pass before the screenshot; confirm the page height and image report afterward.
networkidle0 never completes The site keeps connections open, polls, or loads analytics continuously. Use domcontentloaded or networkidle2 with explicit selector and image waits.
Some images show zero natural width The request failed, the URL is invalid, or the server rejected the browser request. Inspect currentSrc, browser console and request failures; verify authentication and headers if needed.
Background images are absent The check only inspects image elements. Identify the background-image elements and wait for their computed style or corresponding requests.
The capture ends before appended content appears Scrolling expanded the page after the initial height measurement. Allow another pass or wait for a specific content-loaded condition; cap passes for pages that grow indefinitely.
Screenshot generation runs out of memory or is very slow The page is extremely tall, has many large images, or uses a high pixel scale. Reduce viewport or scale where appropriate, limit full-page output, or capture sections separately.
TimeoutError: Navigation timeout The selected navigation lifecycle never occurred within the timeout. Choose a lifecycle event suitable for the site and add targeted waits after navigation rather than extending every timeout without limit.

Performance, reliability, and cost

Scrolling adds time roughly in proportion to the number of viewport steps and the waits used at each step. Waiting only for nearby images avoids holding every step for images far down the page. Keep a total job deadline in production, record the final URL and failed image list, and close the browser in a finally block as shown.

For repeatable output, pin the Puppeteer version used by the project, set the viewport before navigation, use bounded timeouts, and make waits reflect the target site’s actual loading behavior. Network-idle is a threshold, not an image completeness guarantee. Full-page screenshots can be expensive in memory because the output dimensions grow with document height; PNG file size and capture time also depend on page content.

Running Puppeteer yourself has browser hosting and compute costs that depend on your environment and workload. There is no universal cost or speed figure for this workflow. A managed screenshot API can avoid maintaining the browser setup, with its own plan and request limits.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. Its full-page capture loads lazy images. For API options and configuration, see the ScreenshotNeo documentation.

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}`);
  • Cookie banners are accepted and removed before capture; the service also removes known consent platforms, newsletter popups, and chat widgets. Each cleanup step can be turned off.
  • Bot checks, blank pages, failed loads, timeouts, and cache hits are not 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 per month with no card. Paid plans start at $5 for 3,000 screenshots.

Sign up for 1,000 free screenshots a month, with no card required.

FAQ

Does fullPage: true load lazy images?

It requests a full-page screenshot. Trigger lazy loading separately by scrolling and waiting for the page’s content to load.

Is networkidle2 enough for a reliable screenshot?

It can be a useful navigation wait, but it does not prove that every lazy image has been requested or loaded. Pair it with scrolling and checks relevant to the page.

Why does the example allow an image wait to time out?

A broken or blocked image should not stall the whole capture forever. The script reports unsuccessful image elements so the caller can decide whether to retry or accept a partial result.

Will this work for every lazy-loading library?

No single scroll pattern covers every implementation. Some pages need a custom event, selector wait, or application-specific signal, especially for CSS backgrounds and virtualized content.

Which Puppeteer version should I use?

Use the version pinned in your project and consult its API reference for supported options and defaults. The official documentation can change over time.