Puppeteer waitUntil: When Is a Page Ready to Screenshot?
Puppeteer waitUntil on a test SPA: load, domcontentloaded, networkidle0, networkidle2, selectors and functions, with timings and a reusable ready helper.
waitUntil decides when page.goto() returns, not when your page is ready. For a screenshot you usually want three separate things: the navigation to finish, the content you care about to render, and the fonts and images inside the frame to paint. This is the pattern that captured a complete page on every run in our tests:
await page.goto(url, { waitUntil: 'load' }); await page.waitForSelector('#chart', { visible: true }); await page.screenshot({ path: 'page.png' });
The rest of this post shows why, with timings from a test SPA, and gives you a reusable waitUntilReady() helper. It is part of our Puppeteer screenshot guide.
Tested with: Puppeteer 25.14.0, Chrome 155.0.8059.79 (the browser Puppeteer downloads), Node.js 22.18.0, macOS on an Apple M1 Pro. Tested on 2026-10-18.
The six options in one table
| Option | Resolves when | Good for | Fails on |
|---|---|---|---|
waitUntil: 'domcontentloaded' |
HTML is parsed, deferred scripts ran | Reading DOM, scraping text | Anything that paints later: images, fonts, client-rendered data |
waitUntil: 'load' (default) |
load event: images, styles, sync scripts done |
Server-rendered pages | Data fetched by JavaScript after load |
waitUntil: 'networkidle2' |
At most 2 connections open for 500 ms | Pages with one or two persistent connections | Data requests that start after a short pause |
waitUntil: 'networkidle0' |
0 connections open for 500 ms | Static pages with no background traffic | Long-polls, streams, beacons, unread response bodies |
page.waitForSelector(sel) |
The element exists (and is visible, with visible: true) |
Client-rendered content you can name | Pages where the element appears before its content |
page.waitForFunction(fn) |
fn returns a truthy value |
Custom "ready" flags, chart libraries | Pages you do not control |
You can pass an array to waitUntil, for example ['load', 'networkidle2']. goto() then resolves when all of them have happened.
What we measured
We built a small single-page app and served it from a local Node.js server, so network timing is the same on every run. The page:
- Opens a long-poll request (
/poll) that the server holds for 10 seconds. This stands in for chat widgets, analytics and live-update connections. - After 300 ms, fetches
/api/orders. The server answers after 800 ms, and the page renders 20 rows into#content. - 700 ms after the rows, draws
#chartwith no network request (like a charting library that animates in), then setswindow.__ready = true.
A capture is complete only if all 20 rows and the chart are in the DOM when the screenshot is taken. Each strategy ran 5 times, each in a fresh browser context; the time covers goto plus the wait plus page.screenshot().
| Strategy | Median | Min to max | Complete |
|---|---|---|---|
domcontentloaded |
81 ms | 57 to 100 ms | 0 of 5 |
load |
107 ms | 77 to 480 ms | 0 of 5 |
networkidle2 |
999 ms | 726 to 1,023 ms | 0 of 5 |
networkidle0 |
12,049 ms | 11,962 to 12,053 ms | 5 of 5 |
load + waitForSelector('#content li') |
1,180 ms | 1,171 to 1,188 ms | 0 of 5 |
load + waitForFunction(() => window.__ready) |
1,897 ms | 1,871 to 1,905 ms | 5 of 5 |
networkidle2 + waitForSelector('#chart') |
1,864 ms | 1,853 to 1,871 ms | 5 of 5 |
What the numbers say:
loadanddomcontentloadedare fast and wrong for client-rendered pages. Both returned before the data request even started, so every screenshot showed "Loading…".networkidle2returned before the data arrived. With the long-poll open (one connection), the page went "almost idle" during the 300 ms pause beforefetch('/api/orders'). A short gap between requests is all it takes.networkidle0was correct but took 12 seconds, because it had to wait for the 10-second long-poll to close. On a real site with a chat widget, it may never resolve.- Waiting for the wrong selector is the same as not waiting.
#content liappeared 700 ms before the chart, so the capture had rows but no chart. Pick the element that renders last. - A selector or function for the last thing to render was correct and fast, at about 1.9 seconds, which is roughly the real render time of the page.
For contrast, we also timed goto() alone on https://example.com (3 runs each, over the internet): domcontentloaded 269 to 445 ms, load 228 to 438 ms, networkidle2 959 to 999 ms, networkidle0 980 to 997 ms. On a static page the network-idle options cost you about half a second of pure waiting and buy nothing.
A trap we found: unread response bodies
The first version of our test page started the long-poll with fetch('/poll').catch(() => {}) and never read the response. With that page, networkidle0 never resolved. It hit the 60-second timeout even though the server answered after 10 seconds.
Puppeteer's requestfinished event never fired for /poll, so the request counted as open. When we changed the page to fetch('/poll').then((r) => r.text()), networkidle0 resolved about 2 seconds after the poll ended. You cannot fix someone else's page, but this explains a class of "networkidle0 times out on one site only" reports: the page is not busy, it just never consumed a response.
Recipes
Server-rendered pages
If the HTML arrives complete from the server (most marketing sites, docs, blogs), load is enough. Add a font check, because load does not wait for fonts that CSS requests late:
await page.goto(url, { waitUntil: 'load' }); await page.evaluate(() => document.fonts.ready);
Client-rendered pages (React, Vue, Svelte)
Wait for the element that renders last and make it visible-only, so an empty placeholder does not count:
await page.goto(url, { waitUntil: 'load' }); await page.waitForSelector('[data-testid="revenue-chart"] svg', { visible: true, timeout: 15_000 });
If a skeleton loader is easier to target than the content, wait for it to go away instead:
await page.waitForSelector('.skeleton', { hidden: true });
Pages you control: a ready flag
When you own the page (an OG card template, an internal dashboard), have it announce readiness. This is the most reliable option because the page knows when its own work is done:
// In your page, after the last render: window.__ready = true; // In Puppeteer: await page.waitForFunction(() => window.__ready === true, { timeout: 10_000 });
Locators
Puppeteer locators retry until the element is attached, visible and stable, so they double as a wait:
const chart = await page.locator('#chart').waitHandle(); await chart.screenshot({ path: 'chart.png' });
See element screenshots in Puppeteer for padding, shadow DOM and transparent backgrounds.
A fixed delay, if you must
page.waitForTimeout() was removed in Puppeteer 22. A fixed pause is a last resort for CSS animations with no end event:
await new Promise((resolve) => setTimeout(resolve, 600));
Prefer turning animations off: await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }]) works on pages that respect the setting.
A reusable waitUntilReady() helper
This helper combines the parts that worked: a content check, a short network quiet that is allowed to time out, fonts, decoded images in the viewport, and two animation frames so the last style change is painted. It is a complete script.
import puppeteer, { TimeoutError } from 'puppeteer'; /** * Wait until a page is ready to screenshot. * readySelector: an element that only exists once the data has rendered. */ export async function waitUntilReady(page, { readySelector, quietMs = 500, maxQuietWaitMs = 3_000 } = {}) { // 1. The content you care about is on screen. if (readySelector) { await page.waitForSelector(readySelector, { visible: true, timeout: 15_000 }); } // 2. Short, bounded network quiet. Two open connections are allowed // (long-polls, analytics); a page that never goes quiet is not an error. try { await page.waitForNetworkIdle({ idleTime: quietMs, concurrency: 2, timeout: maxQuietWaitMs }); } catch (err) { if (!(err instanceof TimeoutError)) throw err; } // 3. Web fonts are loaded and images in the viewport are decoded. await page.evaluate(async () => { await document.fonts.ready; const inView = [...document.images].filter((img) => { const r = img.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight && img.src; }); await Promise.all(inView.map((img) => img.decode().catch(() => {}))); }); // 4. Two animation frames, so the last style change is painted. await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); } const [url = 'https://example.com', readySelector] = process.argv.slice(2); const browser = await puppeteer.launch(); try { const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); const t0 = performance.now(); await page.goto(url, { waitUntil: 'load', timeout: 30_000 }); await waitUntilReady(page, { readySelector }); await page.screenshot({ path: 'ready.png' }); console.log(`ready in ${Math.round(performance.now() - t0)} ms`); } catch (err) { console.error(err); process.exitCode = 1; } finally { await browser.close(); }
Run it with node ready.mjs <url> [selector]. Results on our machine:
| Command | Result |
|---|---|
node ready.mjs http://localhost:4517/spa '#chart' |
complete, 2,392 to 2,414 ms over 3 runs |
node ready.mjs http://localhost:4517/spa (no selector) |
588 ms, screenshot shows "Loading…" |
node ready.mjs https://example.com |
731 ms |
node ready.mjs http://localhost:4517/spa '#nope' |
TimeoutError after 15 s, exit code 1 |
Two things to note. Without a selector, the bounded network quiet is not enough on its own for a client-rendered page, which matches the networkidle2 row above. And the helper takes about 500 ms longer than the bare selector wait, which is the price of the network quiet and the font and image checks. Drop step 2 when you know the page has no late requests.
One more lesson: we first tried h1 as the selector for example.com, and it timed out. The page currently has no <h1>. Check your selector against the live page, not your memory of it.
Timeouts: set three, not one
A single global timeout hides which stage failed, and a bare "Navigation timeout of 30000 ms exceeded" does not say why. Give each stage its own budget so the error tells you what went wrong:
page.setDefaultNavigationTimeout(30_000); // goto await page.waitForSelector('#chart', { timeout: 15_000 }); // content await page.waitForNetworkIdle({ idleTime: 500, timeout: 3_000 }).catch(() => {}); // optional quiet
If you capture many pages in one process, read Puppeteer timeouts and memory leaks next: a stuck wait that never closes its page is the usual start of a memory leak.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Screenshot shows a spinner or "Loading…" | goto resolved on load before client data arrived |
Wait for the element that renders last, with visible: true |
networkidle0 times out on one site |
Long-poll, websocket fallback, analytics beacon, or an unread response body | Use load + selector; if you need quiet, use waitForNetworkIdle with concurrency: 2 and a short timeout you catch |
networkidle2 capture is missing data |
The page paused between requests and looked idle | Add a selector or function wait after it |
| Text renders in a fallback font | Font requested after load |
await page.evaluate(() => document.fonts.ready) |
| Images are blank or half-decoded | Lazy images, or decode not finished | Decode in-view images (helper step 3); for below-the-fold images see the full page guide |
| Chart is mid-animation | Library animates after render | Ready flag set in the animation's end callback, or reduced motion |
TypeError: page.waitForTimeout is not a function |
Removed in Puppeteer 22 | Use a condition, or setTimeout in a promise |
waitForSelector times out but element is on screen |
Element is inside a shadow root or an iframe | Use host >>> .inner for open shadow roots; for iframes, get the frame with page.frames() and wait in it |
| Works locally, times out in CI | Slower CPU, cold cache, more third-party requests | Raise per-stage timeouts and log which stage failed |
The same wait with one API call
RenderScreenshot exposes the same choices as request parameters (see wait options): wait.until accepts load, domcontentloaded or networkidle; wait.for_selector waits for an element with a mode of visible, hidden, attached or detached; wait.delay adds a pause after the condition, and wait.timeout caps the total in seconds (max 60).
import { writeFile } from 'node:fs/promises'; import { Client, TakeOptions } from 'renderscreenshot'; const client = new Client(process.env.RS_API_KEY); const image = await client.take( TakeOptions.url('https://example.com/dashboard') .waitFor('load') .waitForSelector('#chart') .waitForTimeout(30) ); await writeFile('dashboard.png', image);
The browser, timeouts and retries run on our side. Our wait strategies guide covers which option fits which kind of page. You can try it with the 150 free credits each month on the free plan.
Related guides
- Taking screenshots with Puppeteer, the hub for this series
- Playwright wait for page load, the same question with Playwright's auto-waiting
- Puppeteer request blocking, to remove the trackers that keep the network busy
- Scaling Puppeteer screenshots, for running these waits in parallel
Related in this topic
- Puppeteer Screenshots: Tested Recipes for Puppeteer 25 Every Puppeteer screenshot recipe re-run on Puppeteer 25.14 and Chrome 155: viewport, full page, element, clip, waits, formats, auth, blocking and fonts.
- Puppeteer on AWS Lambda with @sparticuz/chromium Run Puppeteer on AWS Lambda with @sparticuz/chromium: version matching, a container-image handler tested in the Lambda emulator, and warm reuse.
- Puppeteer in Docker: A Tested Dockerfile with Fonts A non-root Puppeteer Dockerfile with Chromium, CJK and emoji fonts and dumb-init, plus the sandbox error it hits, measured image sizes and launch times.
- Puppeteer Full Page Screenshot: Limits, Lazy Images, Fixes Puppeteer full page screenshot guide: the 16,384 px limit we measured, captureBeyondViewport, lazy-load scrolling, sticky headers and a tested script.
- Hide Cookie Banners and Chat Widgets in Puppeteer Remove cookie banners, consent iframes, shadow-DOM dialogs and chat widgets from Puppeteer screenshots: a tested module and what each method misses.
- Puppeteer HTML to PDF: page.pdf() Options Tested Generate PDFs with Puppeteer page.pdf(): paper size, margins, header and footer templates, page breaks and print CSS, with measured sizes and timings.