Fix Screenshots That Miss Content Loaded by Lazy Loading
Full-page capture does not always trigger lazy content. Scroll the page, wait for the content you need, then capture it.
A full-page screenshot can include the page’s full scrollable height while still missing images or sections that load only when they enter the viewport. Make the relevant content visible by scrolling the page before capture, wait for the specific images or elements you need, and then take the screenshot. Playwright’s fullPage: true controls screenshot extent; it does not guarantee that every site-specific scroll listener or intersection callback has run. Playwright Page API
Why lazy-loaded content is missing
Sites often defer images or other content until they are near or inside the visible viewport. They may use native browser lazy loading, an IntersectionObserver, or application-specific scroll handlers. Google’s guidance for lazy-loaded content describes the viewport visibility trigger. A screenshot that captures the entire document does not necessarily move the viewport through the page to trigger those behaviors. Google Search Central: Fix Lazy-Loaded Website Content
So there are two separate conditions to satisfy:
- Capture extent: how much of the document is included in the screenshot.
- Content readiness: whether the page has loaded and rendered the content that should appear in that extent.
Scrolling through the page helps trigger viewport-based loading. Waiting for the expected content to appear or finish loading helps avoid capturing too early. Neither a full-page option nor a generic delay proves that every page-specific condition has been met.
Playwright: scroll, wait, and capture
This runnable Node.js example uses Playwright. It navigates to a page, scrolls the main document in bounded viewport-sized steps, waits for images in each viewport to settle, and captures the full page. Install Playwright with npm install playwright; install its browser if needed with npx playwright install chromium. Save the code as capture.mjs and run 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({ headless: true });
const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });
// This initial condition is useful for many pages, but it does not guarantee
// that later scroll-triggered requests have completed.
await page.locator('body').waitFor({ state: 'visible', timeout: 15_000 });
// Scroll the document so viewport-triggered content can load. The height is
// re-read each time because loading content can increase the document height.
let previousHeight = 0;
let stableEndPasses = 0;
const maxSteps = 100;
const stepSize = Math.max(300, Math.floor(page.viewportSize().height * 0.8));
for (let step = 0; step < maxSteps; step++) {
const state = await page.evaluate(() => ({
y: window.scrollY,
viewport: window.innerHeight,
height: document.documentElement.scrollHeight
}));
await page.evaluate((y) => window.scrollTo(0, y), state.y + stepSize);
await page.waitForTimeout(150); // Small reaction interval, not a readiness guarantee.
// Wait for currently visible images to either load or report an error.
await page.evaluate(async () => {
const images = [...document.images].filter((img) => {
const r = img.getBoundingClientRect();
return r.bottom >= 0 && r.top <= window.innerHeight;
});
await Promise.all(images.map((img) => {
if (img.complete) return Promise.resolve();
return new Promise((resolve) => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
});
}));
});
const after = await page.evaluate(() => ({
y: window.scrollY,
viewport: window.innerHeight,
height: document.documentElement.scrollHeight
}));
if (after.y + after.viewport >= after.height - 2 && after.height === previousHeight) {
stableEndPasses++;
if (stableEndPasses >= 2) break;
} else {
stableEndPasses = 0;
}
previousHeight = after.height;
if (step === maxSteps - 1) {
throw new Error(`Reached maxSteps=${maxSteps}; page may be infinite or still growing`);
}
}
// Replace this selector with a content-specific readiness condition when
// the page exposes one. For a gallery, for example, wait for expected cards.
await page.waitForFunction(() => {
const broken = [...document.images].filter((img) => img.complete && img.naturalWidth === 0);
return broken.length === 0;
}, { timeout: 10_000 }).catch(() => {
console.warn('Some images may be broken or still unavailable; inspect the page-specific state.');
});
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
await browser.close();
}
The short pause lets scroll handlers react; it is not a universal wait value. Replace it or supplement it with an application-specific signal when possible. For a known target, wait directly for it:
await page.locator('.product-grid img').first().waitFor({ state: 'visible' });
await page.waitForFunction(() => {
const img = document.querySelector('.product-grid img');
return img instanceof HTMLImageElement && img.complete && img.naturalWidth > 0;
});
await page.screenshot({ path: 'products.png', fullPage: true });
Use a condition that matches the page
Choose a wait condition based on what must be present in the image, not on an arbitrary amount of time.
| Page behavior | Useful readiness check | What to watch for |
|---|---|---|
| Known image or section | Wait for its locator to be visible, then check the image is complete and has a nonzero natural width. | An image can be in the DOM before its source has loaded. |
| Loading indicator | Wait for the relevant spinner or skeleton to disappear. | Make sure the selector identifies the content region you care about. |
| Scroll-triggered API request | Wait for a known response, a page state change, or the resulting content element. | Network idle may never occur when analytics or polling remain active. |
| Several expected items | Wait until the locator count reaches the expected number. | Use an explicit expected count; a feed may continue growing. |
| Content that expands document height | Scroll until the bottom is reached and height stays stable for multiple passes. | Set a step limit to bound infinite or continuously growing pages. |
waitUntil: 'networkidle' can help with initial navigation when it fits the site, but it is not a guarantee that later scroll-triggered content is ready. Puppeteer’s screenshot guide uses networkidle2 in a screenshot example; treat navigation idleness as one possible signal, not proof of final readiness. Puppeteer Screenshots guide
Handle nested scrollers, virtualized lists, and infinite feeds
Nested scrolling containers
Some pages scroll a panel rather than the document. Scrolling window will not trigger the panel’s lazy content. Scroll the actual container and wait for its content. For example:
const panel = page.locator('.results-panel');
await panel.evaluate(async (el) => {
const step = Math.max(200, Math.floor(el.clientHeight * 0.8));
for (let i = 0; i < 50; i++) {
const before = el.scrollTop;
el.scrollTop = Math.min(before + step, el.scrollHeight);
await new Promise((resolve) => setTimeout(resolve, 150));
if (el.scrollTop === before || el.scrollTop + el.clientHeight >= el.scrollHeight) break;
}
});
await page.screenshot({ path: 'panel.png', fullPage: true });
This example is a bounded starting point. If the container loads more items asynchronously, wait for an item or loading-state transition after each step before deciding that it has reached the end.
Virtualized content
A virtualized list may keep only visible rows in the DOM and recycle them as you scroll. A full-page screenshot cannot include rows that the application never renders at the same time. Decide whether the goal is a screenshot of the currently rendered viewport, a sequence of screenshots stitched by your own workflow, or a page state in which virtualization is disabled. Do not assume increasing the page height will reveal unrendered rows.
Infinite feeds
Use an explicit stopping rule: a known item count, a “load more” button reaching its end state, a terminal marker, or a maximum scroll count. Avoid looping until the page stops changing without a limit; a feed can keep producing content indefinitely. For reproducible captures, use a stable test account or fixture and define exactly which feed state should be included.
Layout changes while loading
Image dimensions may be unknown until load, shifting content and increasing document height. Re-read scroll height as you progress and wait for the important images to settle before capture. If the site reserves image space using dimensions or aspect ratios, layout is generally easier to stabilize, but the capture script should still verify the content it depends on.
Other browser automation options
Puppeteer supports full-page screenshots and element screenshots; its documentation notes that element screenshots attempt to scroll a hidden element into view. You still need to trigger and verify the page’s own lazy-loading behavior for a whole-page capture. The same principle applies across browser automation tools: cause the relevant viewport or container to reach the content, then check the result before capture. Puppeteer Screenshots guide
For Playwright, use page.screenshot({ fullPage: true }) only after the scroll-and-wait workflow. The API describes a full scrollable-page capture; it does not describe that option as a lazy-content loading pass. Playwright Page API
Or skip the browser setup
If you need a screenshot without maintaining browser automation, ScreenshotNeo is a website screenshot API and MCP server for developers. One GET request returns an image or PDF. Its clean-shot flow accepts consent banners like a visitor and removes 60+ known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, and the response reports the page verdict and billing status in headers. AI agents can use its MCP server tools: take_screenshot, get_page_info, and capture_pdf.
See the ScreenshotNeo API documentation for parameters and response details. This is a one-call capture example; it does not replace a page-specific readiness check when you need to guarantee that a particular lazy-loaded element is present.
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 Bun.write('shot.webp', res);
ScreenshotNeo includes 1,000 screenshots a month free with no card; paid plans start at $5 for 3,000 screenshots. Start with 1,000 free screenshots a month, no card required.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Screenshot is full height but images are blank | Capture happened before the viewport triggered the image load, or before the request completed. | Scroll through the relevant region, then wait for the image’s complete state and nonzero naturalWidth. |
| Only the first screen loads | The page uses viewport-triggered loading and the script never scrolled through the rest of the document. | Perform a bounded scroll pass before calling the full-page screenshot method. |
| Scrolling the page has no effect | The target content is inside a nested scrolling element, or an overlay prevents scrolling. | Identify the actual scroll container and scroll it. Dismiss or handle the overlay if it is part of the test setup. |
| Wait for network idle times out | Long polling, analytics, streaming, or other ongoing requests prevent an idle window. | Wait for the target element, response, or application state instead of requiring global network idleness. |
| Loop never reaches the bottom | An infinite feed keeps adding items, document height keeps growing, or the wrong container is being measured. | Set a maximum step count and a target end condition; measure the correct scroll container. |
| Some images remain broken | The URL failed, access is restricted, the image is blocked, or the source has not been assigned yet. | Inspect the image’s src/currentSrc, browser console, and network response; wait for the application to assign the source and handle genuine failures explicitly. |
| Rows disappear from the final full-page image | The page virtualizes rows and removes offscreen items from the DOM. | Capture the required state in sections or use a supported non-virtualized test mode; a full-page screenshot cannot include content that is not rendered. |
| Screenshot height is unexpectedly large or unstable | Content keeps loading, layout shifts, or the page has an unbounded feed. | Define a deterministic end state, wait for target content, and cap the scroll loop. |
Performance, reliability, and cost
A scroll pass adds time roughly in proportion to the number of viewports visited and the content’s response time. Waiting for precise content conditions is usually more efficient and repeatable than using a long fixed sleep, though each site needs its own condition. Keep loops bounded, reuse a browser process when running many captures, and close pages and browsers in cleanup paths. For highly dynamic pages, use a stable fixture and record the viewport, browser, and intended content state so captures can be compared meaningfully.
Reliability depends on the site: lazy-loading thresholds, nested containers, API timing, virtualization, and infinite scrolling vary by implementation. Validate the workflow against representative pages and assert the presence of required content before saving the artifact. A successful screenshot call only confirms that an image was produced; it does not confirm that the image contains the state you intended.
For self-hosted browser automation, account for browser execution time and the resources used by each page. For ScreenshotNeo, the stated plans are Free: 1,000 shots/month; Starter: $5 for 3,000; Growth: $15 for 15,000; Pro: $39 for 60,000; Scale: $99 for 250,000; Business: $249 for 1,000,000. Yearly billing gives two months free, and every feature is on every plan. A clean shot is billed; bot checks/CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed. See current options at the documentation.
FAQ
Does fullPage: true scroll the page before taking the screenshot?
It requests a screenshot of the full scrollable page. Do a separate scroll pass when the site loads content in response to viewport entry.
Should I use a longer timeout?
Only if the relevant operation needs more time. Prefer waiting for the content or state you need; a longer generic timeout can still finish before delayed content appears.
Can a screenshot include every item in a virtualized list?
Not if the application renders only the current viewport’s rows. Capture deliberate sections or use a test state that renders the required items together.
Is network idle enough?
Not by itself. It can indicate a quiet interval, but does not show that scroll-triggered content was requested or that the required element rendered.


