Playwright Screenshot Misses Images Loaded After Scrolling: How to Fix It
A full-page Playwright screenshot does not trigger every lazy-loaded image. Scroll the page, wait for image success, then capture it reliably.
page.screenshot({ fullPage: true }) captures the page’s full scrollable extent, but it does not guarantee that images deferred until scrolling have loaded. Scroll through the page in steps to trigger lazy loading, wait for the relevant images to finish successfully, return to the top, and then take the screenshot.
Below is a bounded Playwright JavaScript helper for pages whose content is revealed by scrolling. It checks image success rather than relying on the document load event or a fixed wait alone. The loop also handles pages that add content as you scroll, up to a defined cap.
1. Why a full-page screenshot can miss images
Native lazy-loaded images defer their requests until the browser estimates they are near the viewport. Some sites use intersection observers, scroll handlers, or application logic to reveal images in a similar way. The document’s load event can fire while offscreen lazy images are still unloaded. A full-page screenshot specifies the capture extent; it does not itself make the page scroll through that extent first.
Playwright’s networkidle wait is discouraged as a general readiness signal. Network quiet does not prove a particular image or application state is ready, and persistent connections or polling may prevent quietness. Prefer a meaningful condition such as successful image loads, a known item count, or an application-specific ready state.
2. Fix it with Playwright JavaScript
Use this helper for a finite page or a feed that grows while scrolling. It gradually scrolls the main document, waits briefly for scroll-triggered application code to run, and stops after the document height remains stable for three rounds. It then waits for the images in the final DOM and reports images that have a URL but no decoded image dimensions.
async function loadImagesBeforeScreenshot(page) {
// Scroll through the main document to trigger viewport-sensitive loading.
await page.evaluate(async () => {
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const step = Math.max(200, Math.floor(window.innerHeight * 0.8));
let previousHeight = 0;
let stableRounds = 0;
// The cap prevents an endless loop on continuously growing pages.
for (let i = 0; i < 80 && stableRounds < 3; i++) {
window.scrollBy(0, step);
await pause(150);
const height = document.documentElement.scrollHeight;
stableRounds = height === previousHeight ? stableRounds + 1 : 0;
previousHeight = height;
}
window.scrollTo(0, 0);
});
// Wait for images present at the end of scrolling to settle.
const failures = await page.evaluate(async () => {
const images = [...document.images];
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 });
});
}));
return images
.filter(img => img.currentSrc && img.naturalWidth === 0)
.map(img => img.currentSrc);
});
if (failures.length) {
throw new Error(`Images failed to load: ${failures.join(', ')}`);
}
await page.screenshot({ path: 'page.png', fullPage: true });
}
// Example with an existing Playwright Page:
await loadImagesBeforeScreenshot(page);
The short per-step pause gives scroll-triggered page code a chance to run; it is not treated as proof that images loaded. The helper checks the image elements afterward. Adjust the loop for the page under test: a real infinite feed should stop on a known item count, a loading indicator disappearing, or an application data signal where possible. The 80-step cap is a safety bound, not a promise that every page can be exhausted.
A complete runnable script
For a standalone example, install Playwright and its Chromium browser, save this as capture.js, then run node capture.js https://example.com. The helper above can be placed in the same file before the main block.
const { chromium } = require('playwright');
async function loadImagesBeforeScreenshot(page) {
await page.evaluate(async () => {
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const step = Math.max(200, Math.floor(window.innerHeight * 0.8));
let previousHeight = 0;
let stableRounds = 0;
for (let i = 0; i < 80 && stableRounds < 3; i++) {
window.scrollBy(0, step);
await pause(150);
const height = document.documentElement.scrollHeight;
stableRounds = height === previousHeight ? stableRounds + 1 : 0;
previousHeight = height;
}
window.scrollTo(0, 0);
});
const failures = await page.evaluate(async () => {
const images = [...document.images];
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 });
});
}));
return images
.filter(img => img.currentSrc && img.naturalWidth === 0)
.map(img => img.currentSrc);
});
if (failures.length) {
throw new Error(`Images failed to load: ${failures.join(', ')}`);
}
await page.screenshot({ path: 'page.png', fullPage: true });
}
(async () => {
const url = process.argv[2];
if (!url) throw new Error('Usage: node capture.js <url>');
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto(url, { waitUntil: 'load' });
await loadImagesBeforeScreenshot(page);
} finally {
await browser.close();
}
})().catch(error => {
console.error(error);
process.exitCode = 1;
});
For example, setup commands in a new project are npm install playwright and npx playwright install chromium. The page navigation waits for load only to complete the initial navigation; the image readiness check is what handles deferred images.
3. Adapt the readiness check to the page
Finite pages
On a finite page with a stable height, scroll through the document in steps and wait for the images you care about. You may simplify the loop if you know the page height cannot change. Keep the success check: an image can have complete === true even when it failed to load, so use naturalWidth > 0 when actual image data is required.
Infinite scroll or content that grows
Height stability is only a heuristic. Some feeds add content after a longer delay, and some keep growing indefinitely. Replace the stable-height stop condition with a page-specific one when possible: wait for a known number of cards, a loading spinner to disappear, or a documented application state. Keep an overall iteration or time limit so automation cannot run forever.
Nested scroll containers
If the page’s content scrolls inside an element with its own scrollbar, scrolling window will not bring those images into view. Identify the scrollable container and scroll that element in increments. Likewise, if the app only creates images after clicking a “Load more” control or opening a panel, perform that user action before waiting for images.
CSS background images and replaced DOM nodes
document.images covers <img> elements, not CSS background-image resources. For those, inspect the relevant elements’ computed styles and wait for the background URLs using an application-specific signal. If scrolling causes the app to replace earlier image nodes, the final-DOM check only covers nodes still present at the end; collect the URLs or assert the loaded state as each batch appears if every item matters.
4. Choose the right screenshot extent
Use fullPage: true when the output should cover the full scrollable document. Omit it for a viewport screenshot. In either case, trigger and verify the image loading that matters before capture. For a visual regression check, Playwright’s screenshot assertion can wait for consecutive screenshots to match, but that stabilization does not cause missing page content to load; prepare the page first.
5. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Images are still absent with fullPage: true |
The screenshot did not trigger the site’s viewport-based lazy loading. | Scroll through the page in increments, wait for image success, then capture. |
The script finishes at load but images are missing |
Offscreen lazy images may still be deferred when the document load event fires. | Use image or app readiness checks after triggering the content. |
networkidle never happens, or happens too early |
Polling and persistent connections can prevent network quiet; quietness also does not identify required images. | Wait for the relevant images, item count, spinner, or application signal. |
| An image is marked complete but appears broken | complete can be true for a failed image. |
Check naturalWidth > 0 and inspect the image URL and browser console/network errors. |
| The page height is stable but later cards are missing | Loading may be delayed or depend on an application trigger beyond the current scroll. | Use a known item count or app-specific completion signal, and perform required clicks. |
| The main page scrolls but the gallery does not | The gallery may be a nested scroll container. | Scroll the actual container element, or trigger the app’s reveal action. |
| The failure list is empty but a visual remains absent | The image may be a CSS background, canvas content, or not yet inserted into the DOM. | Check computed styles and application state; wait on the resource or signal appropriate to that rendering method. |
| Screenshot comparisons vary across runs | Viewport, browser engine, device scale, or page state differs. | Keep the browser, viewport, scale, fonts, and application readiness conditions consistent with the visual baseline. |
6. Performance, reliability, and cost
- Performance: scrolling and waiting add time, especially for long feeds. Use a page-specific stop condition and check only the image elements or application state required by the capture.
- Reliability: prefer deterministic signals such as a known item count or successful image dimensions over arbitrary long sleeps or generic network quiet. Keep a hard loop limit for feeds that can grow without end.
- Capture consistency: use the same browser engine, viewport, device scale, and page state for repeated screenshots. Fonts, responsive breakpoints, and different image variants can change the result.
- Cost: local Playwright has no per-screenshot API charge, but each capture consumes browser runtime and machine resources. Account for browser installation, execution time, and any infrastructure you run it on.
7. Or skip the browser setup
If you need a screenshot without maintaining a browser automation setup, ScreenshotNeo is a website screenshot API and MCP server. One GET request returns a PNG, JPEG, WebP, or PDF. Its full-page capture loads lazy images. Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed. AI agents can take screenshots through its MCP server using Claude, Cursor, or another MCP client. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000.
See the ScreenshotNeo API documentation for request options. Example with cURL:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
Sign up for 1,000 free screenshots a month, with no card.
8. Frequently asked questions
Does fullPage: true scroll the browser through the page?
It captures the full page extent; do not rely on it to trigger every site’s scroll-dependent loading. Scroll and check readiness first.
Should I set every lazy image to eager loading?
That can be useful when you control the page, but it does not address every app-specific trigger, nested container, or CSS background. For pages you do not control, simulate the relevant scrolling or action and verify the result.
Can Playwright guarantee that every image on any site is ready?
No universal check covers images, backgrounds, canvases, replaced nodes, and infinite feeds. Define what content the capture requires and use that page’s completion signal.
Can I use this before a viewport-only screenshot?
Yes. Trigger and verify the content first, then omit fullPage: true if only the current viewport should be captured.


