How to Compare Screenshots When a Page Uses Lazy-Loaded Images
Lazy-loaded images can be missing from visual comparisons. Trigger loading, verify image readiness, and capture both versions under consistent conditions.
To compare screenshots of a page with lazy-loaded images, first scroll through the content in scope so the page triggers its image-loading behavior. Then wait for the relevant images to load successfully, return to the intended capture position, and compare screenshots taken with the same browser, viewport, fonts, and application state. A full-page screenshot expands the capture area; it does not guarantee that offscreen images were requested or rendered.
Why lazy images go missing
An image marked loading="lazy" may not be requested until the browser estimates it is near the viewport. The page’s load event is not proof that those images are ready. Sites can also defer images through application code, often using IntersectionObserver, so their loading behavior depends on scrolling into the observer’s root and any configured margins.
There are two different questions to answer before comparing:
- Was the image-loading behavior triggered? Scroll through the relevant content, including a nested scroll container if the page uses one.
- Did the image load successfully? Check its completed state and natural dimensions. A broken image can have
complete === true, so completion alone is insufficient.
Prepare a reliable comparison
- Open the exact route and set the test’s application data and state.
- Set the target viewport and browser project before triggering lazy loading.
- Scroll through the relevant content in increments. Pause briefly or wait on application-specific signals so scroll-triggered code can run.
- Wait for the in-scope images to load successfully, or explicitly handle images that are expected to fail or remain empty.
- Return to the intended capture position and capture the viewport, an element, or the full page according to the question being tested.
- Compare against an approved baseline. Investigate the diff before updating that baseline.
There is no universal scroll increment or fixed delay that guarantees readiness. Observer roots, rootMargin, request latency, nested scrolling, and application code vary by site. Prefer condition-based waits over a long arbitrary sleep.
Playwright example
This JavaScript example triggers viewport-based loading by scrolling the window, checks that document images completed successfully, and then uses Playwright’s screenshot assertion. Adapt it to the page under test: nested scrollers, placeholders, dynamically inserted images, CSS background images, or intentionally broken images need site-specific handling.
import { test, expect } from '@playwright/test';
test('page screenshot includes lazy-loaded images', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto('https://example.com/catalog', { waitUntil: 'domcontentloaded' });
// Trigger viewport-based lazy loading from top to bottom.
const scrollHeight = await page.locator('body').evaluate(el => el.scrollHeight);
for (let y = 0; y < scrollHeight; y += 600) {
await page.evaluate(scrollY => window.scrollTo(0, scrollY), y);
await page.waitForTimeout(100);
}
// Return to the same starting state used by the baseline.
await page.evaluate(() => window.scrollTo(0, 0));
// complete can also be true for a broken image; naturalWidth checks success.
await page.waitForFunction(() =>
[...document.images].every(img => img.complete && img.naturalWidth > 0)
);
await expect(page).toHaveScreenshot({ fullPage: true });
});
Install Playwright and its test browsers using the instructions for your project, then run this test with your configured Playwright test command. Replace the example URL with your route. The code is an adaptable pattern, not a universal recipe: it does not scroll nested containers, account for deliberately empty images, or explicitly wait for CSS background images. A timeout from the image predicate may indicate a broken image or a page-specific loading condition; inspect the images in scope instead of weakening the check blindly.
Scope the readiness check
If only one component matters, restrict the image check to that component rather than waiting on every image in the document. For example, use the component locator’s evaluateAll to inspect its descendant images:
const gallery = page.locator('[data-testid="gallery"]');
await page.waitForFunction(() => {
const root = document.querySelector('[data-testid="gallery"]');
if (!root) return false;
return [...root.querySelectorAll('img')].every(
img => img.complete && img.naturalWidth > 0
);
});
For a nested scroller, scroll that element rather than the window. For example, repeatedly update the container’s scrollTop up to its scrollHeight, allowing the site’s observer callbacks and requests to run between steps.
Choose the capture scope
| Scope | Use it for | Lazy-image preparation |
|---|---|---|
| Viewport | What a user sees at a specific scroll position | Trigger loading for that viewport and return to the matching position. |
| Element | A component such as a gallery or product card | Scroll the relevant page or container until its images load, then capture the element. |
| Full page | Document-wide layout and content regressions | Trigger loading across the content first; full-page capture alone does not do this. |
Playwright’s toHaveScreenshot() waits until two consecutive screenshots match before comparing. That helps with capture stability, but it cannot establish that the page triggered every lazy image. Keep the loading check as a separate step. See the official Playwright visual comparisons documentation and screenshot documentation.
Keep the comparison conditions consistent
Visual diffs can change with the host operating system, browser version, hardware, headless mode, fonts, viewport, device scale, test data, and application state. Playwright notes that browser rendering can vary with host and runtime conditions. Run baseline and actual captures in the same environment where possible, pin the browser version through the project’s normal setup, and keep fonts and test data stable. See Playwright’s guidance on visual comparisons.
Use the same viewport and browser project first. If desktop and mobile layouts both matter, maintain separate baselines. Review intended content changes instead of automatically accepting every new screenshot. Pixel thresholds can help account for known rendering noise, but choose them after reviewing expected variation and regression risk; do not use them to hide an unexplained diff.
Common problems and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Images are absent from a full-page screenshot | Capture started before scroll-triggered loading ran. | Scroll through the content in scope, wait for image readiness, then capture. |
| The readiness wait times out | A broken URL, a blocked request, an intentionally empty image, or a check that includes unrelated images. | Inspect the failing image’s URL and state. Scope the predicate and explicitly handle expected failures or placeholders. |
complete is true but an image is broken |
complete indicates loading finished, including failure. |
Require naturalWidth > 0 for images expected to load successfully. |
| Scrolling the window does not load images | Images are inside a nested scroll container or an observer uses a different root. | Scroll the actual container and confirm the observed elements intersect its root. |
| Images load in a manual run but not in CI | Network timing, browser differences, viewport, fonts, or test data differ. | Stabilize the environment and wait on relevant conditions rather than increasing a fixed delay alone. |
| Diffs appear despite all images loading | Rendering conditions changed or the visual change is genuine. | Compare browser, OS, fonts, viewport, scale, data, and application state; inspect the diff before updating the baseline. |
A page has no descendant <img> for a visible image |
The image may be a CSS background or drawn by a canvas. | Wait on an application-specific readiness signal and validate the relevant visual region. |
Performance, reliability, and cost
Scrolling the whole document and waiting for every image can make a test slower and can fail on unrelated broken content. Limit the scroll and readiness check to the region under test when that matches the assertion. Use bounded timeouts and useful failure diagnostics so a genuinely missing asset is visible. A fixed delay is simple but either wastes time on fast pages or remains too short on slow ones.
Network-dependent visual tests can be less reliable than tests using controlled fixtures or stable test data. Keep the capture browser and rendering environment consistent. A screenshot assertion’s consecutive-image check reduces transient capture differences, while the explicit image check addresses a separate risk: assets never loaded.
DIY screenshot comparisons have no per-capture API charge, but they use CI time, browser resources, and maintenance effort. If captures run at scale or you need a screenshot endpoint, compare those operational costs with a hosted service’s plan and billing rules.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. One GET request returns an image or PDF, and its options include full-page capture with lazy images loaded. For a direct capture, 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,
)
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 import('node:fs/promises').then(fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));
Cookie banners, popups, and chat widgets are removed before the shot, and each cleanup step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are never billed; response headers report the page verdict and billing status. Its MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. These captures can simplify image collection, but a visual regression test still needs consistent conditions and an approved baseline.
Sign up for 1,000 free screenshots a month with no card.
FAQ
Does a full-page screenshot force lazy images to load?
No. Full-page capture sets the capture scope; trigger the page’s lazy-loading behavior before capture.
Can I wait for networkidle instead of checking images?
Network idle alone does not prove every intended image loaded successfully. Check the images or an application-specific readiness condition.
Should I make all images eager in visual tests?
Only if that matches the behavior you want to test. Scrolling exercises the site’s real lazy-loading path; eager loading can be useful in a controlled test, but changes what the test verifies.
How should I handle a deliberately broken image?
Make the expected failure explicit in the test, and do not apply a blanket successful-load predicate to that image.


