ScreenshotNeo

BlogHow-to

How to Capture Product Screenshots on Websites with Lazy-Loaded Images

Scroll to trigger lazy-loaded product images, confirm they are ready, then capture the full page or a specific product element with Playwright.

By the ScreenshotNeo team4 October 20269 min read

To capture product screenshots with lazy-loaded images, scroll through the parts of the page you want to include, wait for the target images to finish loading, and then take the screenshot. The page’s initial load event is not enough: lazy images may not have been fetched yet. In Playwright, use page.screenshot({ fullPage: true }) for the whole document or a locator screenshot for a product gallery, card, or image region.

This guide uses JavaScript with Playwright. It includes a runnable example, a Python equivalent, cURL and Node.js examples for a hosted screenshot API, plus guidance for dynamic pages and missing images.

Why lazy-loaded images are missing from screenshots

Lazy loading defers non-critical resources until they are needed, often until an image approaches the viewport as a visitor scrolls. A screenshot operation does not itself guarantee that off-screen images have been requested or rendered. MDN explains how lazy loading defers resource requests and notes that the window load event is not a reliable signal for lazy images. See also MDN’s documentation for the image complete property.

Even a completed image request can fail, or an image can render differently than expected. Scroll the relevant content into view, check image readiness, and inspect the resulting capture.

Choose full-page or product-element capture

Capture type Use it when Playwright API
Full page You need the whole product page, including content below the initial viewport. page.screenshot({ fullPage: true })
Element You need a product card, gallery, or other specific region. locator.screenshot()

Playwright describes a full-page screenshot as capturing the full scrollable page as if shown on a very tall screen. Its screenshot APIs also support element capture and options such as output type, scale, and clipping. Read the Playwright screenshot documentation.

JavaScript: runnable Playwright example

Install Playwright and its Chromium browser:

npm install playwright
npx playwright install chromium

Save this as capture-product.mjs. Pass the page URL as the first argument. By default, it scrolls through the document, checks image completion, and saves a full-page PNG. To capture a specific element instead, set SELECTOR to a CSS selector and the script will save that element.

import { chromium } from 'playwright';

const url = process.argv[2];
if (!url) {
  throw new Error('Usage: node capture-product.mjs <url>');
}

const selector = process.env.SELECTOR;
const output = process.env.OUTPUT ?? (selector ? 'product-element.png' : 'product-full-page.png');
const stepPixels = Number(process.env.SCROLL_STEP ?? 700);
const pauseMs = Number(process.env.SCROLL_PAUSE_MS ?? 150);

if (!Number.isFinite(stepPixels) || stepPixels <= 0) {
  throw new Error('SCROLL_STEP must be a positive number');
}
if (!Number.isFinite(pauseMs) || pauseMs < 0) {
  throw new Error('SCROLL_PAUSE_MS must be zero or a positive number');
}

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

  // Scroll down in increments to activate viewport-based lazy loading.
  let previousHeight = 0;
  for (let pass = 0; pass < 5; pass++) {
    const height = await page.evaluate(() => document.documentElement.scrollHeight);
    for (let y = 0; y < height; y += stepPixels) {
      await page.evaluate((top) => window.scrollTo(0, top), y);
      if (pauseMs) await page.waitForTimeout(pauseMs);
    }
    await page.evaluate(() => window.scrollTo(0, 0));
    await page.waitForTimeout(pauseMs);
    const nextHeight = await page.evaluate(() => document.documentElement.scrollHeight);
    if (nextHeight === height && nextHeight === previousHeight) break;
    previousHeight = nextHeight;
  }

  // Wait for images that have been inserted into the document to settle.
  // A broken image may also be complete, so report failed images separately.
  const imageReport = await page.evaluate(async () => {
    const images = [...document.images];
    await Promise.all(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 });
      });
    }));
    return {
      total: images.length,
      incomplete: images.filter((img) => !img.complete).map((img) => img.currentSrc || img.src),
      failed: images.filter((img) => img.complete && img.naturalWidth === 0).map((img) => img.currentSrc || img.src),
    };
  });
  console.log('Image report:', imageReport);

  if (selector) {
    const target = page.locator(selector).first();
    await target.waitFor({ state: 'visible', timeout: 15_000 });
    await target.screenshot({ path: output, animations: 'disabled' });
  } else {
    await page.screenshot({ path: output, fullPage: true, animations: 'disabled' });
  }
  console.log(`Saved ${output}`);
} finally {
  await browser.close();
}

Run it like this:

node capture-product.mjs 'https://example.com/products/example-chair'
SELECTOR='.product-gallery' OUTPUT=gallery.png node capture-product.mjs 'https://example.com/products/example-chair'

The scroll step and pause are adjustable inputs, not universal guarantees. Choose values based on the page’s layout and loading behavior. The loop makes a bounded number of passes because infinite-scroll pages may keep extending. If the page has a known gallery selector, element capture reduces unrelated page content in the output.

Python: runnable Playwright example

Install the Python package and Chromium:

python -m pip install playwright
python -m playwright install chromium

Save this as capture_product.py:

import asyncio
import sys
from playwright.async_api import async_playwright

async def main():
    if len(sys.argv) < 2:
        raise SystemExit('Usage: python capture_product.py <url> [selector]')
    url = sys.argv[1]
    selector = sys.argv[2] if len(sys.argv) > 2 else None

    async with async_playwright() as p:
        browser = await p.chromium.launch()
        try:
            page = await browser.new_page(viewport={"width": 1440, "height": 900})
            await page.goto(url, wait_until='domcontentloaded', timeout=60_000)

            previous_height = 0
            for _ in range(5):
                height = await page.evaluate('document.documentElement.scrollHeight')
                y = 0
                while y < height:
                    await page.evaluate('(top) => window.scrollTo(0, top)', y)
                    await page.wait_for_timeout(150)
                    y += 700
                await page.evaluate('window.scrollTo(0, 0)')
                await page.wait_for_timeout(150)
                next_height = await page.evaluate('document.documentElement.scrollHeight')
                if next_height == height and next_height == previous_height:
                    break
                previous_height = next_height

            report = await page.evaluate('''async () => {
              const images = [...document.images];
              await Promise.all(images.map(img => img.complete ? Promise.resolve() :
                new Promise(resolve => {
                  img.addEventListener('load', resolve, { once: true });
                  img.addEventListener('error', resolve, { once: true });
                })
              ));
              return {
                total: images.length,
                incomplete: images.filter(img => !img.complete).map(img => img.currentSrc || img.src),
                failed: images.filter(img => img.complete && img.naturalWidth === 0).map(img => img.currentSrc || img.src)
              };
            }''')
            print('Image report:', report)

            if selector:
                target = page.locator(selector).first()
                await target.wait_for(state='visible', timeout=15_000)
                await target.screenshot(path='product-element.png', animations='disabled')
            else:
                await page.screenshot(path='product-full-page.png', full_page=True, animations='disabled')
        finally:
            await browser.close()

asyncio.run(main())

Run a full page capture or pass a CSS selector for an element capture:

python capture_product.py 'https://example.com/products/example-chair'
python capture_product.py 'https://example.com/products/example-chair' '.product-gallery'

The image report distinguishes images that remain incomplete from images whose requests completed without producing a usable image. Add a page-specific check if images are rendered as CSS backgrounds or inside a component that does not use ordinary <img> elements.

Adjust the workflow for page-specific loading

Product galleries and cards

For one gallery or card, wait for its locator to appear and capture that element. If it has its own scroll container, scroll that container rather than only the window. A CSS selector for the outer gallery may need a page-specific locator or interaction to reveal all slides.

Infinite scroll and virtualized grids

Some pages append items while scrolling; others recycle a small number of DOM elements to represent a larger list. A document-height loop cannot guarantee that every item will remain present for one full-page screenshot. Define the desired stopping condition, such as a known item count or an end-of-results marker, and capture the relevant regions while they are rendered. Nested scroll areas require scrolling the correct container. The browser documentation does not define one universal recipe for these site-specific cases.

Custom image loading

Some sites use data-src, CSS backgrounds, JavaScript-driven galleries, or placeholders that are replaced after an application-specific event. Inspect the page’s DOM and network activity, identify the actual loaded image or completion signal, and wait for that condition. The document.images check covers image elements, not every way a page can display imagery.

Output choices

Playwright supports PNG, JPEG, and WebP screenshot formats where supported by the installed version, as well as scale and clipping controls. PNG is useful when sharp edges and text matter; JPEG can reduce output size for photographic content. Use full-page capture for a long document and an element screenshot when only a product region is needed. Check the official options for the exact configuration supported by your Playwright version.

Troubleshooting missing or incomplete images

Symptom Likely cause Fix
Images below the fold are blank They were never brought near the viewport, so lazy loading did not start. Scroll through the intended content in increments, then check readiness before capture.
Capture starts after load, but images are absent The load event does not imply that lazy images have loaded. Wait on the relevant image or page-specific completion condition after scrolling.
An image reports complete but is still blank The request may have failed; completion alone is not proof of success. Check naturalWidth or inspect the image request and report failed resources.
Some grid items never appear The page may use infinite scroll, a nested scroller, or virtualization. Scroll the correct container and use an explicit end condition or capture each rendered region.
Full-page shot omits a gallery item The gallery may be a carousel or script-controlled component with only one slide rendered. Interact with the gallery to reveal the desired item, then capture the gallery element.
Capture times out during navigation The page may keep network connections open or load slowly. Use domcontentloaded for navigation, then wait for the specific content you need instead of requiring all network activity to stop.
Images differ between runs Dynamic content, animation, viewport changes, or timing affects rendering. Keep the viewport fixed, disable animations for the screenshot, and wait on a page-specific stable state.

Performance, reliability, and cost

  • Performance: Scrolling and waiting adds time, especially on long pages. Limit the work to the sections you intend to capture and prefer an element screenshot for one product region.
  • Reliability: A fixed delay alone cannot guarantee readiness. Combine scrolling with image readiness checks and a page-specific verification for custom components. Report broken images separately from images still loading.
  • Memory and output: Full-page screenshots can produce tall, large files. Choose an appropriate output format and scale, and avoid full-page capture when the deliverable is only a gallery or card.
  • Cost: A self-hosted Playwright run uses your browser compute and bandwidth. The dossier provides no universal timing or cost benchmark; actual resource use depends on page length, image weight, and infrastructure.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server for developers. Its full-page capture loads lazy images. Make a single request for the screenshot; the API docs are at ScreenshotNeo API documentation.

cURL

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

Python

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)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are never billed; response headers report the page verdict and billing status. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots each 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

Can a full-page screenshot trigger every lazy image automatically?

Do not assume so. Scroll the page through the image regions first, then verify the images you need.

Does img.complete mean an image loaded successfully?

No. It indicates the image loading process is complete, including failure. Check naturalWidth or another page-specific success condition as well.

Should I use a full-page screenshot for a product image?

Usually not. Capture the gallery, card, or image element when that is the only content you need.