How to Capture a Website Screenshot After All Images Have Loaded in Playwright
Wait for HTML images to load and decode before capturing a Playwright screenshot. Handle lazy loading, broken images, dynamic content, and full-page output.
To capture a Playwright screenshot after the page’s HTML images are ready, wait for navigation, trigger lazy-loaded content if needed, then wait for each relevant HTMLImageElement to finish loading and decode. Only then call page.screenshot(). A navigation milestone such as load is useful, but it does not guarantee that images inserted later by client-side code or loaded on scroll are ready.
This guide uses the Playwright JavaScript API. The same browser-side readiness check works from Playwright Python. It also explains full-page capture, broken images, dynamic pages, and screenshot stability.
1. Wait for images, including lazy-loaded images
Here is a runnable example for a one-shot capture. It navigates to a page, scrolls down in increments to give lazy loading a chance to start, returns to the top, waits for the images currently in the document to load or fail, decodes successfully loaded images, and saves a full-page PNG.
const { chromium } = require('playwright');
(async () => {
const url = 'https://example.com';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
try {
await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
// Optional: trigger native lazy loading on long pages.
await page.evaluate(async () => {
const step = Math.max(1, Math.floor(window.innerHeight * 0.8));
const height = document.documentElement.scrollHeight;
for (let y = 0; y < height; y += step) {
window.scrollTo(0, y);
await new Promise(resolve =>
requestAnimationFrame(() => requestAnimationFrame(resolve))
);
}
window.scrollTo(0, 0);
await new Promise(resolve => requestAnimationFrame(resolve));
});
const imageResults = await page.evaluate(async () => {
const images = Array.from(document.images);
return await Promise.all(images.map(async (img, index) => {
if (!img.complete) {
await new Promise(resolve => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
});
}
if (img.naturalWidth === 0) {
return { index, src: img.currentSrc || img.src, status: 'failed' };
}
if (typeof img.decode === 'function') {
try {
await img.decode();
} catch (error) {
return {
index,
src: img.currentSrc || img.src,
status: 'decode-failed',
error: String(error)
};
}
}
return { index, src: img.currentSrc || img.src, status: 'ready' };
}));
});
const failures = imageResults.filter(result => result.status !== 'ready');
if (failures.length) {
console.warn('Some images were not ready:', failures);
// For a strict capture, replace this warning with:
// throw new Error(`Image readiness failed for ${failures.length} image(s)`);
}
await page.screenshot({ path: 'screenshot.png', fullPage: true });
} finally {
await browser.close();
}
})();
Install Playwright with npm install playwright; install its browser with npx playwright install chromium. Save the script as capture.js and run node capture.js.
img.complete alone is not enough: it can be true for a broken image. Check naturalWidth > 0 to identify an image with usable intrinsic dimensions. decode() resolves when the image data is ready to render and may reject if the request failed, the source changed during decoding, or the data is corrupt. Choose whether a failure should abort the capture, be logged, or be accepted as a partial image.
2. What the readiness check covers
The code snapshots document.images once. It waits for the HTML <img> elements present at that time. It does not automatically cover every thing that might appear in a screenshot:
- Images inserted later: a framework may add or replace images after a fetch or interaction. Wait for the application’s ready signal or perform the action, then run the image check again.
- CSS backgrounds: background images are not
HTMLImageElementobjects. If they matter, check the page’s known selectors and background URLs using site-specific logic. - Canvas, video, and frames: these have their own rendering and readiness behavior. Wait for the application or media-specific state; cross-origin frames may also limit access.
- Images that keep changing: a carousel, animation, or rotating ad can change after decode. Pause or set the page to a deterministic state before capture if that content matters.
For a page with a known loading indicator, wait for the application state as well as the images. For example, use await page.locator('[data-loading="true"]').waitFor({ state: 'detached' }) if that selector accurately represents the site’s loading state, then run the image check. Replace the sample selector with one owned by the page; a generic selector cannot reliably identify when every application has finished rendering.
3. Full-page screenshots and lazy loading
Set fullPage: true to capture the full scrollable document. It controls the capture extent; it does not itself wait for images. On pages using native lazy loading, images may not begin loading until they approach the viewport, so scroll through the page before checking readiness.
The example uses two animation frames at each scroll position so the browser can process the scroll and rendering work. Some sites lazy-load only after a longer delay, use an intersection observer with custom thresholds, or load content in response to a specific interaction. In those cases, use a site-specific wait or increase the settling wait between scroll positions. After scrolling back to the top, check image readiness again because scrolling or page scripts can trigger more work.
For exceptionally long pages, full-page screenshots can use substantial memory and take longer to render and write. If the output or browser limits become a problem, capture sections or viewport-sized tiles and stitch them in a separate image-processing step. Keep overlap and fixed-position elements in mind when stitching.
4. Choose the navigation and screenshot options
| Option | Use it for | What it does not guarantee |
|---|---|---|
waitUntil: 'load' |
A practical default when initial document resources matter. | It does not promise that later client-side images, lazy images, or image swaps have settled. |
waitUntil: 'domcontentloaded' |
Pages where you want to start app-specific waits as soon as HTML parsing completes. | Images and other resources may still be loading. |
waitUntil: 'commit' |
Cases where you need navigation to be committed before taking control of readiness checks. | It is an early navigation milestone, not a visual readiness condition. |
waitUntil: 'networkidle' |
Rare cases where a quiet network is useful as an additional signal. | It does not mean images are decoded or that the intended app state is ready. Playwright discourages it for tests. |
fullPage: true |
Capturing the full document height. | It does not cause lazy content to load or wait for image decoding. |
scale: 'css' or 'device' |
Choosing screenshot output scale: CSS pixels or device pixels. | It does not affect resource readiness. |
Playwright’s page.goto() defaults to the load milestone. Its networkidle condition means no network connections for at least 500 ms, and its Page API documentation marks it discouraged for tests. A quiet network can still leave a broken image, an undecoded image, or an app that has not inserted its later content. Prefer explicit image and app readiness checks when the screenshot depends on them. See the official Playwright Page API.
5. Use the same approach from Playwright Python
Python can run the same readiness logic inside the page with page.evaluate(). This example uses the synchronous API and saves a full-page PNG.
from playwright.sync_api import sync_playwright
url = "https://example.com"
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1440, "height": 900})
try:
page.goto(url, wait_until="load", timeout=30_000)
page.evaluate("""async () => {
const step = Math.max(1, Math.floor(window.innerHeight * 0.8));
const height = document.documentElement.scrollHeight;
for (let y = 0; y < height; y += step) {
window.scrollTo(0, y);
await new Promise(resolve =>
requestAnimationFrame(() => requestAnimationFrame(resolve))
);
}
window.scrollTo(0, 0);
await new Promise(resolve => requestAnimationFrame(resolve));
}""")
results = page.evaluate("""async () => {
return await Promise.all(Array.from(document.images).map(async (img, index) => {
if (!img.complete) {
await new Promise(resolve => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
});
}
if (img.naturalWidth === 0) {
return { index, src: img.currentSrc || img.src, status: 'failed' };
}
if (typeof img.decode === 'function') {
try {
await img.decode();
} catch (error) {
return { index, src: img.currentSrc || img.src, status: 'decode-failed' };
}
}
return { index, src: img.currentSrc || img.src, status: 'ready' };
}));
}""")
failures = [item for item in results if item["status"] != "ready"]
if failures:
print("Images not ready:", failures)
# For strict capture jobs, raise an exception here instead.
page.screenshot(path="screenshot.png", full_page=True)
finally:
browser.close()
Install the Python package and browser with pip install playwright and playwright install chromium. Save as capture.py and run python capture.py. The script’s warning policy is intentional: decide whether a partial image is useful for your use case, rather than silently treating every failed image as ready.
6. Visual regression tests
For a Playwright Test visual assertion, keep the explicit readiness check in your setup, then use toHaveScreenshot() for the assertion. The matcher waits for two consecutive screenshots to match before comparing the result. That stabilizes the screenshot; it is not a substitute for confirming that the desired images and application state exist.
import { test, expect } from '@playwright/test';
test('page screenshot includes loaded images', async ({ page }) => {
await page.goto('https://example.com', { waitUntil: 'load' });
await page.evaluate(async () => {
await Promise.all(Array.from(document.images).map(async img => {
if (!img.complete) {
await new Promise(resolve => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
});
}
if (img.naturalWidth > 0 && typeof img.decode === 'function') {
await img.decode().catch(() => {});
}
}));
});
await expect(page).toHaveScreenshot('page.png', { fullPage: true });
});
In a strict visual test, swallowing decode errors may hide a genuine missing asset. Track failures and fail the test if those images are part of the expected result. Playwright’s PageAssertions API describes screenshot assertion behavior. Its visual comparisons guide also notes that rendering can vary across operating systems, browser versions, settings, hardware, power conditions, and headless mode. Keep the capture environment consistent when comparing baselines.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Some images are blank even after load |
The images were inserted after navigation, are lazy-loaded, or use a source that changed later. | Trigger the page behavior that loads them, wait for the app’s ready state, then rerun the image check. |
| The script hangs while waiting | An image request neither succeeds nor produces an error event promptly, or the page keeps adding work. | Set a bounded navigation and job timeout. Add a deadline to the readiness helper and report unresolved image URLs so one request cannot stall the whole capture indefinitely. |
complete is true but the image is missing |
complete can also describe a failed request. |
Check naturalWidth; treat zero as failed and choose an explicit partial-versus-fail policy. |
decode() rejects |
The request failed, the source changed during decode, or the image data is corrupt. | Record the image URL and error. Retry only if the page can recover; otherwise fail a strict capture or accept a documented partial result. |
| Full-page capture omits lower-page images | Those images had not entered the lazy-loading path before the readiness snapshot. | Scroll through the document first, wait for app-specific lazy loading, then check images and capture. |
| Background images are absent | document.images does not include CSS backgrounds. |
Wait for known background assets with site-specific logic; do not assume the image-element check covers them. |
| Screenshots differ across runs or machines | Fonts, rendering environment, animations, dynamic content, or image timing vary. | Pin the browser and environment, stabilize app data, disable or control animations, and use Playwright’s screenshot assertion for visual comparisons. |
| Navigation times out on a busy site | Third-party requests or persistent connections can outlast the chosen milestone or timeout. | Use a suitable earlier navigation milestone, then wait for the specific app state and image readiness your capture requires. |
8. Performance, reliability, and cost
Waiting only for relevant images keeps capture time lower than waiting for every possible page activity. For a large document, the image wait runs concurrently with Promise.all, but every pending request still affects total time. Consider checking only a content container if the page includes irrelevant tracking or decorative images, and impose an overall timeout appropriate to the job.
Scrolling the whole document can trigger many image requests and increase bandwidth and memory use. Use it when full-page completeness matters; skip it for a viewport screenshot whose relevant images are already in view. A full-page bitmap also consumes more memory than a viewport capture. In screenshot tests, stable browser versions, viewport sizes, fonts, data, and animation settings make comparisons more reliable.
Playwright itself is open source, but the browser run has operational costs: compute time, image bandwidth, storage for saved outputs, and maintenance of browser dependencies. Control these by reusing browser processes where appropriate, bounding navigation and capture time, avoiding unnecessary scroll passes, and saving only outputs you need. No fixed time or cost is universal; it depends on the page and execution environment.
9. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server for developers. Its full-page capture loads lazy images, so you can request a capture without installing and managing a local Playwright browser. See the ScreenshotNeo API documentation for parameters and response details.
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 bytes = Buffer.from(await res.arrayBuffer());
require('node:fs').writeFileSync('shot.webp', bytes);
- Cookie banners and consent notices, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off.
- Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed. Response headers identify the page verdict and billing status.
- An MCP server provides
take_screenshot,get_page_info, andcapture_pdffor Claude, Cursor, and other MCP clients. - The free plan includes 1,000 screenshots a month with no card. Paid plans start at $5 for 3,000 shots; yearly billing gives two months free. Every feature is on every plan.
Sign up for 1,000 free screenshots a month, with no card required.
10. FAQ
Does fullPage: true wait for lazy images?
No. It expands the screenshot to the full document. Trigger lazy loading and wait for image readiness separately.
Should I wait for networkidle?
Usually not as the sole readiness check. It measures a period of network quiet, not successful image decoding or application readiness, and Playwright discourages it for tests.
Does this wait for every visual asset?
No. It covers HTML image elements present during the check. CSS backgrounds, canvas, media, frames, and images added afterward need their own readiness handling.
What should a visual test do when an image fails?
Fail if the asset is part of the expected result; otherwise record the failure and make the partial-capture policy explicit. A broken image should not silently count as ready.


