ScreenshotNeo

BlogHow-to

How to Take a Screenshot of Lazy-Loaded Images in Playwright

Scroll to trigger lazy loading, verify images have loaded and decoded, then capture a reliable full-page or element screenshot in Playwright.

By the ScreenshotNeo team4 October 20269 min read

To capture lazy-loaded images reliably in Playwright, scroll the page until the target images have been triggered, wait for those images to load and decode, then take the screenshot. fullPage: true captures the full scrollable document, but it does not guarantee that offscreen lazy images were requested or are ready. The document load event also does not prove every lazy image has loaded. [MDN: img loading]

The example below uses Playwright’s JavaScript library. It scrolls in viewport-sized increments, checks each current <img> for successful loading, waits for successful images to decode, and saves a full-page PNG. It reports broken or unready images rather than silently treating them as ready.

1. Install Playwright

Create a project and install the Playwright package and Chromium browser:

npm init -y
npm install playwright
npx playwright install chromium

Save the following as screenshot.mjs. Run it with node screenshot.mjs https://example.com. The script accepts the target URL as its first command-line argument.

2. Scroll, wait for images, and capture

import { chromium } from 'playwright';

const targetUrl = process.argv[2];
if (!targetUrl) {
  throw new Error('Usage: node screenshot.mjs https://example.com');
}

const browser = await chromium.launch();
try {
  const page = await browser.newPage({
    viewport: { width: 1280, height: 800 },
    deviceScaleFactor: 1,
  });

  await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });

  // Visit the document in overlapping steps so images near the viewport
  // can be requested by native lazy loading or scroll-triggered page code.
  await page.evaluate(async () => {
    const step = Math.max(1, Math.floor(window.innerHeight * 0.8));
    let previousHeight = -1;
    for (let pass = 0; pass < 10; pass++) {
      const height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
      for (let y = 0; y < height; y += step) {
        window.scrollTo(0, y);
        // Pacing only: image readiness is checked separately below.
        await new Promise(resolve => setTimeout(resolve, 100));
      }
      if (height === previousHeight) break;
      previousHeight = height;
    }
    window.scrollTo(0, 0);
  });

  // Check after scrolling because the page may have inserted more images.
  // Bound each image wait so one stalled request cannot hang the script forever.
  const imageResults = await page.locator('img').evaluateAll(async images => {
    const timeout = ms => new Promise((_, reject) =>
      setTimeout(() => reject(new Error('image wait timed out')), ms));

    return Promise.all(images.map(async img => {
      const src = img.currentSrc || img.src;
      try {
        if (!img.complete) {
          await Promise.race([
            new Promise((resolve, reject) => {
              img.addEventListener('load', resolve, { once: true });
              img.addEventListener('error', () => reject(new Error('image load failed')), { once: true });
            }),
            timeout(15_000),
          ]);
        }
        if (img.naturalWidth === 0) throw new Error('image has no decoded pixels');
        await Promise.race([img.decode(), timeout(15_000)]);
        return { src, loaded: true };
      } catch (error) {
        return { src, loaded: false, error: error.message };
      }
    }));
  });

  const failedImages = imageResults.filter(result => !result.loaded);
  if (failedImages.length) {
    console.error('Images that did not load:', failedImages);
  }

  await page.screenshot({ path: 'page.png', fullPage: true });
  console.log(`Saved page.png; ${imageResults.length - failedImages.length}/${imageResults.length} img elements loaded and decoded.`);
} finally {
  await browser.close();
}

The 100 ms pause gives scroll-triggered code a chance to run; it is not a readiness guarantee. The explicit load and decode checks determine which <img> elements are ready. complete can also be true for a failed image, so the script checks naturalWidth > 0. The browser’s decode() promise resolves when the image is decoded and safe to render. [MDN: HTMLImageElement.decode()]

The sample caps scrolling at ten passes to avoid an unbounded loop on pages that keep extending. Change that limit or, preferably, replace it with the site’s known finite completion condition. If you need every image on an infinite feed, define a maximum item count, scroll boundary, or application signal first.

3. Choose full-page or element capture

Capture the whole document

Use page.screenshot({ fullPage: true }) when the desired output is the full scrollable document. This controls capture extent; it does not itself guarantee that lazy content was fetched. Scroll to trigger loading and check readiness before capture. [Playwright Page API]

Capture one element

Use a locator screenshot for a component or image gallery. Playwright scrolls the locator into view if needed, then captures it. If the target is inside a scrollable panel, a locator screenshot includes only the panel’s currently visible scrolled portion; it does not combine the hidden contents of that panel into one tall image. [Playwright Locator API]

const gallery = page.locator('#gallery');
await gallery.scrollIntoViewIfNeeded();
await gallery.screenshot({ path: 'gallery.png' });

4. Handle nested scrollers and targeted image sets

Scrolling the document will not necessarily activate a loader inside an independently scrollable gallery, chat panel, or virtualized list. Scroll that container itself, then check the images it contains:

const panel = page.locator('.image-panel');
await panel.evaluate(async element => {
  const step = Math.max(1, Math.floor(element.clientHeight * 0.8));
  for (let y = 0; y < element.scrollHeight; y += step) {
    element.scrollTop = y;
    await new Promise(resolve => setTimeout(resolve, 100));
  }
  element.scrollTop = 0;
});

const panelImages = await panel.locator('img').evaluateAll(async images =>
  Promise.all(images.map(async img => {
    try {
      if (!img.complete) {
        await new Promise((resolve, reject) => {
          img.addEventListener('load', resolve, { once: true });
          img.addEventListener('error', reject, { once: true });
        });
      }
      if (img.naturalWidth === 0) return { src: img.src, loaded: false };
      await img.decode();
      return { src: img.currentSrc || img.src, loaded: true };
    } catch {
      return { src: img.currentSrc || img.src, loaded: false };
    }
  }))
);
console.log(panelImages.filter(result => !result.loaded));
await panel.screenshot({ path: 'panel-visible-area.png' });

For a page containing unrelated or intentionally broken images, narrow the locator to the relevant area, such as page.locator('#product-gallery img'). A generic check of all img elements cannot tell whether a failure is expected for that page.

5. Wait for the right kind of readiness

Native loading="lazy" tells the browser it may defer offscreen images until they are expected to be needed. The browser chooses when that threshold is reached; it is not a fixed delay and it is not equivalent to waiting for the page’s load event. [MDN: img loading] Scroll target locations into or near the viewport to trigger those requests.

For tests, prefer a check tied to the expected content or application state. Playwright discourages using networkidle as a testing readiness signal; pages may keep network activity open, and network quiet does not prove an image is decoded. [Playwright Page API]

  • Native image: wait for complete, verify naturalWidth > 0, and await decode().
  • Application loader: wait for the site’s loaded state or image element insertion, then verify the resulting image.
  • Visual regression: Playwright Test’s toHaveScreenshot() waits for two consecutive screenshots to match before comparing. A stable placeholder can still match, so assert that expected images loaded separately. Keep browser, OS, viewport, and rendering environment consistent between runs. [Playwright visual comparisons]

page.locator('img').evaluateAll() sees image elements present at the time it runs. If scrolling inserts new elements, run the check after scrolling, as in the main example. If the page continues inserting content while the check runs, repeat scrolling and collection until the application’s finite completion signal is met.

6. Cover cases beyond ordinary img elements

Page content Why the generic img check may miss it What to do
<picture> with responsive sources The browser selects a source based on viewport and device scale. Set the intended viewport and scale before navigation, then inspect the rendered img.currentSrc and decode the image.
CSS background images They are not <img> elements. Wait for the component’s own ready state or inspect the relevant computed style and resource loading in page-specific code.
Images in iframes The parent page’s locator does not automatically enumerate a frame’s DOM. Use the appropriate Playwright frame locator and apply the same load/decode checks inside the frame.
Virtualized galleries Offscreen items may be removed from the DOM rather than retained for a full-page capture. Capture bounded sections or use an application-provided export/rendering mode; a full-page screenshot cannot include elements that are not rendered in the document.
Infinite feeds There is no natural final scroll height. Set a finite item count, maximum scroll position, or explicit stop condition before scrolling and capturing.

7. Troubleshooting

Symptom Likely cause Fix
Full-page screenshot has blank image areas The screenshot captured the document before offscreen images were triggered or decoded. Scroll through the target regions, then check complete, naturalWidth, and decode() before capture.
complete is true, but the image is broken complete can also be true after a failed load. Require naturalWidth > 0; log the image URL and investigate network access, response status, or a bad source URL.
decode() rejects The resource may have failed, changed while loading, or be unusable as an image. Check the image’s load/error state and naturalWidth; report that image as failed instead of treating the whole page as ready.
Images in a panel remain unloaded The panel has its own scroll position or loader. Scroll the panel itself and inspect images within that panel. Then capture the panel’s visible area or capture the page if that is the desired output.
The script hangs waiting for an image A request stalled without firing a load or error event. Apply a bounded timeout, log unresolved URLs, and use a site-specific wait or request timeout policy.
Some images never appear in the report The page inserts them after the check, uses CSS backgrounds, or renders content in a frame. Collect after the final scroll pass; repeat until a finite app signal, and handle backgrounds and frames separately.
Screenshot assertion passes with placeholders The placeholder is stable, so consecutive screenshots can match. Assert the expected image state separately before calling toHaveScreenshot().

8. Performance, reliability, and cost

Scrolling and waiting for every image increases capture time in proportion to page height, image count, and resource latency. Limit checks to the relevant gallery when the rest of the page does not matter. For visual tests, use a consistent viewport and rendering environment so expected differences do not come from different browsers or operating systems.

A full-page image can be very tall on long documents. For long pages or feeds, capture defined sections or establish a stop condition rather than attempting to render an unbounded page. Treat a failed image as a visible result in logs and decide explicitly whether the test should fail or allow known broken/optional assets.

Running Playwright locally has no ScreenshotNeo API charge, but it uses your machine or CI time and browser resources. If you use ScreenshotNeo instead, the free plan includes 1,000 shots per month without a card; paid plans start at $5 for 3,000 shots. Only clean shots are billed: bot checks/CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and each response identifies the result with X-Page-Verdict and X-Billed. [ScreenshotNeo]

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. One GET request returns an image or PDF, and its options include full-page capture with lazy images loaded. The API also supports custom viewport settings and several image formats. See the ScreenshotNeo API documentation for request options.

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}`);
if (!res.ok) throw new Error(`ScreenshotNeo request failed: ${res.status}`);
await Bun.write('shot.webp', res);

ScreenshotNeo removes cookie banners, popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. You get 1,000 screenshots a month free with no card; paid plans start at $5 for 3,000. Sign up for free.

FAQ

How do I detect when a loading=”lazy” image has loaded?

Check the target image’s complete property, confirm naturalWidth > 0, and await decode() before capturing.

Does fullPage make Playwright load every lazy image?

No. It captures the full scrollable document. Trigger lazy loading and verify the images separately before taking the screenshot.

Can I use networkidle instead of checking images?

It is not a reliable substitute for image readiness. Check the expected image or application state directly.

Scroll the gallery container to trigger its images. A locator screenshot captures the currently visible portion of that scroller, so capture sections separately if you need the entire gallery.