How to Capture Lazy-Loaded Images Before Taking a Playwright Screenshot
Scroll to trigger lazy loading, wait for the images you need, then capture. Learn how to handle full pages, nested scrollers, infinite feeds, and missing images in Playwright.
Scroll the page to trigger viewport-based lazy loading, wait until the images you need have loaded successfully, and only then take the screenshot. Playwright’s fullPage: true controls how much of the document the screenshot includes; it does not make the browser load deferred images. A page’s load event is not enough either: lazy images may still be pending.
This guide shows a practical Playwright JavaScript workflow, explains the readiness checks and their limits, and covers nested scrollers, CSS backgrounds, infinite feeds, and common failures. For the relevant API details, see the Playwright screenshot guide, the Playwright Page API, and MDN’s guides to lazy loading and the <img> element.
1. Why lazy images are missing from screenshots
Browsers can defer loading images that are outside or far from the viewport. Scrolling brings them near the viewport and gives native lazy loading and many viewport-triggered scripts a chance to request them. A full-page screenshot can include pixels from below the fold without first causing every site’s lazy-loading logic to run.
These are separate questions:
- What area should the screenshot contain? Use
fullPage: truefor the full scrollable document, or omit it for the current viewport. - Are the resources ready to paint? Trigger loading, then wait for the relevant images or a site-specific readiness signal.
Do not treat the document load event as proof that deferred images are ready. Likewise, network quiet is only a heuristic: Playwright discourages using networkidle as a general test readiness condition. Prefer an explicit condition tied to the content you need. Visual snapshot assertions wait for consecutive screenshots to match, but that visual stability check does not prove that each desired image loaded successfully. See the Playwright visual comparisons guide.
2. Complete Playwright example: scroll, verify, capture
The following CommonJS example launches Chromium, visits a URL, scrolls the top-level document in viewport-sized steps, checks ordinary document images, and saves a full-page PNG. It uses a bounded scroll loop because loading content can increase page height. The readiness wait has a finite timeout so one stalled image does not hang the script indefinitely.
const { chromium } = require('playwright');
async function loadImagesByScrolling(page, {
maxPasses = 10,
stepDelayMs = 100,
bottomDelayMs = 250,
} = {}) {
await page.evaluate(async ({ maxPasses, stepDelayMs, bottomDelayMs }) => {
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const step = Math.max(1, window.innerHeight);
let previousHeight = -1;
for (let pass = 0; pass < maxPasses; pass++) {
const height = document.documentElement.scrollHeight;
for (let y = 0; y < height; y += step) {
window.scrollTo(0, y);
await pause(stepDelayMs);
}
window.scrollTo(0, document.documentElement.scrollHeight);
await pause(bottomDelayMs);
const newHeight = document.documentElement.scrollHeight;
if (newHeight === previousHeight) break;
previousHeight = newHeight;
}
}, { maxPasses, stepDelayMs, bottomDelayMs });
// A completed image may still have failed. Exclude images with no current
// source, and require a decoded natural size for sourced images.
await page.waitForFunction(() =>
[...document.images].every((img) =>
img.complete && (img.currentSrc === '' || img.naturalWidth > 0)
),
{ timeout: 15000 }
);
}
(async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await loadImagesByScrolling(page);
await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
await browser.close();
}
})();
Install Playwright in a project that has Node.js available, then run the script:
npm install playwright
npx playwright install chromium
node capture.js
Replace https://example.com with the page you are authorized to capture. The example targets ordinary <img> elements and a page where scrolling the main document triggers loading. Tune the delays for the page and environment; they are opportunities for page scripts and browser loading to run, not guarantees of completion. The explicit image check supplies the readiness condition.
Scope the readiness check to required images
Waiting for every image may be too strict when a page contains an intentionally broken thumbnail, an optional advertisement, or images irrelevant to the screenshot. If the page has stable selectors for the images that matter, wait for those instead:
await page.waitForFunction(() => {
const images = [...document.querySelectorAll('.article-content img')];
return images.length > 0 && images.every((img) =>
img.complete && img.naturalWidth > 0
);
}, { timeout: 15000 });
Adjust the selector and the empty-set policy to the page. Requiring at least one matching image avoids treating a selector mismatch as success. For responsive images, inspect currentSrc to see which candidate the browser selected and naturalWidth to check that it decoded with a nonzero intrinsic width.
3. Handle special page behavior
Nested scroll containers
Some galleries and feeds scroll an element rather than the document. Scrolling window will not move such a container, so it may not trigger its observer. Scroll the actual element in steps and define a limit:
await page.locator('.gallery').evaluate(async (el) => {
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const step = Math.max(1, el.clientHeight);
const maxSteps = 100;
for (let i = 0; i < maxSteps && el.scrollTop + el.clientHeight < el.scrollHeight; i++) {
el.scrollTop = Math.min(el.scrollTop + step, el.scrollHeight);
await pause(100);
}
});
Use the site’s normal interaction if it listens for wheel or keyboard events rather than scroll position alone. If the screenshot must show the original position, record scrollTop before preloading and restore it after the needed assets are ready. Allow the layout to settle after restoring because image arrival can change dimensions.
CSS background images and other non-img content
document.images only covers image elements. A CSS background-image, canvas, video poster, or image drawn by application code needs a separate readiness signal. For known CSS backgrounds, the page can expose an application-specific ready marker, or your script can inspect the computed style and explicitly load the referenced URL. A generic img.complete check cannot certify these resources.
Infinite feeds and virtualized lists
An infinite feed may never reach a stable document height. A virtualized list may remove offscreen nodes as you scroll, so a single final full-page image cannot include items that are no longer in the DOM. Set a deliberate stopping rule: a target item count, a known end marker, or a maximum scroll distance. If the desired output is a long archive, capture sections as you go or use the site’s export or pagination flow. Do not loop until height stops changing on a feed designed to keep growing.
Layout shifts and scroll position
If images arrive without reserved dimensions, the page can shift as you scroll or after you return to the top. Where you control the page, provide image dimensions or an aspect ratio. When you do not control it, wait for the relevant images, return to the desired capture position, and allow layout changes to settle before capture.
4. Choosing waits and screenshot options
| Need | Approach | Limit |
|---|---|---|
| Trigger viewport-based lazy loading | Scroll in viewport-sized steps and briefly yield between steps | Custom loaders may require a nested scroller or normal user interaction |
| Confirm ordinary images loaded | Check complete and naturalWidth > 0 |
Does not cover CSS backgrounds, canvas, or app-specific content |
| Capture the whole document | page.screenshot({ fullPage: true }) |
Controls screenshot extent, not resource readiness |
| Capture just what is visible | Leave fullPage unset or false |
Still wait for images in that viewport if needed |
| Wait for an app-specific state | Use waitForFunction or a locator assertion for a meaningful signal |
The signal must represent the content required in the image |
Use a delay to let scroll-triggered work start, not as the only proof that it finished. Set finite timeouts and decide what to do when a required image fails: fail the capture, record a partial result, or exclude that image from the required set. This policy is more reliable than waiting forever or silently assuming every response succeeded.
5. Troubleshooting missing or incomplete images
| Symptom | Likely cause | Fix |
|---|---|---|
| Images below the fold are blank | The browser captured before viewport-triggered requests began | Scroll in steps before capture, then wait for required images |
fullPage: true still misses images |
Full-page extent does not trigger every lazy loader or wait for assets | Run the preload sequence independently of screenshot options |
| Wait times out on one image | Broken URL, blocked request, slow response, or an image that never becomes required | Inspect the failed image and network activity; scope the check or apply an explicit failure policy |
| Top-level scrolling has no effect | The page uses a nested scroller, interaction-driven loading, or virtualization | Scroll the relevant container or use the site’s interaction and stop condition |
| The image is complete but appears broken | complete can also be true for a failed image |
Require naturalWidth > 0 for images with a source |
| Images load but content is still missing | The content uses CSS backgrounds, canvas, video, or custom rendering | Wait on an application-specific readiness signal or inspect that resource type separately |
| Capture looks inconsistent between runs | Layout shifts, animations, carousels, or changing remote content | Reserve image dimensions where possible, wait for stable application state, and disable or control motion if appropriate |
6. Performance, reliability, and cost
Preloading adds time proportional to the page’s height and the waits between scroll steps. A long page can require many steps; repeated passes are useful when loading changes document height, but a finite pass count prevents unbounded work. Keep the step delay modest, scope readiness checks to necessary images, and avoid rescanning a huge page more often than needed. For infinite content, choose an explicit item or distance limit.
Reliability depends on matching the preload method to the page: top-level scrolling works for document-based triggers, while custom containers and app-specific loaders need their own signals. A timeout makes failures visible and bounds capture time. Record which images failed if partial output is acceptable. Use a fixed viewport and a stable page state when repeatability matters. These choices trade capture time against how much content and certainty you need; there is no universal delay that guarantees every site is ready.
The do-it-yourself approach uses Playwright and browser execution; its operational cost depends on your hosting, browser runtime, and capture volume. This guide makes no benchmark or universal cost claim. For a hosted API option, ScreenshotNeo provides a free allowance and published plan sizes in the section below.
7. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. For a one-call capture, request the target URL and save the returned image. See the ScreenshotNeo API documentation for options.
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}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
Replace the example URL with the page you need. ScreenshotNeo accepts the parameter names used by other screenshot APIs, which can simplify a switch. Cookie and consent banners are accepted like a visitor and 60+ known consent platforms, newsletter popups, and chat widgets are removed before the shot; each step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. Its MCP server gives AI agents tools to take screenshots, get page information, and capture PDFs.
The free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 screenshots; every feature is available on every plan. For automated browser capture, use Playwright when you need control of the browser workflow; use the API when a hosted one-call capture fits the job.
Sign up for ScreenshotNeo’s free 1,000 screenshots a month, with no card.
8. FAQ
Does fullPage: true load images below the fold?
No. It requests a screenshot of the full scrollable document. Trigger lazy loading and wait for the needed content separately.
Can I rely on networkidle?
Not as proof that a particular image is ready. Prefer an image check or application signal that matches what the screenshot needs.
Why does the example allow images with an empty currentSrc?
An image without a selected source is not a loaded asset. The sample treats it as outside the set of sourced images to wait for. If every image must have a source, change the condition to require a nonempty currentSrc and positive naturalWidth.
Will this work for a virtualized page?
Not as a way to preserve all offscreen items in one full-page screenshot. Virtualized pages can remove items as they leave view; capture sections or use the page’s own export behavior.
Can ScreenshotNeo be used by an AI agent?
Yes. Its MCP server includes take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.


