How to Test Lazy-Loaded Page Screenshots Across Desktop Viewport Sizes
Test desktop visual regressions reliably by exercising explicit viewport sizes, triggering lazy images, and waiting for page-specific readiness before capture.
To test lazy-loaded page screenshots across desktop sizes, capture each supported CSS viewport explicitly, bring the target images into view, wait until they have loaded and the layout has settled, then compare the viewport screenshot with a baseline. A page’s load event alone is not enough: lazy images may still be deferred. Keep the browser and rendering environment consistent between baseline creation and later comparisons.
This guide uses Playwright with TypeScript. The same sequence applies to other browser automation: set a viewport, trigger the lazy-loading condition, check image readiness, stabilize the layout, and capture.
Choose the viewports and the screenshot you need
A viewport is measured in CSS pixels. Choose dimensions that represent the desktop widths your product supports and that exercise its layout breakpoints. For example, a dashboard might need checks at 1280×800, 1440×900, and 1920×1080. These are example values; use your own supported sizes.
Viewport size and device scale factor are separate settings. The viewport determines the CSS layout; device scale factor affects the relationship between CSS pixels and output pixels. Keep both explicit when they matter to your baselines.
| Capture goal | Capture mode |
|---|---|
| Check the layout visible at a specific desktop size | Viewport screenshot |
| Check the entire scrollable document as one output | Full-page screenshot |
| Check whether content loads only when approached | Assert the unloaded state, scroll to the content, then assert the loaded state |
A full-page screenshot is not a substitute for viewport regression testing. It answers a different question, and browser behavior or page-specific loading logic can leave deferred content missing unless it is triggered and ready first.
Set up a Playwright visual test
Install Playwright’s test package and its browser using the official Playwright installation instructions. Save the following as a test file such as tests/gallery-visual.spec.ts. It assumes the application is available at the configured base URL and that the gallery section has the stated test ID.
import { test, expect } from '@playwright/test';
const desktopViewports = [
{ width: 1280, height: 800 },
{ width: 1440, height: 900 },
{ width: 1920, height: 1080 },
];
test.describe('gallery desktop visual regression', () => {
for (const viewport of desktopViewports) {
test(`renders at ${viewport.width}x${viewport.height}`, async ({ page }) => {
await page.setViewportSize(viewport);
await page.goto('/gallery', { waitUntil: 'domcontentloaded' });
const section = page.getByTestId('below-fold-gallery');
await section.scrollIntoViewIfNeeded();
const images = section.locator('img');
await expect.poll(async () => {
return images.evaluateAll((nodes) =>
nodes.length > 0 && nodes.every((node) => {
const image = node as HTMLImageElement;
return image.complete && image.naturalWidth > 0;
})
);
}, { message: 'gallery images should load successfully' }).toBe(true);
// Return to the intended capture position after triggering below-fold loading.
await page.evaluate(() => window.scrollTo(0, 0));
await expect(page).toHaveScreenshot(
`gallery-${viewport.width}x${viewport.height}.png`
);
});
}
});
Run it with npx playwright test tests/gallery-visual.spec.ts. On first use, toHaveScreenshot() creates reference snapshots; later runs compare captures with those references. Review and commit the initial snapshots from a known-good page state. For details on snapshot comparison, see Playwright visual comparisons.
The example scrolls the gallery section into view to trigger deferred loading, then returns to the top because the screenshot is intended to represent the initial viewport. If the intended screenshot is the gallery itself, capture while it is in view. If the target is a full-page output, use Playwright’s full-page screenshot option after preparing all relevant sections.
Trigger lazy loading and wait for the right condition
Native loading="lazy" defers an image until it is within a browser-calculated distance of the viewport. JavaScript-driven loaders often use Intersection Observer or another visibility trigger. Scrolling the target into or near view is usually the relevant test action.
After scrolling, wait for a condition that describes the target content. For an image, complete indicates that loading has finished, while naturalWidth > 0 helps distinguish a successfully loaded image from a broken one. If the application exposes a reliable loaded marker, prefer that application-specific condition. Avoid using a fixed delay as the only readiness check.
Here is a small helper for an individual image when the page has a stable selector:
async function waitForImage(page, selector: string) {
await page.locator(selector).evaluate(async (node) => {
const image = node as HTMLImageElement;
if (!image.complete) {
await new Promise<void>((resolve) => {
image.addEventListener('load', () => resolve(), { once: true });
image.addEventListener('error', () => resolve(), { once: true });
});
}
if (!image.complete || image.naturalWidth === 0) {
throw new Error(`Image did not load: ${image.currentSrc || image.src}`);
}
});
}
This helper reports a failed image rather than silently treating an error event as success. For dynamically inserted images, first wait for the element or application-owned loaded marker to appear, then check its image state. If a section contains several images, poll all expected images as in the test above.
Nested scroll containers
If the lazy loader observes a nested scrolling container as its Intersection Observer root, scrolling the window may not trigger it. Scroll the actual container or the target within it. For example:
const container = page.locator('[data-testid="gallery-scroll"]');
const target = container.locator('[data-testid="target-image"]');
await target.scrollIntoViewIfNeeded();
When the test is about lazy-loading behavior itself, check both sides of the behavior: verify the image has not loaded while it is outside the intended trigger area, then scroll the relevant root and wait for its loaded state. Do not assume every browser or loader uses the same trigger distance.
Wait for layout stability before comparing
Images and other deferred content can change page geometry after they load. Give lazy images explicit width and height attributes or an aspect ratio in CSS where possible; this reserves space and reduces layout shifts. After image readiness, wait for the layout relevant to the assertion to settle.
One practical check is to sample the target element’s bounding box until it stops changing for a few animation frames. This is a readiness aid, not a universal guarantee for pages with ongoing animations or content that continues to update:
async function waitForStableBox(page, selector: string) {
await page.locator(selector).evaluate(async (node) => {
const element = node as HTMLElement;
let previous = '';
let stableFrames = 0;
for (let frame = 0; frame < 30 && stableFrames < 3; frame++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const rect = element.getBoundingClientRect();
const current = [rect.x, rect.y, rect.width, rect.height].join(',');
stableFrames = current === previous ? stableFrames + 1 : 0;
previous = current;
}
if (stableFrames < 3) throw new Error('Target layout did not settle');
});
}
Use this only for geometry that matters to your capture. If fonts, transitions, carousels, timestamps, ads, or live data affect pixels, make those states deterministic or disable them through test-specific application behavior. Playwright’s screenshot matcher waits for two consecutive screenshots to match before saving the last one, but page-specific asynchronous behavior still needs suitable readiness conditions.
Capture viewport and full-page screenshots
For visual regression at each desktop size, use the default viewport screenshot. For a whole-document capture, Playwright supports a full-page option:
await page.screenshot({ path: 'gallery-full-page.png', fullPage: true });
To keep screenshot output in CSS-pixel scale, Playwright’s screenshot API supports scale: 'css'; scale: 'device' uses device pixels. Choose the setting that matches the purpose of the artifact and keep it consistent across baselines. See Playwright Screenshots and Playwright emulation for capture and viewport configuration details.
Full-page capture does not mean every application’s lazy-loading logic has necessarily run for every section. If the page needs each region to approach the viewport before loading, visit the relevant sections in a loop, wait for their expected content, then capture the full document. Some pages require multiple scroll steps, a nested scroll root, or an application-owned readiness signal.
Keep baselines reproducible
Visual comparisons can vary with the host operating system, browser version, browser settings, hardware, power source, and headless mode. Use the same rendering environment for baseline generation and comparison, including Playwright and browser versions. Run screenshot tests in a consistent CI image or development environment when possible, and regenerate baselines only when the visual change is intended.
- Pin the Playwright version and use its matching installed browser.
- Use the same operating system and headless configuration for baseline and comparison runs.
- Set viewport dimensions and device scale factor explicitly.
- Control animations and time-dependent content if they affect the pixels being compared.
- Name snapshots with the page and viewport, such as
dashboard-desktop-1280x800.png.
Playwright documents these sources of rendering variation and recommends the same environment as the baseline in its visual comparison guide.
Options and configuration to consider
| Setting or choice | When it matters |
|---|---|
| Viewport width and height | Exercise supported desktop breakpoints and intended fold positions. |
| Device scale factor | Match display-density behavior or output pixel scale; keep separate from CSS viewport dimensions. |
| Browser engine and version | Keep screenshot rendering consistent with the baseline; use additional projects if cross-browser behavior is part of the requirement. |
| Capture mode | Viewport for responsive layout checks; full page for whole-document output. |
| Scroll root | Use the correct nested container when the lazy loader observes a non-window root. |
| Readiness condition | Prefer an application marker or successful image state over elapsed time alone. |
| Animation and dynamic content | Control when motion or changing content causes unstable pixels. |
Native lazy-loading thresholds are browser-calculated, and JavaScript loaders can use their own observer margins and logic. Do not encode a universal distance or assume one fixed delay works across implementations.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Images are missing from a full-page screenshot | The capture happened before lazy regions were brought near the viewport or before their requests completed. | Scroll through relevant sections, wait for target image readiness, then capture. |
window.load fires but images are absent |
Lazy images can remain unloaded when the page load event fires. | Trigger the lazy-loading condition and check the images directly. |
| Image wait passes but the image is blank or broken | The check used only complete; a failed image can also be complete. |
Require naturalWidth > 0 or an application-specific success marker. |
| Scrolling the page does not load the image | The loader observes a nested scroll container, or the target is inserted later. | Scroll the configured root; wait for the target element before checking it. |
| Image appears in a different position between runs | Image dimensions were not reserved, or other content shifted after loading. | Set image dimensions or aspect ratio and wait for relevant geometry to settle. |
| Snapshots differ on CI but not locally | Browser, OS, headless mode, fonts, hardware, or other rendering settings differ. | Compare in a stable environment that matches the baseline and pin browser versions. |
| Only the first run creates a snapshot | This is Playwright’s initial reference-generation behavior. | Review and retain the expected baseline; subsequent runs perform comparisons. |
| Test hangs waiting for an image | The image request failed, the selector is wrong, or the page never reaches the expected state. | Check the selector and network response, and fail with a bounded timeout and a useful diagnostic. |
Performance, reliability, and cost
Each viewport run adds navigation, scrolling, readiness checks, and image comparison work. Start with the smallest set of widths that covers supported desktop breakpoints, then add sizes when they represent a real product requirement. Reuse a page or browser context only when state isolation remains correct for the test.
Waiting for the actual content is more reliable than sleeping for an arbitrary duration and can finish as soon as the condition is met. However, external image hosts and network conditions can still make tests slower or flaky. For stable visual checks, use predictable test data and dependencies where your application allows it, and report which images failed to load.
Browser automation has compute and maintenance costs: CI time, browser installation, snapshot storage, and review of intentional visual changes. A full-page capture can be larger and more expensive to compare than a viewport capture. Keep the capture scope aligned with the question the test is meant to answer.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. Its API takes a URL and returns an image or PDF; its browser setup is handled by the service. For this example, request a desktop viewport and WebP output. See the ScreenshotNeo docs for API parameters.
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-d width=1440 \
-d height=900 \
-o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={
"access_key": "YOUR_API_KEY",
"url": "https://stripe.com",
"width": 1440,
"height": 900,
},
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',
width: '1440',
height: '900',
});
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)
);
ScreenshotNeo accepts and removes cookie and consent banners, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks, blank pages, and failed loads are never billed. Response headers identify 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 a 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
How do I test lazy-loaded images with Playwright?
Scroll the image or its containing section into the relevant viewport, wait until the image is complete and has a nonzero natural width, then capture after layout changes settle.
How do I take screenshots at different viewport sizes?
Run a parameterized test over explicit width and height pairs. Set each size before navigating or capturing, and include the dimensions in the snapshot name.
Why are images missing from my full-page screenshot?
Many lazy loaders start loading only when content approaches a viewport or configured observer root. Visit the sections first and wait for the images before taking the full-page capture.
Does a viewport screenshot test the whole page?
No. It captures the visible viewport. Use a full-page screenshot when the complete scrollable document is the subject of the check.


