· Tutorials · By Simon Chiu

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:

  1. Opens a long-poll request (/poll) that the server holds for 10 seconds. This stands in for chat widgets, analytics and live-update connections.
  2. After 300 ms, fetches /api/orders. The server answers after 800 ms, and the page renders 20 rows into #content.
  3. 700 ms after the rows, draws #chart with no network request (like a charting library that animates in), then sets window.__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:

  • load and domcontentloaded are fast and wrong for client-rendered pages. Both returned before the data request even started, so every screenshot showed "Loading…".
  • networkidle2 returned before the data arrived. With the long-poll open (one connection), the page went "almost idle" during the 300 ms pause before fetch('/api/orders'). A short gap between requests is all it takes.
  • networkidle0 was 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 li appeared 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 in this topic