ScreenshotNeo

BlogHow-to

How to Wait for a Page to Load Fully in Puppeteer

Learn which Puppeteer wait condition fits your page: load events, network idle, selectors, locators, navigation races, timeouts, and reliable screenshots.

By the ScreenshotNeo team1 October 20269 min read

Short answer: Puppeteer considers navigation complete at the load lifecycle event by default. That is enough for ordinary pages, but it does not prove that a client-rendered application has fetched data, finished hydration, or displayed the result your script needs. Choose a synchronization condition that matches the task: a lifecycle event, network quiet, a specific selector, or an application-level readiness signal.

For most reliable automation, start navigation, then wait for the element or state your next action actually depends on. Use network-idle waits when network quiet is part of the requirement, and register navigation waits before clicks that trigger navigation.

What “fully loaded” means in Puppeteer

A browser page can pass several milestones:

Condition What it tells you Typical use
domcontentloaded The HTML has been parsed; subresources may still be loading. Start DOM work as early as possible.
load The document and subresources reached the browser’s load milestone. This is Puppeteer’s default for page.goto(). Ordinary navigation.
networkidle0 No active network connections for the required idle period. Pages that should become completely quiet.
networkidle2 At most two active network connections for the required idle period. Pages with analytics, polling, or other background requests.
Selector or Locator readiness The content or control your workflow needs exists and, when required, is visible and interactable. SPAs, search results, dashboards, and screenshots.

The current WaitForOptions reference documents load as the default waitUntil value and 30 seconds as the default timeout. An array of lifecycle events requires every listed event to fire. No lifecycle event can know whether your application’s business data is ready.

1. Use the default load wait for ordinary pages

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();

await page.goto('https://example.com');
console.log(await page.title());

await browser.close();

This is equivalent to await page.goto(url, { waitUntil: 'load' }). It waits for the navigation lifecycle event, not for every timer, animation, API request, or lazy-loaded component.

2. Select a navigation lifecycle condition

await page.goto(url, { waitUntil: 'domcontentloaded' });
// or
await page.goto(url, { waitUntil: 'networkidle2' });

Use domcontentloaded when your code only needs the parsed document. Use networkidle2 when a short period with no more than two active connections matches the page’s behavior. Puppeteer’s screenshot guide demonstrates networkidle2 before taking a screenshot; that example is not a universal rule.

You can require multiple milestones:

await page.goto(url, {
  waitUntil: ['domcontentloaded', 'load'],
  timeout: 45_000,
});

Arrays are stricter: every named event must occur before navigation resolves. A long-lived connection can make network-idle conditions unsuitable for chat apps, dashboards, analytics-heavy sites, or pages that poll continuously.

3. Wait for the content your script needs

await page.goto('https://example.com/results');
await page.waitForSelector('[data-testid="results"]', { visible: true });
const count = await page.locator('[data-testid="results"]').count();
console.log({ count });

page.waitForSelector() resolves when a matching element is added to the DOM. Set visible: true when hidden markup does not count as ready. Its documented default timeout is 30 seconds. A selector wait is usually a better contract than guessing how long a framework will take.

For a known application condition, expose a readiness marker:

await page.goto('https://app.example.com');
await page.waitForSelector('[data-app-ready="true"]', { visible: true });

If you control the application, set that marker only after hydration and the required data requests finish. You can also wait for a function that observes a browser-side condition:

await page.waitForFunction(() => window.appState?.reportsLoaded === true, {
  timeout: 30_000,
});

4. Use Locators when the next step is an interaction

Puppeteer recommends Locators for selecting and interacting with elements. A Locator waits for the element and relevant interaction preconditions, such as visibility and a usable target.

const search = page.locator('input[name="q"]');
await search.fill('puppeteer');
await page.locator('button[type="submit"]').click();
await page.locator('[data-testid="results"]').wait();

Use waitForSelector when you need a lower-level presence or visibility check, or when you want to inspect an element before interacting with it.

5. Wait for network idle as a separate condition

await page.goto(url);
await page.waitForNetworkIdle();

page.waitForNetworkIdle() resolves after the network meets its quiet condition for at least the configured idle period. The API reference documents a default idle time of 500 ms and a default concurrency of zero. Configure it explicitly when those values matter:

await page.waitForNetworkIdle({
  idleTime: 1_000,
  concurrency: 2,
  timeout: 30_000,
});

Network idle does not mean animations ended, a timer will not change the DOM, or the application will never make another request. Combine it with a selector when both conditions matter:

await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForNetworkIdle({ idleTime: 500, concurrency: 2 });
await page.waitForSelector('[data-testid="chart"]', { visible: true });

6. Avoid the click and navigation race

Register waitForNavigation() before the click. The Page API documents this Promise.all() pattern:

const [response] = await Promise.all([
  page.waitForNavigation({ waitUntil: 'networkidle2' }),
  page.click('a.next-page'),
]);

if (!response) {
  throw new Error('Navigation did not return a response');
}
console.log(response.status());

Waiting after the click can miss a fast navigation and leave your script waiting until it times out. For links that update the current document without a navigation, wait for the resulting selector or use a Locator instead.

7. A complete, reusable helper

import puppeteer from 'puppeteer';

export async function openReadyPage(url, {
  waitUntil = 'load',
  selector,
  visible = true,
  idle = false,
  timeout = 30_000,
} = {}) {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  page.setDefaultTimeout(timeout);

  try {
    const response = await page.goto(url, { waitUntil, timeout });

    if (response && !response.ok()) {
      throw new Error(`HTTP ${response.status()} while loading ${url}`);
    }

    if (idle) {
      await page.waitForNetworkIdle({
        idleTime: 500,
        concurrency: 2,
        timeout,
      });
    }

    if (selector) {
      await page.waitForSelector(selector, { visible, timeout });
    }

    return { browser, page, response };
  } catch (error) {
    await browser.close();
    throw error;
  }
}

const { browser, page } = await openReadyPage(
  'https://example.com/dashboard',
  {
    waitUntil: 'domcontentloaded',
    selector: '[data-testid="dashboard"]',
    idle: true,
    timeout: 45_000,
  },
);

await page.screenshot({ path: 'dashboard.png', fullPage: true });
await browser.close();

Inspect the response because headless shell mode may return without throwing for HTTP errors such as 404 or 500. A successful navigation promise is not automatically a successful HTTP response.

Choosing between load, network idle, and a selector

Your requirement Recommended wait Reason
Read a traditional document page.goto(url) Uses the documented load default.
Begin work after HTML parsing waitUntil: 'domcontentloaded' Earlier milestone; subresources may still be pending.
Capture after a quiet network networkidle2 or waitForNetworkIdle() Expresses a network-quiet requirement.
Show search results or a dashboard waitForSelector() or a Locator Targets the actual content needed.
Click a control that navigates Promise.all([waitForNavigation(), click()]) Prevents a navigation race.

Timeouts, hidden elements, and failure handling

  • Navigation timeout: WaitForOptions documents 30,000 ms by default. Set a task-appropriate value or timeout: 0 to disable the timeout, with care.
  • Selector timeout: the default is 30 seconds. Increase it for slow environments, but first verify that the selector represents a real readiness condition.
  • Hidden waits: a hidden selector wait can resolve with null when the selector never appears. Use visible: true when visibility is part of correctness.
  • Never-ending requests: analytics, WebSockets, polling, and ads can prevent networkidle0. Prefer networkidle2, a bounded waitForNetworkIdle(), or a selector.
  • Future work: network idle does not stop animations or prevent later API calls. Wait for a stable application marker if visual or data stability matters.

Common errors and fixes

Error or symptom Likely cause Fix
Navigation timeout of 30000 ms exceeded The selected lifecycle event never occurred, or the server is slow. Check the URL and response, increase the timeout, or use an earlier event plus a targeted selector.
Waiting for selector ... failed The selector is wrong, content is inside a frame, or the app failed to render. Inspect the DOM, verify the frame, capture console and page errors, and choose a stable test ID.
networkidle0 never resolves Polling, WebSockets, analytics, or another persistent request. Use networkidle2 or wait for the required element.
Screenshot is blank or missing data The screenshot ran after load but before hydration or data rendering. Wait for the visible result selector or an application readiness flag.
Click appears to do nothing The element is present but not interactable, covered, disabled, or inside an iframe. Use a Locator, wait for visibility, handle the correct frame, and inspect overlays.
Navigation returned for a 404 or 500 HTTP status was not checked. Read response.status() and fail explicitly for unwanted status codes.

Debugging a page that never becomes ready

page.on('console', message => console.log('console:', message.text()));
page.on('pageerror', error => console.error('page error:', error));
page.on('requestfailed', request =>
  console.error('request failed:', request.url(), request.failure()),
);

const response = await page.goto(url, {
  waitUntil: 'domcontentloaded',
  timeout: 30_000,
});
console.log('status:', response?.status());
console.log('url:', page.url());
await page.screenshot({ path: 'debug.png', fullPage: true });

Use a short diagnostic run to determine whether the problem is navigation, an HTTP error, a failed asset, a JavaScript exception, a wrong selector, or an application that intentionally keeps connections open.

Performance, reliability, and cost considerations

  • Choose the earliest correct condition. Waiting for load or domcontentloaded is faster than waiting for global network idle when your task does not need every request to finish.
  • Prefer deterministic signals. A specific visible result is usually more stable than a fixed sleep and less prone to waiting on unrelated traffic.
  • Bound every wait. Explicit timeouts make failures observable and prevent a worker from being held forever.
  • Reuse browser processes carefully. Reusing a browser and creating new pages can reduce launch overhead, but close pages and contexts so cookies, memory, and listeners do not leak between jobs.
  • Do not treat idle as visual stability. Fonts, animations, lazy images, and layout shifts can continue after the network is quiet. Wait for the element and, when necessary, disable or finish animations in your test setup.
  • Inspect status and failure events. A resolved navigation promise alone does not establish that the requested page is usable.

Or skip the browser setup

If your goal is a clean screenshot rather than browser automation, ScreenshotNeo provides a single GET request that returns PNG, JPEG, WebP, or PDF. It accepts cookie and consent banners like a visitor, then removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture. Each step can be turned off.

Only clean shots are billed. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and the response reports the result with X-Page-Verdict and X-Billed headers. ScreenshotNeo also has an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

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 failed: ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', data));

See the ScreenshotNeo API documentation for the full option set, including full-page capture, CSS selectors, dark mode, device presets, custom viewports, retina scale, PDF settings, custom CSS and JavaScript, click and wait actions, request blocking, headers, cookies, user agents, timezone, geolocation, transparent backgrounds, resizing, caching, signed links, asynchronous jobs, webhooks, bulk capture, usage, and the OpenAPI specification.

There is a free plan with 1,000 screenshots per month and no card. Paid plans start at $5 for 3,000 shots; every feature is available on every plan. Create a free ScreenshotNeo account.

FAQ

Does page.goto() wait for JavaScript-rendered content?

It waits for the selected navigation lifecycle event. JavaScript may render content afterward, so wait for the resulting selector or application readiness condition.

Should I always use networkidle0?

No. Persistent connections and polling can prevent it from resolving. Use it only when zero active connections is truly part of the requirement.

Is a fixed setTimeout() a reliable solution?

Usually no. A fixed delay can be too short on a slow run and waste time on a fast run. Prefer a selector, Locator, readiness flag, or bounded network-idle wait.

When should I use a Locator instead of waitForSelector()?

Use a Locator for modern interaction flows because it waits for the element and interaction state. Use waitForSelector() for explicit low-level presence or visibility checks.

Can Puppeteer prove that every request has finished?

No. Network-idle APIs report a period that meets their connection threshold. They do not prove that future work, timers, animations, or application state changes cannot occur.