ScreenshotNeo

BlogHow-to

Playwright Screenshot of Lazy-Loaded Images Is Blank on First Run

A full-page screenshot does not guarantee lazy images have loaded. Trigger each image’s loading behavior, wait for it to be ready, then capture.

By the ScreenshotNeo team4 October 20269 min read

A Playwright screenshot can be blank where lazy-loaded images should appear because the screenshot starts before those images have been requested, decoded, and painted. A full-page screenshot controls how much of the page is captured; it does not guarantee the page’s lazy-loading behavior has run. Scroll the images or their sections into view, wait for an image-specific ready condition, then capture.

Direct fix: locate the images you need, bring each into view, and wait until it has loaded successfully (for a normal image, naturalWidth > 0 is a useful check). Adapt the selector and readiness condition to the page: custom lazy loaders and changing image lists may need application-specific handling.

1. Why the first screenshot can be blank

Document readiness and image readiness are different conditions. DOMContentLoaded and the document’s load event describe document lifecycle milestones. An image that is off-screen, inserted later by client-side code, or managed by a custom lazy loader may not be requested until the page scrolls or another application event occurs.

Playwright’s full-page capture covers the page’s scrollable extent, but capture extent is not a signal that every lazy-loading implementation has completed. Likewise, waiting for load does not prove that the particular images in your screenshot are ready. Playwright also discourages using networkidle as a general testing readiness strategy. See the Page API documentation and PageAssertions API documentation.

A first-run-only failure may be a timing race; later runs may happen to benefit from cache or page state. That is a possibility to investigate, not a diagnosis. Compare image state in cold and warm runs before deciding what is causing it.

2. Runnable Playwright JavaScript fix

Install Playwright and its Chromium browser if they are not already installed in your project:

npm install -D playwright
npx playwright install chromium

Save the following as capture.mjs and run node capture.mjs. Replace the example URL and, if necessary, the image selector with one that matches the page. The script scrolls matching lazy images into view and waits for each image to report a nonzero natural width before taking a full-page screenshot.

import { chromium } from 'playwright';

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

try {
  await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });

  // Narrow this selector to the images that should appear in the capture.
  const images = page.locator('img[loading="lazy"]');
  const count = await images.count();

  for (let i = 0; i < count; i++) {
    const image = images.nth(i);
    await image.scrollIntoViewIfNeeded();
    await image.waitFor({ state: 'visible' });
    await page.waitForFunction(
      (img) => img.complete && img.naturalWidth > 0,
      await image.elementHandle(),
      { timeout: 15000 }
    );
  }

  await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
  await browser.close();
}

The example assumes standard img elements and a stable list. Some sites lazy-load images without the loading="lazy" attribute, use CSS backgrounds, or replace elements while scrolling. For those pages, use a selector or loaded-state marker that reflects the app’s actual behavior. The Playwright issue example demonstrates scrolling and checking natural width, and also illustrates why a changing list needs care: Playwright issue #31737.

3. Diagnose the page before choosing a wait

  1. Identify the blank images. Are they below the initial viewport, inside a nested scrolling container, or added or updated by client-side code?
  2. Inspect their state. For each target image, check src, srcset, currentSrc, loading, complete, and naturalWidth before and after scrolling. A deferred URL may be held in a data attribute until the site receives a trigger.
  3. Trigger the site’s loading behavior. Scroll the image or containing section into view. If the app uses a custom loader, trigger the same user-visible event it expects.
  4. Wait for the needed content state. For a regular image, successful natural width is a useful condition. If the application has a more precise loaded marker, wait for that instead.
  5. Capture after the condition passes. Use fullPage: true when you want the full scrollable page, separately from the checks that establish image readiness.

A quick diagnostic helper can report the state of a target selector:

const states = await page.locator('img').evaluateAll((imgs) =>
  imgs.map((img) => ({
    src: img.getAttribute('src'),
    currentSrc: img.currentSrc,
    loading: img.loading,
    complete: img.complete,
    naturalWidth: img.naturalWidth,
  }))
);
console.table(states);

Use this to distinguish “no real image URL yet,” “request failed,” and “loaded image but still visually missing.” It is a diagnostic aid, not a universal readiness procedure.

4. Choose the right readiness condition

Approach What it tells you Use it when
DOMContentLoaded The initial document has been parsed. You need to begin interacting with the page; it does not establish image readiness.
load The document load event has fired. You need that lifecycle milestone, while still checking target images separately.
Scroll plus image condition The target image was triggered and satisfies the condition you chose. The screenshot depends on specific lazy-loaded images. This is usually the closest match to the desired result.
networkidle A network activity state, not proof that intended images are visible and correct. Avoid it as a generic test readiness proxy; Playwright discourages it for this purpose.
Fixed sleep Only that a duration elapsed. At most, use as a temporary diagnostic. It can be too short on a slow run and waste time on a fast one.

Playwright actions and assertions have auto-wait behavior, but that cannot infer a page-specific lazy-load condition that has not been triggered. Prefer a condition tied to the image or app state over a guessed delay. The Page API documents load-state behavior and the guidance on networkidle.

5. Handle custom loaders and changing pages

Custom lazy loading

A page may defer its image URL in a data attribute, use a framework component, or set an image as a CSS background. In those cases, img[loading="lazy"] will not find every relevant visual. Inspect the markup and trigger the mechanism the application actually uses, then wait for its loaded class, attribute, or other meaningful state.

Nested scrolling containers

If images are inside an independently scrolling panel, scrolling the page may not trigger them. Scroll the relevant container or image in the way the application expects, then verify the target image state before capture.

Lists that change while scrolling

Content may be appended or replaced as the page scrolls. A count taken once can miss later images, and element handles collected earlier can become detached. Recheck the list as the page changes or wait for a known application-level completion condition. Playwright notes that locator.all() returns immediately and can be unpredictable for a changing list; see the issue example.

Visual snapshot assertions

If you use Playwright Test’s toHaveScreenshot, it waits for two consecutive screenshots with the same result before comparison. That visual stabilization helps with snapshot comparison, but it does not trigger lazy loading or establish that the intended images loaded. First get the application and image state ready, then use screenshot assertion stabilization. Details are in the PageAssertions API.

6. Python and Node.js alternatives

The same sequence applies in other Playwright language bindings: navigate, trigger lazy loading, wait for the image-specific state, and capture. The following Python example uses the synchronous API and standard image elements.

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page(viewport={"width": 1440, "height": 900})
    try:
        page.goto("https://example.com", wait_until="domcontentloaded")
        images = page.locator('img[loading="lazy"]')
        count = images.count()

        for i in range(count):
            image = images.nth(i)
            image.scroll_into_view_if_needed()
            image.wait_for(state="visible")
            image.evaluate("img => img.decode()")
            image.wait_for_function("img => img.complete && img.naturalWidth > 0")

        page.screenshot(path="page.png", full_page=True)
    finally:
        browser.close()

In JavaScript, img.decode() can be an additional image decoding check after the image URL has been assigned. It does not trigger a custom loader by itself; scroll or otherwise trigger the app first. The earlier JavaScript example is the corresponding Node.js version.

7. Troubleshooting

Symptom Likely branch to investigate What to do
The image has no useful src or currentSrc. The page may not have assigned the real URL yet. Inspect srcset and data attributes; trigger the page’s lazy-loader event and recheck.
naturalWidth stays zero. The image may still be loading, the request may have failed, or the source may be invalid. Inspect console and network failures; distinguish a failed resource from a screenshot taken too early.
The condition passes, but the screenshot looks blank. The image may be hidden, transparent, covered, in another frame, or not yet painted as expected. Check computed visibility and opacity, overlays, the target frame or element, and image decoding.
The first run fails but later runs pass. A timing race or differing cache/page state is possible. Log image state in cold and warm runs and wait on the target image condition. Do not assume cache is the cause without evidence.
The loop misses images or throws on a detached element. The page’s image list may change during scrolling. Use a locator strategy suited to a changing list and re-evaluate completion as the app adds content.
A longer timeout appears to fix it inconsistently. The wait may be disconnected from the real loading condition. Use the timeout as a failure limit, but wait for image or application state instead of relying on elapsed time.

8. Performance, reliability, and cost

Scrolling and waiting for every image makes a full-page capture more reliable when every image matters, but it can take longer on pages with many images or slow resources. If the test only checks a particular region, target only the images in that region. Set a sensible timeout so a failed or unreachable image produces a useful error instead of hanging indefinitely.

For reliability, make the readiness check match the image your screenshot needs, and handle failed loads distinctly from unfinished loads. Avoid generic network-idle waits and arbitrary sleeps as correctness conditions. For visual regression, control other dynamic content too; screenshot stabilization cannot make changing application data deterministic.

Local Playwright runs consume your own browser and machine resources. There is no screenshot API charge for this do-it-yourself approach; your operational cost is run time and the infrastructure that runs the browser.

9. Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. One GET request returns an image or PDF, and full-page capture loads lazy images. Its capture flow accepts cookie or consent banners like a visitor and removes 60+ known consent platforms, newsletter popups, and chat widgets before the shot; each of those steps can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, with the page verdict and billing status included in response headers. AI agents can use its MCP tools, including take_screenshot, get_page_info, and capture_pdf. Every feature is on every plan: 1,000 shots per month are free with no card, and paid plans start at $5 for 3,000 shots. See the ScreenshotNeo documentation for 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}`);
const bytes = new Uint8Array(await res.arrayBuffer());
await import('node:fs/promises').then(({ writeFile }) => writeFile('shot.webp', bytes));

Get 1,000 free screenshots a month with no card.

10. FAQ

Does fullPage: true load lazy images?

It requests a full-page capture, but you should still trigger the site’s lazy-loading behavior and check the images your capture depends on.

Should I use a fixed timeout?

Use timeouts to bound how long a condition may take, not as proof that the image is ready. Wait for an image or app-specific condition.

Is naturalWidth > 0 enough for every page?

No. It suits normal image elements after their source is assigned. CSS backgrounds, custom loaders, and changing application content need a condition that fits the page.

Does screenshot assertion stabilization load images?

No. It stabilizes consecutive screenshot results for comparison; lazy loading must be triggered and verified first.