"Wait until the page is loaded" sounds like one thing and is several. A page can have its HTML parsed, its images downloaded, its web fonts still arriving, its data still coming from an API, and an analytics connection that never closes — all at once. Puppeteer's waitUntil options each wait for a different one of those moments, and choosing the wrong one gives you either a half-empty screenshot or a timeout. We measured what each one catches on the same test page.
How we measured. Puppeteer 25.7.0 in the official ghcr.io/puppeteer/puppeteer image, on 2026-11-08, against a local test page built to have every common delay: a web font served after 1.5 s, two images after 0.4 s, a lazy image far below the fold, a CSS animation, and data fetched by a script that arrives after about 2.1 s. A second version of the page also opened a server-sent events connection that never closes, as chat widgets and live dashboards do. Each strategy ran five times; the table shows the median.
What each strategy caught
| Strategy | Time | Data from the script | Web font | Images (of 3) |
|---|---|---|---|---|
goto waitUntil: 'domcontentloaded' |
24 ms | no | no | 0 |
goto waitUntil: 'networkidle2' |
941 ms | no | no | 2 |
goto waitUntil: 'load' |
1,572 ms | no | yes | 2 |
load + document.fonts.ready |
1,555 ms | no | yes | 2 |
goto waitUntil: 'networkidle0' |
2,645 ms | yes | yes | 2 |
load + waitForSelector('#data') |
2,123 ms | yes | yes | 2 |
domcontentloaded + waitForSelector + fonts.ready |
2,180 ms | yes | yes | 2 |
With the connection that never closes:
| Strategy | Time | Result |
|---|---|---|
networkidle0 |
15,000 ms | timed out |
networkidle2 |
2,061 ms | font, but no data |
load + waitForSelector('#data') |
2,126 ms | everything |
Four findings:
domcontentloadedis far too early for a screenshot. 24 ms in, nothing but the HTML text is there.loadwaited for the web font but not for the data. Fonts and images are part of the page's own load; afetch()started by a script is not.networkidle2returned before both the font and the data on the first page. "At most two connections open" was true while the font and the API request were the two.networkidle0is the only option that caught everything without a selector — and the only one that hung. One open connection, and it never returns.
No strategy loaded the lazy image: below the fold, the browser never requested it. That needs scrolling, not waiting.
What the options mean
load and domcontentloaded wait for the browser events of the same names; Puppeteer's documentation defines the two network options:
load— the window'sloadevent: the HTML and the resources it references (stylesheets, images, fonts in use).domcontentloaded— the HTML has been parsed; images and fonts may still be loading.networkidle0— "no more than 0 network connections for at least 500 ms".networkidle2— "no more than 2 network connections for at least 500 ms".
(PuppeteerLifeCycleEvent). The network options are heuristics: they guess "done" from silence on the network, which is why they are both too early (two slow requests count as idle) and too late (one endless connection never is).
The reliable pattern: wait for what you need
await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
await page.waitForSelector('#data', { timeout: 10_000 }); // the content that arrives later
await page.evaluate(() => document.fonts.ready); // web fonts, if any are still loading
await page.screenshot({ path: 'page.png' });
waitForSelectoris the most precise wait there is: it returns the moment the element you care about exists. Pick something that appears only when the content is ready — the data table, a product price, a chart's container with a "ready" class — not a placeholder.document.fonts.readyresolves when the fonts the page uses have loaded. In our testloadalready covered the font, but a font requested later (by text inserted after load) is not covered byload.- Timeouts on everything. A selector that never appears should fail in seconds, not hang the job.
If you do not control the page and have no selector to wait for, networkidle2 plus a short fixed pause is a reasonable fallback — but check the result, and expect it to miss content on slow APIs.
Other waits worth knowing
page.waitForNetworkIdle({ idleTime: 500 })— the networkidle0 rule after an action such as a click, not only after navigation.page.waitForFunction(() => window.appReady === true)— when your own app can signal that it has rendered.- A fixed delay (
await new Promise(r => setTimeout(r, 1000))) — the last resort, for animations that must finish.page.waitForTimeoutis gone: in Puppeteer 25.7 it isundefined. - Lazy images: scroll through the page, or set
loading="eager"on them before the shot. Our full-page screenshot guide measured how many load and why. - Animations: freeze them with CSS before the shot, or a spinner is caught mid-turn (visual regression guide).
How our screenshot API waits
If you take screenshots through Shareglint instead of running Puppeteer yourself, the same choices are parameters. Every screenshot first waits for DOMContentLoaded, then for the load event (up to a few seconds), then for the network to be quiet for 500 ms with at most two requests open (up to 2.5 s) — so an endless connection costs a few seconds, not the whole job. On top of that:
| You need | Parameter |
|---|---|
| Content that arrives later | wait_for: a CSS selector to wait for |
| An animation or chart to finish | delay: extra seconds to wait |
| Lazy images in a full-page shot | lazy_load: scrolls through the page first |
| No cookie banner | block_cookie_banners |
curl -X POST "https://shareglint.com/v1/screenshots?wait=true" \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{ "url": "https://example.com/dashboard", "wait_for": "#data", "full_page": true, "lazy_load": true }'
A viewport screenshot uses 1 render of the monthly quota, a full page 2 (screenshot API). For pages behind a login or on localhost, keep using Puppeteer locally with the pattern above.