ScreenshotNeo

BlogHow-to

How to Capture a Website Screenshot After Lazy-Loaded Images Finish Loading

Scroll to trigger lazy loading, verify the images you need are ready, then capture the full page with Playwright or Puppeteer.

By the ScreenshotNeo team4 October 20269 min read

To capture a full-page screenshot with lazy-loaded images, first scroll through the page to trigger loading, then check that the images and any page-specific content you need are ready. Only then take the screenshot. A full-page option captures the scrollable page; it does not guarantee that offscreen images were requested or finished loading.

The examples below use Playwright with JavaScript and Puppeteer with Node.js. They scroll in viewport-sized steps, wait briefly for content, check ordinary <img> elements, and capture the page. Adapt the readiness checks to the site: CSS background images, custom components, infinite scrolling, and interaction-gated content need additional handling.

1. Understand what “loaded” means

Many pages defer loading images until they approach the viewport. Browser automation that navigates directly to a page and immediately takes a full-page screenshot may capture those image locations before scrolling has triggered their requests. Waiting for network activity to quiet down can help, but it does not prove that every lazy resource has been requested or rendered.

For ordinary image elements, img.complete && img.naturalWidth > 0 is a useful readiness check. It detects successfully loaded images in the document, including images that have already failed, but it does not cover CSS backgrounds or application-specific content. Decide in advance which images and sections matter, and include any page-specific loading indicators in the check.

2. Playwright: scroll, check images, and capture

Install Playwright and its browser if your project does not already have them:

npm install playwright
npx playwright install chromium

Save this as capture-lazy-images.mjs. Set TARGET_URL to the page you need to capture.

import { chromium } from 'playwright';

const TARGET_URL = 'https://example.com';
const OUTPUT = 'page.png';
const STEP_WAIT_MS = 250;
const IMAGE_TIMEOUT_MS = 15000;

const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });

try {
  await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });

  // Visit each viewport-sized region so viewport-triggered lazy loading can run.
  await page.evaluate(async (stepWaitMs) => {
    const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    let previousHeight = 0;
    let stableHeightPasses = 0;

    while (stableHeightPasses < 3) {
      const height = document.documentElement.scrollHeight;
      for (let y = 0; y < height; y += Math.max(1, window.innerHeight * 0.8)) {
        window.scrollTo(0, y);
        await pause(stepWaitMs);
      }
      window.scrollTo(0, document.documentElement.scrollHeight);
      await pause(stepWaitMs);

      const newHeight = document.documentElement.scrollHeight;
      stableHeightPasses = newHeight === previousHeight ? stableHeightPasses + 1 : 0;
      previousHeight = newHeight;
    }
  }, STEP_WAIT_MS);

  // Wait for ordinary IMG elements to either load or fail. Failed images are
  // reported so they do not make the script wait forever.
  const imageResults = await page.evaluate(async (timeoutMs) => {
    const images = [...document.images];
    const waitForImage = (img) => {
      if (img.complete) {
        return Promise.resolve({ src: img.currentSrc || img.src, ok: img.naturalWidth > 0 });
      }
      return new Promise((resolve) => {
        const finish = () => resolve({ src: img.currentSrc || img.src, ok: img.naturalWidth > 0 });
        img.addEventListener('load', finish, { once: true });
        img.addEventListener('error', finish, { once: true });
        setTimeout(finish, timeoutMs);
      });
    };
    return Promise.all(images.map(waitForImage));
  }, IMAGE_TIMEOUT_MS);

  const failed = imageResults.filter((result) => !result.ok);
  if (failed.length) {
    console.warn(`${failed.length} image(s) failed or did not finish before the timeout.`);
    console.warn(failed.map((result) => result.src));
  }

  await page.evaluate(() => window.scrollTo(0, 0));
  await page.screenshot({ path: OUTPUT, fullPage: true });
  console.log(`Saved ${OUTPUT}; checked ${imageResults.length} image(s).`);
} finally {
  await browser.close();
}

The loop makes several passes because scrolling can add content and increase document height. The three stable-height passes are a practical stopping rule, not a guarantee for pages that continue loading indefinitely. Adjust the pause and stopping rule for the page you are capturing. The image timeout limits how long the script waits for an individual image; it does not turn a failed image into a successful one.

Playwright options to tune

  • Navigation: use domcontentloaded for an initial document state, or wait for a page-specific selector that signals the content you need is present. A fixed navigation wait is not a substitute for checking lazy images.
  • Viewport: choose the width and height that trigger the page’s responsive layout and lazy-loading behavior. A different viewport can select different image sources.
  • Scroll step and pause: smaller steps and longer pauses can help pages with slow intersection observers or delayed requests, at the cost of capture time.
  • Image scope: check only relevant images if a page contains many offscreen, decorative, or intentionally broken images. Add selectors for page-specific loading states.
  • Screenshot scope: use fullPage: true for the entire scrollable document. Capture a locator instead when only one element matters; use a normal viewport screenshot when only the visible region matters.

3. Puppeteer: the same workflow in Node.js

Install Puppeteer, which downloads a compatible browser as part of its normal installation:

npm install puppeteer

Save as capture-lazy-images-puppeteer.mjs and set the target URL. This script uses the same scroll-and-check approach before calling page.screenshot().

import puppeteer from 'puppeteer';

const TARGET_URL = 'https://example.com';
const OUTPUT = 'page.png';
const STEP_WAIT_MS = 250;
const IMAGE_TIMEOUT_MS = 15000;

const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });

try {
  await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });

  await page.evaluate(async (stepWaitMs) => {
    const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    let previousHeight = 0;
    let stableHeightPasses = 0;

    while (stableHeightPasses < 3) {
      const height = document.documentElement.scrollHeight;
      for (let y = 0; y < height; y += Math.max(1, window.innerHeight * 0.8)) {
        window.scrollTo(0, y);
        await pause(stepWaitMs);
      }
      window.scrollTo(0, document.documentElement.scrollHeight);
      await pause(stepWaitMs);
      const newHeight = document.documentElement.scrollHeight;
      stableHeightPasses = newHeight === previousHeight ? stableHeightPasses + 1 : 0;
      previousHeight = newHeight;
    }
  }, STEP_WAIT_MS);

  const imageResults = await page.evaluate(async (timeoutMs) => {
    const images = [...document.images];
    return Promise.all(images.map((img) => new Promise((resolve) => {
      const finish = () => resolve({ src: img.currentSrc || img.src, ok: img.naturalWidth > 0 });
      if (img.complete) return finish();
      img.addEventListener('load', finish, { once: true });
      img.addEventListener('error', finish, { once: true });
      setTimeout(finish, timeoutMs);
    })));
  }, IMAGE_TIMEOUT_MS);

  const failed = imageResults.filter((result) => !result.ok);
  if (failed.length) console.warn('Images failed or timed out:', failed.map((result) => result.src));

  await page.evaluate(() => window.scrollTo(0, 0));
  await page.screenshot({ path: OUTPUT, fullPage: true });
  console.log(`Saved ${OUTPUT}; checked ${imageResults.length} image(s).`);
} finally {
  await browser.close();
}

Puppeteer documents networkidle2 as a navigation option in its screenshot example. It can be useful as an additional wait, but sites with persistent requests may never become idle, and idle network activity does not establish that every lazy image has loaded. Prefer an explicit readiness condition for the content you need.

4. Handle page-specific loading behavior

CSS background images

document.images does not include CSS backgrounds. If the target page uses them, inspect the relevant elements’ computed backgroundImage values and wait for those image URLs to load, or wait for a page-specific state that indicates the section has rendered. Backgrounds may contain multiple layers or gradients, so a generic image-element check is insufficient.

Infinite scroll and virtualized lists

Infinite-scroll pages may keep adding content every time you reach the bottom. Virtualized lists may remove items above or below the viewport, so a full-page screenshot can show only the currently rendered subset. Set an explicit capture scope and stopping condition, such as a maximum number of scrolls, a known item count, or a specific end marker. If the page only renders visible rows, capture the sections you need separately or use the application’s supported export route.

Images that load after interaction

Some pages reveal images after clicking a tab, expanding an accordion, accepting a consent prompt, or hovering. Perform the required interaction before scrolling and checking. A screenshot workflow cannot infer which hidden states should be opened.

Responsive sources and layout shifts

Responsive pages can change image URLs and dimensions based on viewport width. Set the intended viewport before navigation. After images load, allow layout to settle and inspect the capture for shifts that moved content or exposed blank areas. If fonts affect the layout, wait for the page’s fonts as well as its images, for example with await page.evaluate(() => document.fonts.ready).

5. Verify the capture and choose the right scope

  1. Confirm the document reached the expected section or end marker.
  2. Review the logged failed-image list. Investigate relevant failures instead of silently treating them as loaded.
  3. Open the output and look for empty image regions, placeholders, clipped content, or unexpected layout changes.
  4. Repeat with a page-specific selector or readiness check if the site renders content asynchronously.
  5. For visual regression tests, keep viewport, browser, page state, and waits consistent between runs.

Playwright’s screenshot assertions can wait for consecutive screenshots to stabilize for visual comparison in its test runner. That is useful for visual testing, but visual stability alone does not prove that all offscreen assets were requested.

6. Troubleshooting

Symptom Likely cause Fix
Images are blank in the full-page output The page was captured before scrolling triggered lazy loading, or image requests failed. Scroll through the page, check image completion and natural width, and inspect failed URLs.
The script waits indefinitely for network idle The page maintains analytics, streaming, or other ongoing requests. Use a bounded navigation wait and explicit selectors or image readiness checks instead of relying only on network idle.
The image check reports failures The URL returned an error, access is blocked, or the check ran before the page assigned the final source. Inspect currentSrc, verify access in the browser context, and wait for the page’s own loading state where needed.
New sections keep appearing Infinite scroll or content expansion keeps increasing document height. Define a maximum scroll count or known stopping marker and capture a deliberate scope.
Some rows or cards are missing The page virtualizes content and only renders items near the viewport. Capture sections individually or use an application-supported way to render or export the full dataset.
Background art is missing while IMG checks pass The asset is a CSS background, which is absent from document.images. Add checks for the relevant computed background URLs or page-specific rendering state.
Text or image positions differ between runs Fonts, responsive layout, animations, or late content changed the page after capture began. Fix the viewport, wait for fonts and relevant content, and disable or settle animations when the page allows it.
Capture is much taller than expected A loop or page keeps extending content, or hidden content contributes to document height. Log the height after each pass, set a maximum capture height, and stop at a known section.

7. Performance, reliability, and cost

Scrolling the whole document takes longer than an immediate screenshot, especially when each step includes a pause or the page loads many assets. Use the smallest scroll step and wait that reliably trigger the page’s loading behavior. A timeout should bound the wait, while logs should distinguish a failed image from a successful one.

For repeatable captures, make the browser version, viewport, target state, and stopping condition consistent. Do not assume that a fixed sleep or a network-idle event is universally reliable. Capture only the needed region when a full-page image is unnecessary; very tall pages consume more time and produce larger files. Browser automation has no per-screenshot service charge, though it uses your compute, maintenance, and browser infrastructure.

Or skip the browser setup

If you want a screenshot API to handle the browser capture, ScreenshotNeo returns an image or PDF from one GET request. Its API documentation covers the request options.

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

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
    timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', new Uint8Array(await res.arrayBuffer()));

ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are never billed, and responses identify the page verdict and billing status. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots.

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

FAQ

Does fullPage: true load lazy images?

No. It captures the full scrollable page, but you should scroll first to trigger viewport-based loading and verify the content you need.

Is a fixed delay enough?

Not reliably. A delay may be too short for one page and unnecessarily long for another. Use image and page-specific readiness checks with a timeout.

Does networkidle2 guarantee the page is ready?

No. It is a documented navigation option and useful in some workflows, but it cannot guarantee every lazy-loaded asset was requested or rendered.

Can I capture a page with infinite scrolling?

Yes, if you define how much content to load and where to stop. An unbounded page has no finite “everything” screenshot.

References