Chrome Headless: Wait for Page Fonts and Images to Load
Wait for the page state you need, then let used fonts and relevant images load and decode before capturing with Chrome Headless or Puppeteer.
To keep a Chrome Headless screenshot from showing fallback fonts or missing images, wait for the page’s actual content to appear, wait for the fonts used by that content, and wait for the relevant images to load and decode. Then call page.screenshot(). Navigation finishing is only a milestone; it does not prove that a single-page app has rendered, lazy images have been fetched, or every image has decoded.
The example below uses Puppeteer because it can express those waits and report failures. document.fonts.ready covers fonts used by the document and associated layout work; it does not force every declared but unused font to load. For a full-page capture, the example also scrolls through the document to trigger many lazy images. Adapt the app-ready selector and image-error policy to your page.
1. Install Puppeteer and choose a readiness condition
Install Puppeteer in a Node.js project:
npm install puppeteer
Choose a condition that corresponds to the content you expect in the screenshot. If the page has a stable main region, wait for it. If your application exposes a reliable ready marker, use that instead. Network idle can be a useful navigation milestone, but it is not a universal definition of ready: polling may prevent it, and client-side rendering may continue after it.
waitUntil: 'load'waits for the page load event. It does not guarantee app-specific readiness or lazy image loading.waitUntil: 'networkidle2'waits for a quiet network period and is shown in Puppeteer’s screenshot guide. Treat it as a milestone, not proof that the screenshot is ready.waitForSelector()orwaitForFunction()can wait for a page-specific condition. The condition must mean what your capture needs it to mean.
See the Puppeteer screenshot guide, Page API, and navigation lifecycle events for the current API details.
2. Wait for fonts and images, then capture
Save this as screenshot.mjs. Set TARGET_URL to the page you own or are authorized to capture. The script has bounded navigation and selector waits, triggers lazy loading by scrolling, then waits for font readiness and image loading/decoding. It reports broken images and fails by default so a partial capture does not silently look successful.
import puppeteer from 'puppeteer';
const TARGET_URL = 'https://example.com';
const OUTPUT = 'page.png';
const TIMEOUT_MS = 30_000;
const FAIL_ON_BROKEN_IMAGES = true;
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
page.setDefaultTimeout(TIMEOUT_MS);
await page.setViewport({ width: 1440, height: 1000, deviceScaleFactor: 1 });
// Use the readiness signal that fits the site. networkidle2 is a milestone;
// the selector below confirms the page's main content exists.
await page.goto(TARGET_URL, {
waitUntil: 'networkidle2',
timeout: TIMEOUT_MS,
});
await page.waitForSelector('main', { visible: true, timeout: TIMEOUT_MS });
// For a full-page shot, move through the document so loading="lazy" images
// near each viewport have a chance to start loading. This is not a guarantee
// for every custom lazy-loading implementation.
await page.evaluate(async () => {
const step = Math.max(1, window.innerHeight);
const maxY = document.documentElement.scrollHeight;
for (let y = 0; y < maxY; y += step) {
window.scrollTo(0, y);
await new Promise(resolve => setTimeout(resolve, 100));
}
window.scrollTo(0, 0);
});
// Wait for fonts and image fetch/decode. A failed image is recorded rather
// than hanging forever; the caller chooses whether that should fail capture.
const readiness = await page.evaluate(async (timeoutMs) => {
const withTimeout = (promise, label) => Promise.race([
promise,
new Promise((_, reject) => setTimeout(
() => reject(new Error(`Timed out waiting for ${label}`)), timeoutMs
)),
]);
await withTimeout(document.fonts.ready, 'document fonts');
const images = Array.from(document.images).filter(image =>
Boolean(image.currentSrc || image.src)
);
const results = await Promise.all(images.map(async (image) => {
try {
if (!image.complete) {
await withTimeout(new Promise((resolve) => {
image.addEventListener('load', resolve, { once: true });
image.addEventListener('error', resolve, { once: true });
}), `image ${image.currentSrc || image.src}`);
}
if (image.naturalWidth === 0) {
return { src: image.currentSrc || image.src, ok: false, reason: 'load failed' };
}
if (typeof image.decode === 'function') {
await withTimeout(image.decode(), `image decode ${image.currentSrc || image.src}`);
}
return { src: image.currentSrc || image.src, ok: true };
} catch (error) {
return {
src: image.currentSrc || image.src,
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
}
}));
return {
fontStatus: document.fonts.status,
imageCount: results.length,
failedImages: results.filter(result => !result.ok),
};
}, TIMEOUT_MS);
console.log(JSON.stringify(readiness, null, 2));
if (FAIL_ON_BROKEN_IMAGES && readiness.failedImages.length) {
throw new Error(`${readiness.failedImages.length} image(s) failed or did not become ready`);
}
await page.screenshot({ path: OUTPUT, fullPage: true });
console.log(`Saved ${OUTPUT}`);
} finally {
await browser.close();
}
Run it with node screenshot.mjs. Replace main with a selector that exists only after the content needed for your capture is rendered. If the app exposes a ready flag, a more precise wait could be await page.waitForFunction(() => window.appReady === true), provided the page actually sets that flag.
The complete property by itself is insufficient: it can be true for an image that failed or has no usable source. The script checks naturalWidth and calls decode() where available. Image decode can reject, so errors are collected. Decide whether a broken decorative image should fail the job or merely be logged. Browser references: Document.fonts, FontFaceSet.ready, HTMLImageElement.complete, and HTMLImageElement.decode().
3. Adjust the waits for the kind of screenshot
| Need | Adjustment | Trade-off |
|---|---|---|
| Viewport capture; images are eager | Keep the font and image waits, omit the scrolling loop, and use fullPage: false. |
Images below the viewport do not need to be ready for a viewport-only capture. |
| Full-page capture with native lazy images | Keep the scrolling loop, then inspect the image results before capture. | Scrolling triggers many common lazy-loading patterns, but custom observers or virtualized content may need a site-specific trigger. |
| Only one region matters | Wait for that region’s selector and collect images within it instead of document.images. |
Avoids waiting for unrelated media on a long page. |
| Images may be broken but capture should continue | Set FAIL_ON_BROKEN_IMAGES = false and retain the logged failedImages list. |
The output may contain visible placeholders or empty image areas. |
| Page uses a known app-ready signal | Wait for the app marker or state before fonts/images. | More deterministic if the application defines readiness correctly; the selector or flag itself can be wrong. |
| Page never becomes network idle | Use waitUntil: 'domcontentloaded' or 'load', then wait for the app-specific condition. |
You take responsibility for defining when content is ready. |
For fonts, document.fonts.ready resolves once fonts used by the document have settled and layout work is complete. It does not load every font-face declaration in the stylesheet. If a specific font is essential but not yet used by rendered content, make the relevant content use it or explicitly check the needed font with the Font Loading API before capture.
For images, native loading="lazy" can defer fetching until an image approaches the viewport. A full-page screenshot does not itself guarantee that every lazy image has been requested before capture. Scrolling is a practical trigger, but sites that replace DOM nodes, use virtual scrolling, or load media after custom events need their own readiness steps. See MDN’s img reference and loading property.
4. Chrome Headless command-line screenshots
Chrome’s command line can capture a page directly and set a viewport size:
chrome --headless --window-size=1440,1000 --screenshot=page.png https://example.com
The CLI is useful for a quick capture, but command-line flags do not provide Puppeteer’s page-state waits for a particular selector, font readiness, and per-image decoding. Chrome’s --timeout is a maximum delay before capture, even if loading continues; it is not evidence that the page is ready. For readiness-dependent captures, use the Puppeteer sequence above. See the Chrome Headless overview and CLI reference.
5. Troubleshooting incomplete screenshots
| Symptom | Likely cause | Fix |
|---|---|---|
| Fallback font appears | The screenshot ran before the used web font settled, the font request failed, or the content was inserted after the font wait. | Wait for the app content first, then document.fonts.ready. Check font network responses and ensure the font applies to the rendered element. |
Image is blank despite complete === true |
The request may have failed; complete is not a success check. |
Check naturalWidth > 0, inspect currentSrc, and handle decode rejection. |
| Some lower-page images are missing | Lazy images had not approached the viewport and were never fetched. | Scroll through the page before waiting. For virtualized pages, trigger the application’s own loading behavior. |
networkidle2 times out |
Long polling, analytics, streaming, or other background requests keep the network active. | Navigate with domcontentloaded or load and wait for a specific app-ready state plus the fonts and images you need. |
| Selector wait times out | The selector does not exist on this route, appears only after user action, or the page failed before rendering it. | Verify the selector in the target page, wait for the correct frame or interaction, and inspect navigation errors and response status. |
| Wait completes but the page changes afterward | Hydration, animation, polling, or later DOM updates modify the page after the chosen readiness condition. | Define a stronger app-specific ready marker and, if necessary, disable animation or wait for the exact region to stabilize. |
| Decode wait rejects | The resource may be corrupt, inaccessible, replaced during decode, or otherwise undecodable. | Log the failing URL and decide whether that image is required. Fix its response or treat it as an allowed failure explicitly. |
| CLI screenshot is taken too early | A fixed timeout expired while the page was still loading. | Use Puppeteer state-based waits. A longer CLI timeout only increases the upper bound; it does not verify readiness. |
6. Performance, reliability, and cost
- Keep waits scoped. Waiting for every image on a long page costs time and can fail because of an unrelated third-party resource. For a component screenshot, wait only within that component.
- Bound every wait. A timeout gives the job a clear failure path. Log the selector, font status, failed image URLs, and navigation error so a retry or page fix is actionable.
- Do not mistake quiet for complete. Network idle can add latency and still miss later application work. Use it when it suits the page, then confirm the actual required state.
- Retry selectively. A transient resource error may be worth retrying at the job level, but repeated capture cannot fix a wrong selector, inaccessible image, or never-ending app state. Avoid unbounded retries.
- Use stable capture inputs. Fix viewport, device scale factor, locale, and page state when visual consistency matters. Animations, rotating content, and personalized responses can still make captures differ.
- Account for resource and browser costs. Full-page scrolling and decoding large images consume time and memory. Keep browser jobs bounded and close the browser in a
finallyblock.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server for developers. One GET request returns an image or PDF; see the ScreenshotNeo site and API documentation.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
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', new Uint8Array(await res.arrayBuffer()));
Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents take screenshots. The Free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000. Sign up for 1,000 free screenshots a month, no card required.
FAQ
Does document.fonts.ready load every font declared by the page?
No. It resolves after fonts used by the document and related layout work are ready. An unused font-face may remain unloaded.
Is networkidle2 enough by itself?
No. It can be a useful navigation milestone, but it does not confirm that your app rendered the desired state or that lazy images were fetched and decoded.
Should failed images stop the screenshot job?
That depends on the purpose of the image. Fail when it is essential to the result; otherwise record the broken URL and allow the capture deliberately.
Can I use Chrome’s --timeout instead of Puppeteer waits?
Use it as a delay limit for simple CLI captures, not as a readiness check. For a condition tied to fonts, images, or application state, use a scripted browser wait.


