ScreenshotNeo

BlogHow-to

Lazy-Loaded Images Missing from Website Screenshots? How to Fix It

Full-page screenshots can miss images that load only after scrolling. Scroll the real page, wait for images to finish, then capture.

By the ScreenshotNeo team4 October 20269 min read

If images are missing from a full-page screenshot, scroll the actual page through its content before capturing it. Pause while images load and render, then take the screenshot. A full-page option captures the page’s scrollable extent, but that does not necessarily trigger the real scroll events or viewport intersections some sites use to start loading images. The workflow below is a general fix; the right diagnosis depends on the page, browser, and screenshot tool.

This guide shows the scroll-and-capture workflow with Playwright in JavaScript and Python, explains why it works, and gives checks for cases where it does not. For site owners, it also covers image markup and lazy-loader behavior.

1. Scroll before taking the full-page screenshot

Move the page’s visual viewport down in viewport-sized steps, allowing time for image requests and rendering at each position. After reaching the bottom, wait for visible images to finish loading, return to the top, and capture the full page.

Playwright with JavaScript

Install Playwright and its Chromium browser in a project, then save this as capture.js. Run it with node capture.js https://example.com.

const { chromium } = require('playwright');

async function main() {
  const url = process.argv[2];
  if (!url) throw new Error('Usage: node capture.js https://example.com');

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

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

    // Trigger viewport-dependent lazy loaders by scrolling the real page.
    await page.evaluate(async () => {
      const step = Math.max(300, window.innerHeight * 0.8);
      const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
      let previousHeight = 0;
      let unchangedPasses = 0;

      while (unchangedPasses < 3) {
        const height = document.documentElement.scrollHeight;
        for (let y = 0; y < height; y += step) {
          window.scrollTo(0, y);
          await pause(250);
        }
        window.scrollTo(0, document.documentElement.scrollHeight);
        await pause(500);

        const newHeight = document.documentElement.scrollHeight;
        unchangedPasses = newHeight === previousHeight ? unchangedPasses + 1 : 0;
        previousHeight = newHeight;
      }

      window.scrollTo(0, 0);
    });

    // Wait for image elements that have been requested to finish loading.
    await page.waitForFunction(() => {
      const images = [...document.images];
      return images.every(img => img.complete);
    }, { timeout: 15000 }).catch(() => {
      console.warn('Some images did not reach complete state before timeout.');
    });

    await page.screenshot({ path: 'page.png', fullPage: true, animations: 'disabled' });
    console.log('Saved page.png');
  } finally {
    await browser.close();
  }
}

main().catch(error => { console.error(error); process.exitCode = 1; });

Install with npm install playwright and npx playwright install chromium. The repeated passes account for pages whose height grows as new content appears. Adjust the short per-step pause for the target site. The final image check is bounded by a timeout because broken image requests may never become usable.

Playwright with Python

Install Playwright with pip install playwright, install Chromium with playwright install chromium, and save the following as capture.py. Run python capture.py https://example.com.

import asyncio
import sys
from playwright.async_api import async_playwright

async def main():
    if len(sys.argv) < 2:
        raise SystemExit("Usage: python capture.py https://example.com")
    url = sys.argv[1]

    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page(viewport={"width": 1365, "height": 900})
        try:
            await page.goto(url, wait_until="domcontentloaded", timeout=60000)

            await page.evaluate("""async () => {
              const step = Math.max(300, window.innerHeight * 0.8);
              const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
              let previousHeight = 0;
              let unchangedPasses = 0;
              while (unchangedPasses < 3) {
                const height = document.documentElement.scrollHeight;
                for (let y = 0; y < height; y += step) {
                  window.scrollTo(0, y);
                  await pause(250);
                }
                window.scrollTo(0, document.documentElement.scrollHeight);
                await pause(500);
                const newHeight = document.documentElement.scrollHeight;
                unchangedPasses = newHeight === previousHeight ? unchangedPasses + 1 : 0;
                previousHeight = newHeight;
              }
              window.scrollTo(0, 0);
            }""")

            try:
                await page.wait_for_function(
                    "[...document.images].every(img => img.complete)", timeout=15000
                )
            except Exception:
                print("Some images did not reach complete state before timeout.")

            await page.screenshot(path="page.png", full_page=True, animations="disabled")
            print("Saved page.png")
        finally:
            await browser.close()

asyncio.run(main())

Why not just wait for network idle?

A network-idle condition can be useful after scrolling, but it does not itself make off-screen images intersect the viewport. Some pages also keep analytics, polling, or other requests active, so network idle may never occur. Trigger the loading behavior first, then use a bounded wait for the particular images or content you need.

2. Why full-page screenshots can omit lazy images

With native browser lazy loading, an image marked loading="lazy" can be deferred until it approaches the visual viewport. Custom loaders often use IntersectionObserver or scroll handlers to swap an image URL into src only when the image approaches or enters view.

A full-page screenshot asks the browser to capture the whole scrollable document. It describes capture extent; it is not necessarily the same sequence of viewport scrolls a visitor performs. Playwright documents fullPage: true as capturing the full scrollable page. A reported Playwright issue describes missing lazy and scroll-triggered content in some full-page captures; treat it as a report, since behavior can depend on the page and tool version. Playwright screenshot API

Native lazy loading is controlled by the image’s loading attribute, whose relevant values are lazy and eager. Browser loading thresholds are implementation behavior, not a fixed page setting. MDN: HTMLImageElement loading

3. If you own the page, check the lazy-loading implementation

Keep critical, above-the-fold images eager

Do not defer the main image or other images likely to appear in the initial viewport. Use ordinary eager loading for those images; reserve lazy loading for content farther down the page. This also avoids making the most visible content wait on a scroll that may never happen. Chrome guidance on Largest Contentful Paint

<img
  src="/images/hero.webp"
  width="1200"
  height="675"
  loading="eager"
  alt="Product overview"
>

<img
  src="/images/article-detail.webp"
  width="800"
  height="533"
  loading="lazy"
  alt="Article detail"
>

Width and height reserve the image’s layout space while it loads, which helps prevent layout shifts and makes screenshot positions more stable. Native image loading is generally the simplest choice for suitable below-the-fold images. MDN: authoring fast-loading HTML

For a custom IntersectionObserver loader, verify source swapping

A custom loader must observe the right element and set a usable source when it intersects. If the page stores the URL in data-src but never copies it into src, no request starts. A positive bottom rootMargin can start loading before an image becomes visible; choose a buffer based on the page and network rather than treating an example value as universal. MDN: Intersection Observer root margin

<img
  class="lazy-image"
  data-src="/images/detail.webp"
  width="800"
  height="533"
  alt="Detail"
>

<script>
  const images = document.querySelectorAll("img.lazy-image[data-src]");
  const observer = new IntersectionObserver((entries, observer) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      const image = entry.target;
      image.src = image.dataset.src;
      image.removeAttribute("data-src");
      observer.unobserve(image);
    }
  }, { root: null, rootMargin: "0px 0px 256px 0px" });

  images.forEach(image => observer.observe(image));
</script>

This example is for a simple page with image URLs in data-src. Production code should also handle responsive srcset, failed requests, content added after initialization, and any scroll container that serves as the observer root.

4. Troubleshoot images that remain missing

Symptom Likely cause What to check or change
The image appears after manually scrolling, but not in the screenshot. The capture did not trigger viewport-based loading. Scroll in viewport-sized steps, pause, then capture. Confirm scrolling changes the page’s image state.
The image element exists, but its src is empty or a placeholder. A custom loader has not copied its URL from data-src or another attribute. Inspect the element before and after scrolling. Check the observer target, callback, and source assignment.
src has a URL, but the image is broken. The request failed, was blocked, or returned an unusable response. Inspect browser developer tools’ Network panel and Console for failed requests, HTTP errors, mixed-content problems, or script exceptions.
The image is behind a consent, login, or other overlay. The page requires an interaction or state before content is available. Check whether the overlay blocks the page and whether the capture session needs the appropriate consent or authenticated state.
Only the visible rows of a long feed appear. The page virtualizes content and removes off-screen rows from the DOM. A full-page image cannot include items that are not rendered simultaneously. Capture sections while scrolling, or use the page’s export/data path if available.
Images load, but the screenshot crops or shifts them. Late layout changes, missing dimensions, or a capture taken during animation. Reserve image dimensions, wait for layout to settle, and disable animations where supported.
The scroll loop never finishes or the page keeps growing. Infinite scrolling continually adds new content. Set a maximum scroll distance or item count for the capture, and define which portion of the feed the screenshot should include.

In browser automation, inspect the image properties directly. For example, in Playwright, await page.locator('img').evaluateAll(imgs => imgs.map(i => ({src:i.currentSrc, complete:i.complete, width:i.naturalWidth}))) shows the selected URL, whether loading completed, and whether a usable image decoded. A complete image with naturalWidth equal to zero may have failed; completion alone does not guarantee a visible image.

5. Choose a loading and capture strategy

Approach Best fit Trade-off
Native loading="lazy" Typical below-the-fold content images. Easy to use, with browser-controlled loading distance and timing.
Custom IntersectionObserver Pages that need control over when content starts loading or use custom source swapping. More control, but source assignment, responsive images, dynamic content, and failures must be handled correctly.
Scroll before screenshot Capturing an existing page without changing its code. Costs time proportional to page length and may trigger infinite feeds or other scroll effects.
Capture sections separately Virtualized pages or extremely long feeds. Requires stitching or separate outputs if one continuous image is needed.

6. Performance, reliability, and cost

Scrolling every viewport can increase capture time and cause the browser to request many images. Use a bounded number of steps for long or infinite pages, and wait only as long as the page needs to start and decode its images. A short delay is a practical heuristic, not proof that every image has loaded; checking the specific image elements is more reliable.

For repeatable screenshots, keep viewport size, browser version, page state, and capture timing consistent. Pages with changing content, animations, ads, or personalized images can still produce different results. A network-idle wait alone is not a guarantee that viewport-gated images were requested.

Browser automation cost depends on where it runs and how long each browser session stays open; this guide cannot estimate it without your hosting and workload details. For large batches, consider concurrency limits and retries with a cap so failed pages do not create unbounded work.

7. Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. Send one GET request with the page URL to receive an image or PDF. Its full-page capture loads lazy images. The API also supports wait options, custom JavaScript and CSS, and other capture controls. See the ScreenshotNeo API 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,
)
r.raise_for_status()
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(`Screenshot request failed: ${res.status}`);
await require('node:fs/promises').writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

Cookie banners are accepted like a visitor and removed, along with known consent platforms, newsletter popups, and chat widgets, before the shot; each step can be turned off. Bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents use screenshot tools. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Sign up for 1,000 free screenshots a month, with no card.

8. Frequently asked questions

Does Playwright’s fullPage: true trigger lazy loading?

It requests a screenshot of the full scrollable page. Do not assume it triggers every page’s scroll-dependent loading behavior; scroll first when images are missing.

How long should I wait after scrolling?

There is no universal delay. Start with a short pause at each viewport, then check whether the target image has a nonzero natural width. Increase the pause only if the page needs more time.

Why does my image look loaded in the DOM but not appear in the screenshot?

Check its rendered dimensions, visibility, selected currentSrc, and whether a later layout change or overlay covers it. A URL in the markup alone does not confirm a visible, successfully decoded image.

What details help diagnose a remaining failure?

Share the screenshot tool and browser version, a page URL or sanitized image markup, the relevant capture code, and any failed image requests or console errors. The generic workflow cannot identify a site-specific failure without those details.

Primary references