How to Capture Consistent Screenshots of Web Pages with Lazy-Loaded Images
Full-page capture does not guarantee every image has loaded. Trigger lazy content, verify readiness, and control browser conditions for repeatable screenshots.
To capture a consistent full-page screenshot when a page uses lazy-loaded images, first scroll through the page to trigger deferred content, wait for images and layout to settle, then take a full-page screenshot. A full-page option captures the document’s scrollable area; it does not guarantee that images below the fold have been requested or rendered.
The workflow below uses Playwright JavaScript. It progressively scrolls, rechecks page height for content added during scrolling, waits for ordinary image elements to finish, checks that they have usable dimensions, and then captures the full page. The scroll step and pause are tuning values, not universal guarantees.
1. Set up a repeatable browser context
Use the same browser runtime, viewport, device scale, page state, and loading procedure for each capture. Fixing these conditions reduces differences caused by responsive layout, fonts, dynamic content, and rendering changes.
npm install playwright
npx playwright install chromium
Save the following as capture.mjs. Run it with a page URL as the first argument, for example node capture.mjs https://example.com.
import { chromium } from 'playwright';
const url = process.argv[2];
if (!url) throw new Error('Usage: node capture.mjs <url>');
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1
});
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.evaluate(() => document.fonts?.ready);
// Scroll progressively to trigger viewport-based lazy loading.
// Tune step and pause for the site; neither value is universal.
const step = 700;
const pauseMs = 250;
let stableHeightPasses = 0;
let lastHeight = 0;
const maxPasses = 10;
for (let pass = 0; pass < maxPasses && stableHeightPasses < 2; pass++) {
const height = await page.evaluate(() => document.documentElement.scrollHeight);
if (height === lastHeight) stableHeightPasses++;
else stableHeightPasses = 0;
lastHeight = height;
for (let y = 0; y < height; y += step) {
await page.evaluate(y => window.scrollTo(0, y), y);
await page.waitForTimeout(pauseMs);
}
}
// Return to the top before capturing. Wait for every current <img> to either
// load or error, with a per-image timeout so broken images cannot hang forever.
await page.evaluate(() => window.scrollTo(0, 0));
await page.evaluate(async () => {
const images = [...document.images];
await Promise.all(images.map(img => new Promise(resolve => {
if (img.complete) return resolve();
const finish = () => resolve();
img.addEventListener('load', finish, { once: true });
img.addEventListener('error', finish, { once: true });
setTimeout(finish, 10_000);
})));
});
const imageReport = await page.evaluate(() => [...document.images].map(img => ({
src: img.currentSrc || img.src,
complete: img.complete,
width: img.naturalWidth,
height: img.naturalHeight
})));
const failed = imageReport.filter(img => !img.complete || img.width === 0 || img.height === 0);
if (failed.length) console.warn(`${failed.length} image(s) are incomplete or have no intrinsic dimensions`);
// Allow a short final paint interval after returning to the top.
await page.waitForTimeout(100);
await page.screenshot({ path: 'page.png', fullPage: true });
console.log(`Saved page.png; checked ${imageReport.length} image elements.`);
} finally {
await context.close();
await browser.close();
}
The image check reports ordinary <img> elements. A page can use CSS background images, script-managed placeholders, nested scrolling containers, or custom loading indicators; those need page-specific checks. An image that completed with nonzero dimensions can still show the wrong asset or a placeholder, so inspect the captured result when completeness matters.
2. Why scrolling and readiness checks matter
Lazy loading defers noncritical resources until they are needed, often until scrolling brings them near the viewport. A browser’s load event can fire while off-screen lazy resources are still unloaded. Playwright’s fullPage: true expands the screenshot to the page’s full scrollable area, but that changes the capture area; it does not itself trigger every site’s lazy-loading behavior.
- Navigate. Use
domcontentloadedorloadas a navigation milestone, not proof that all deferred media is ready. - Scroll in stages. Visit progressively lower positions and pause to give observers and page scripts a chance to request and paint images.
- Recheck document height. Scrolling can reveal more content on infinite-scroll pages. Continue while new content is being added, with a limit or an explicit desired endpoint.
- Check image state. For each ordinary image, check
completeand nonzeronaturalWidthandnaturalHeight. Handle known placeholders and background images separately. - Capture after settling. Return to the intended starting position, allow a final paint interval if needed, and capture the full page.
Do not use network idle by itself as a visual-readiness condition. Playwright defines networkidle as at least 500 ms without network connections and discourages it as a testing readiness check. A lazy image may not generate a request until scrolling triggers it, while some pages keep connections open even after the visible content is ready.
3. Choose the right capture scope
| Goal | Approach | Important detail |
|---|---|---|
| Capture the whole document | page.screenshot({ fullPage: true }) |
Trigger deferred content first; full-page geometry alone does not load it. |
| Capture a specific component | Use a Playwright locator screenshot, such as await page.locator('.report').screenshot({ path: 'report.png' }) |
Wait for the component’s own content and images. A locator screenshot may scroll the element into view. |
| Compare visual snapshots in Playwright Test | await expect(page).toHaveScreenshot('page.png') |
The assertion waits for consecutive screenshots to match, helping with visual stability. It cannot trigger resources that never loaded. |
| Capture a page or element in an existing Puppeteer project | Use Puppeteer’s page or element screenshot API | Puppeteer documents element screenshots and scrolling a hidden element into view by default. You still need to trigger page-wide lazy content for a complete full-page capture. |
Playwright and Puppeteer can both fit this workflow. Choose based on your existing automation stack and whether you need a full page, a particular element, or a test-runner snapshot; the cited documentation does not establish a universal winner.
4. Make repeated captures comparable
- Viewport and scale: Set a fixed viewport and device scale factor. Responsive breakpoints and high-density rendering can change layout and pixels.
- Runtime: Keep the browser and its version consistent. A browser update can change fonts, layout, and rasterization.
- Page state: Use the same route, authentication, cookies, locale, and relevant application state on every run.
- Fonts and assets: Wait for fonts where the page exposes them and ensure required assets are available. Missing fonts can change line breaks and page height.
- Animations and dynamic content: Disable or wait for animations when appropriate, and control timestamps, rotating banners, ads, and live data if the application allows it.
- Loading procedure: Keep the scroll direction, step, pause, stopping rule, and readiness checks stable. Tune these against the target page instead of assuming one delay works everywhere.
For Playwright Test visual assertions, toHaveScreenshot() waits until two successive screenshots match before comparing. This helps detect a stable visual result, but it does not prove that all lazy images were triggered or that external content is deterministic.
5. Handle infinite scroll and unusual page layouts
Infinite-scroll feeds
A feed may increase document height each time the bottom approaches. The sample loop makes repeated passes and caps them to avoid running forever. For production, define a meaningful endpoint: a target item, a maximum number of feed loads, or a maximum document height. Without a stopping rule, an unbounded feed has no final full-page extent.
Nested scrolling containers
Some galleries and dashboards scroll inside an element rather than the document. Scrolling window will not bring those items into view. Identify the container, scroll it directly, and verify the expected assets loaded before capturing. A full-page screenshot of the document may not expand a nested container to show all of its internal content.
CSS backgrounds and script placeholders
document.images only reports image elements. For CSS backgrounds, inspect the relevant computed styles or use an application-specific ready signal. For placeholders replaced by scripts, wait for the page’s completion state or verify that the expected content replaced the placeholder.
Large pages
Full-page captures of very tall pages can consume substantial time and memory, and may exceed practical image dimensions for downstream tools. Capture a specific element, split the document into sections, or set a deliberate maximum extent if one enormous image is not required.
6. Troubleshooting
| Symptom | Likely cause | What to do |
|---|---|---|
| Images below the fold are blank | The capture expanded the page but did not trigger the page’s lazy loader. | Scroll progressively before capture, pause for requests and paint, then inspect image completion and dimensions. |
| The script finishes but some images are missing | Broken requests, a timeout, placeholders, CSS backgrounds, or custom loading behavior. | Review the image report and browser console/network errors. Check backgrounds and page-specific state separately; do not treat complete alone as success. |
| Page height keeps changing | Infinite scrolling, delayed modules, or content added by scripts. | Re-read height after scrolling and use a target endpoint or maximum load count for feeds that do not end. |
| Network idle never happens | Persistent connections or recurring requests keep the network active. | Use explicit page readiness and image checks. Network quiet is not a substitute for checking the content you need. |
| Images in a panel stay unloaded | The panel has its own scroll container, so document scrolling never reveals its contents. | Scroll the panel itself and verify its visible items before capture. |
| Snapshots differ between runs | Viewport, browser, fonts, animations, dynamic data, or page state changed. | Fix those inputs and use Playwright Test’s screenshot assertion stabilization where appropriate. |
| Capture hangs waiting for an image | A request never resolves or a listener waits without a timeout. | Bound waits per image, treat errors as completed waits, and report failed dimensions rather than waiting forever. |
| Full-page image is too large | The document is exceptionally tall or wide. | Capture a target element or bounded sections, or limit the intended capture extent. |
7. Performance, reliability, and cost
Progressive scrolling adds work proportional to the number of scroll positions and the pause at each position. A short pause may miss slow page behavior; a long pause increases capture time. Tune both based on the site, and prefer a page-specific ready condition when one is available. Repeatedly capturing a large page also costs browser memory and image processing time.
Reliability comes from bounded waits, explicit success checks, a defined stopping condition, and consistent browser inputs. Record failed images or incomplete page state alongside the screenshot so a missing asset is visible to your pipeline rather than silently accepted. External pages can change independently, so even a stable browser workflow cannot make their content deterministic.
With a self-hosted browser workflow, account for the runtime and maintenance of browser automation, plus the compute and storage your own infrastructure uses. No benchmark or universal cost comparison follows from the available documentation; measure your target pages and workload.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. Its full-page capture loads lazy images. One GET request returns an image or PDF; the JavaScript call below requests a WebP screenshot of a page.
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://stripe.com',
full_page: 'true',
format: 'webp'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', bytes));
See the ScreenshotNeo API documentation for access and parameter details. Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. 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 required.
FAQ
Does fullPage: true load lazy images?
No. It captures the full scrollable area. Trigger lazy loading and check readiness before taking the screenshot.
Is img.complete enough to prove an image loaded successfully?
No. Check nonzero natural dimensions too, and verify expected content when placeholders or page-specific behavior are involved.
What scroll delay should I use?
There is no universally correct delay in the cited documentation. Start with a modest pause, then tune and validate against the target page’s loading behavior.
Can a screenshot assertion fix missing images?
No. It can wait for repeated screenshots to stabilize, but it cannot load content that the page has not requested or rendered.
Sources
- Playwright: Screenshots — full-page and element screenshot options.
- Playwright: Page navigation API — navigation wait states, including the discouraged
networkidlereadiness condition. - MDN: Lazy loading — deferred loading behavior, interaction triggers, load-event caveats, and image completion.
- Puppeteer: Screenshots — page and element screenshots.
- Playwright: Visual comparisons — screenshot assertion stabilization.


