ScreenshotNeo

BlogHow-to

Capture a Lazy-Loaded Background Image in a Playwright Screenshot

Trigger the background image’s lazy-load condition, verify the asset is ready, then capture the element or page with Playwright.

By the ScreenshotNeo team4 October 20269 min read

To capture a lazy-loaded CSS background with Playwright, trigger the condition that makes its owning element load the image, wait for a signal that the specific asset is ready, and then take the screenshot. Scrolling the element into view often triggers viewport-based loading. A successful page.goto(), the page load event, or a generic network-idle wait does not prove that this background has loaded.

The element’s computed background-image can tell you which URL the page intends to use, but it does not prove the image has finished loading or painted. Prefer an application-specific loaded state; when one is unavailable, inspect the matching resource and wait for its completion. The right check depends on how the site assigns and loads the background.

1. Navigate to the page and trigger lazy loading

Use the selector for the element whose CSS paints the background. If the site loads the image when that element approaches the viewport, scroll it into view before checking its state.

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

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();

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

    const hero = page.locator('.hero');
    await hero.scrollIntoViewIfNeeded();

    // Continue with a target-specific readiness check before taking the shot.
  } finally {
    await browser.close();
  }
})();

domcontentloaded is enough to start looking for the target in many pages, but choose the navigation state that fits the site. Playwright supports commit, domcontentloaded, load, and networkidle; load is the default. Do not assume any of these navigation states means a deferred background is ready.

Scrolling works only if it reproduces the page’s actual trigger. Some sites use an IntersectionObserver; others assign the CSS URL only after application state changes, a click, or some other event. If scrolling does not cause the background URL or loaded state to appear, reproduce the site’s real trigger.

2. Wait for evidence the background is ready

Template: wait for the expected CSS URL

This check waits until the element’s computed style references the expected image. Replace the selector and URL fragment with values from the target page.

const hero = page.locator('.hero');
await hero.scrollIntoViewIfNeeded();

await page.waitForFunction(({ selector, expected }) => {
  const el = document.querySelector(selector);
  return el && getComputedStyle(el).backgroundImage.includes(expected);
}, { selector: '.hero', expected: 'hero-image.webp' });

This verifies the CSS URL, not successful loading or decoded pixels. The site may set the style before the image request finishes. For a reliable capture, add the application’s own loaded class or state if it has one, or check the image resource itself.

Wait for an application-defined loaded state

If the page adds a class only after the image loads, wait for that state directly. This is usually the clearest readiness signal because it reflects the page’s own loading logic.

await page.locator('.hero.is-loaded').waitFor({ state: 'visible' });

Use the actual class or state exposed by the application. Do not assume a class name such as is-loaded exists on an arbitrary site.

When there is no loaded state: inspect the resource

A CSS background is not an HTMLImageElement. Its owner does not expose the complete property. One fallback is to read the computed URL, then wait for a matching resource to appear in the browser’s performance entries with a completed transfer. This is useful when the browser exposes the resource entry, but it is not a universal guarantee of decoded and painted pixels. If precise visual readiness matters, expose a site-specific loaded state or use an application-controlled image-loading mechanism.

await page.waitForFunction(({ selector, expected }) => {
  const el = document.querySelector(selector);
  if (!el) return false;

  const background = getComputedStyle(el).backgroundImage;
  if (!background.includes(expected)) return false;

  const urls = [...background.matchAll(/url\(["']?(.*?)["']?\)/g)]
    .map(match => match[1]);
  const target = urls.find(url => url.includes(expected));
  if (!target) return false;

  return performance.getEntriesByName(target)
    .some(entry => entry.responseEnd > 0);
}, { selector: '.hero', expected: 'hero-image.webp' });

Adjust URL matching if the computed style contains an absolute URL, escaped characters, multiple background layers, or a URL different from the fragment you expect. Performance entries can also be absent or affected by caching and browser behavior, so validate this fallback for the page you capture.

3. Capture the right region

After the readiness check succeeds, choose the screenshot method based on the desired output:

  • await hero.screenshot({ path: 'hero.png' }) captures the selected component.
  • await page.screenshot({ path: 'viewport.png' }) captures the current viewport.
  • await page.screenshot({ path: 'full.png', fullPage: true }) captures the full scrollable page.

A full-page capture does not replace the trigger and readiness checks. If the image is lazy-loaded only when its element approaches the viewport, make sure the page’s loading behavior has run for that element before capturing. A full-page screenshot can also produce a very tall image; capture a locator when you need only the background component.

Complete runnable example

This script waits for the expected URL to be assigned and for the matching resource to have a completed response entry, then captures the hero. Treat the resource-entry check as a practical fallback, not proof of decoded pixels; use the site’s loaded state when available.

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

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

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

    const selector = '.hero';
    const expected = 'hero-image.webp';
    const hero = page.locator(selector);
    await hero.scrollIntoViewIfNeeded();

    await page.waitForFunction(({ selector, expected }) => {
      const el = document.querySelector(selector);
      if (!el) return false;

      const background = getComputedStyle(el).backgroundImage;
      if (!background.includes(expected)) return false;

      const urls = [...background.matchAll(/url\(["']?(.*?)["']?\)/g)]
        .map(match => match[1]);
      const target = urls.find(url => url.includes(expected));
      return target && performance.getEntriesByName(target)
        .some(entry => entry.responseEnd > 0);
    }, { selector, expected }, { timeout: 15000 });

    await hero.screenshot({ path: 'hero.png' });
  } finally {
    await browser.close();
  }
})();

Install Playwright and its browser in your project using the official setup instructions. The sample URL, selector, and asset name are placeholders; substitute the real page-specific values. If the page exposes a reliable loaded class, wait for that instead of relying on performance entries.

4. Make visual captures repeatable

For one-off capture, a target-specific condition is usually sufficient. For visual regression tests using Playwright Test, expect(page).toHaveScreenshot() waits for consecutive screenshots to stabilize before comparing them. That stabilization helps with repeatability, but it is not a substitute for triggering a particular lazy resource or verifying its state.

Avoid arbitrary sleeps as the main readiness check: they can be too short on a slow run and waste time on a fast one. Also avoid relying on networkidle as proof that the background is ready. Playwright defines it as no network connections for at least 500 ms and discourages using it for testing; unrelated requests can keep a page busy, and a deferred asset may not have been requested yet.

5. Troubleshoot missing or inconsistent backgrounds

Symptom Likely cause What to check or change
The screenshot has no background, but navigation succeeded. The image is lazy-loaded and its trigger has not run. Scroll the owning element into view or reproduce the page’s actual application trigger before waiting.
The computed style has no expected URL. The site has not assigned the image yet, the selector is wrong, or the style is applied to a different element or pseudo-element. Inspect the actual painted element and the page’s loading logic; check whether the image is on a pseudo-element or nested child.
The URL appears, but the screenshot still shows a placeholder. The CSS is set before the request completes, or before the image is decoded and painted. Wait for the application’s successful-load state. Treat a computed-style match alone as insufficient.
waitForFunction times out. The expected fragment does not match the final URL, the trigger did not run, the request failed, or the selector does not identify the owner. Inspect the computed style and actual network URL, confirm the trigger, and use a condition that matches the page’s real behavior.
Waiting for networkidle hangs or is inconsistent. Unrelated network activity continues, or the target resource has not been triggered. Wait for a target-specific state or resource rather than global network quiet.
A full-page screenshot still omits lower-page backgrounds. The screenshot scope did not trigger each element’s viewport-based lazy-loading behavior. Reproduce the page’s lazy-load trigger for the target element or elements before capture; full-page output alone does not guarantee every asset was requested.
The capture differs across runs. The image is not ready at capture time, or other page content is still changing. Wait for the target’s loaded state; for Playwright Test comparisons, use toHaveScreenshot() stabilization.

6. Performance, reliability, and cost considerations

  • Keep waits specific. A selector or image-specific condition avoids waiting for unrelated traffic and makes failures easier to diagnose.
  • Set a useful timeout. Use a bounded timeout that fits the target site and report which condition timed out. Do not silently continue to a screenshot after the readiness check fails.
  • Choose the smallest capture scope. A locator screenshot is appropriate for a component; a full-page image may consume more memory and produce a much larger artifact.
  • Make failures visible. A missing asset should result in a clear failed readiness check, not a plausible-looking screenshot with a blank hero.
  • Account for cache and resource timing. A warm cache can change timing and resource-entry details. Prefer an application signal where available and validate any resource-based fallback under the conditions in which the script runs.
  • Control page inputs when comparing output. Use a consistent viewport and the same target state; otherwise layout changes can affect what the screenshot contains.

Self-hosted Playwright uses your browser setup and execution environment, so cost and runtime depend on your infrastructure, browser installation, target site, and capture frequency. There is no universal load-time benchmark for this workflow; the site’s network and lazy-loading implementation determine how long the specific asset takes.

Or skip the browser setup

If you need a screenshot artifact rather than browser automation code, ScreenshotNeo is a website screenshot API and MCP server. Its capture flow removes cookie and consent banners from 60+ known platforms, newsletter popups, and chat widgets before the shot; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response reports page verdict and billing status in headers.

One GET request returns an image or PDF. See the ScreenshotNeo API documentation for request options. For example, save a screenshot of the page:

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

The same request in Python:

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)

And in 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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await require('node:fs/promises').writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

ScreenshotNeo also offers an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. It includes options such as viewport and device presets, full-page and element captures, waits, custom CSS and JavaScript, request blocking, caching, and asynchronous jobs. The free plan includes 1,000 screenshots per 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

Can I use HTMLImageElement.complete for a CSS background?

No. That property belongs to an image element; a background owner such as a div is not an HTMLImageElement. Use an application signal or a resource-aware check instead.

Does fullPage: true load every lazy image?

It captures the full scrollable page, but it does not itself guarantee that every viewport-triggered lazy resource was requested and ready.

Should I always wait for load before checking the background?

No. Choose navigation readiness for the page’s initial state, then wait separately for the target background’s trigger and readiness condition.

What if the background uses several image layers?

Computed style can contain multiple URLs. Match the intended layer and its actual URL, or prefer an application state that signals the complete visual asset is ready.

Sources