Waiting in Puppeteer: A Practical Guide
Simple examples for waiting on selectors, functions, navigation, network activity, frames, file choosers, and more in Puppeteer.
Most flaky Puppeteer scripts wait for time to pass. Reliable scripts wait for the page state or browser event they actually need.
For example, wait for a result to become visible instead of sleeping for two seconds and hoping it is ready:
await page.waitForSelector(".search-result", { visible: true });
This guide shows the main waiting APIs, when to use each one, and the pattern that avoids race conditions.
A complete inline waiting example
Run npm install puppeteer in your project. Save this as waiting.mjs and run node waiting.mjs; the HTML is included:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({headless: true});
try {
const page = await browser.newPage();
await page.setContent(`<script>
setTimeout(() => {
const result = document.createElement('p');
result.id = 'result'; result.textContent = 'Ready';
document.body.append(result);
}, 100);
</script>`);
const result = await page.waitForSelector('#result', {timeout: 5000});
if (!result) throw new Error('Required result absent');
try {console.log(await result.evaluate(element => element.textContent));}
finally {await result.dispose();}
} finally {await browser.close();}
It prints Ready after the element appears. The focused patterns below assume a live page and the actual application’s controls, URLs and result conditions. Register waits before actions and do not blindly retry side effects.
Replacing old waiting methods? The general-purpose
page.waitFor(),page.waitForTimeout()andpage.waitForXPath()methods have been removed. Their replacements are shown below.
Which wait should you use?
| You need to wait for… | Use… |
|---|---|
| An element to appear, disappear, or become visible | page.waitForSelector() |
| A custom condition inside the page | page.waitForFunction() |
| A click or form submission to navigate | page.waitForNavigation() |
| A particular outgoing request | page.waitForRequest() |
| A particular server response | page.waitForResponse() |
| Network activity to become quiet | page.waitForNetworkIdle() |
| An iframe to appear | page.waitForFrame() |
| A click to open a file chooser | page.waitForFileChooser() |
| A fixed delay | setTimeout() from node:timers/promises |
| An element matched by XPath | page.waitForSelector() with an XPath selector |
For element actions such as clicking and filling inputs, first consider a locator. Locators automatically wait for an element to be present and ready for the action.
Wait for a selector
Use page.waitForSelector() when a specific DOM element is your readiness signal.
const results = await page.waitForSelector(".search-results", {
visible: true,
timeout: 10_000,
});
// Use the element, then release the handle when it is no longer needed.
if (!results) throw new Error("Required result absent");
await results.click();
await results.dispose();
Use visible: true when the element must be displayed, not merely present in the DOM. Use hidden: true to wait for a spinner, modal, or other element to disappear:
await page.waitForSelector(".loading-spinner", { hidden: true });
If your next step is a normal element action, a locator is usually simpler:
await page.locator('button[type="submit"]').click();
If a selector never matches, see No element found for selector, Failed to find element matching selector, and Waiting failed: ms exceeded.
Wait for a function
Use page.waitForFunction() for a condition that cannot be described by one selector. The function runs in the browser and is retried until it returns a truthy value.
const ready = await page.waitForFunction(
minimum => document.querySelectorAll(".search-result").length >= minimum,
{ timeout: 10_000 },
3,
);
await ready.dispose();
Arguments after the options object are passed safely from Node.js into the page function. Avoid closing over Node.js variables because the function runs in a different JavaScript context.
If you configure interval polling, it must be greater than zero; see Cannot poll with non-positive interval.
Wait for navigation
Use page.waitForNavigation() when an action indirectly loads a new document, reloads the current page, or changes the URL through the History API.
Start the wait before the action. Promise.all() makes that ordering explicit and prevents fast navigations from being missed:
const [response] = await Promise.all([
page.waitForNavigation({
waitUntil: "domcontentloaded",
timeout: 30_000,
}),
page.click("a.checkout"),
]);
console.log(response?.status());
waitForNavigation() can return null for same-document/blank transitions. If your task requires a new HTTP document, reject null and non-OK responses; otherwise validate the expected URL/DOM transition explicitly. A later arbitrary response is not a replacement document response.
If the action replaces the document, query elements again afterward instead of keeping old element handles. See Execution context was destroyed, Unknown value for options.waitUntil, and the longer guide to handling navigation errors.
Wait for a request
Use page.waitForRequest() when you need to know that the page sent a particular request. As with navigation, register the wait before triggering the request.
const [request] = await Promise.all([
page.waitForRequest(
request =>
request.url().endsWith("/api/save") &&
request.method() === "POST",
),
page.click("#ajax button"),
]);
console.log(request.postData());
This tells you that the request was sent. It does not tell you whether the server accepted it.
Wait for a response
Use page.waitForResponse() when the server response is the readiness signal. Match narrowly enough that an unrelated request cannot resolve the wait.
const [response] = await Promise.all([
page.waitForResponse(
response =>
response.url().endsWith("/api/save") &&
response.request().method() === "POST" &&
response.status() === 200,
{ timeout: 15_000 },
),
page.click("#ajax button"),
]);
if (!response.ok()) throw new Error(`Save HTTP ${response.status()}`);
await page.waitForFunction(() => document.querySelector("#status").textContent === "Saved");
Prefer this to network idle when one known API call determines whether the page is ready.
Wait for network idle
Use page.waitForNetworkIdle() when you need the whole page to stay quiet for a short period, for example before taking a screenshot of a page that loads several late resources.
await page.waitForNetworkIdle({
idleTime: 500,
timeout: 10_000,
});
Do not use network idle as a universal definition of “done.” Analytics, polling, streaming, and long-lived connections can keep a page busy even when the useful content is ready. A specific selector or response is usually more reliable.
Wait for a frame
Use page.waitForFrame() when an iframe is created asynchronously and you need to work inside it.
const resultFrame = await page.waitForFrame(
frame => frame.name() === "result",
{ timeout: 10_000 },
);
await resultFrame.waitForSelector("#result");
console.log(await resultFrame.$eval("#result", element => element.textContent));
Always query inside the returned frame. A selector on page only searches the main frame. If the iframe is replaced or removed, find the new frame rather than reusing the old handle; see Frame detached.
Wait for a file chooser
Use page.waitForFileChooser() when a page opens a native file picker after a click. The listener must be active before the picker opens.
const [fileChooser] = await Promise.all([
page.waitForFileChooser(),
page.click("button.upload"),
]);
await fileChooser.accept(["/absolute/path/to/report.pdf"]);
For a plain <input type="file">, uploadFile() on its element handle may be more direct. waitForFileChooser() is useful when a separate button or script triggers the chooser. A chooser can be handled only once; see Cannot accept FileChooser which is already handled and Cannot cancel FileChooser which is already handled.
Why page.waitForTimeout is not a function
Fixed delays are occasionally useful for debugging, rate limiting, or waiting on something Puppeteer cannot observe. Use the Node.js promise-based timer in current Puppeteer code:
import { setTimeout as delay } from "node:timers/promises";
await delay(500);
The old page.waitForTimeout() method has been removed. A fixed sleep should normally be the last choice because it is either longer than necessary or too short on a slow run.
Replacing every removed call with a sleep preserves the old flakiness; migrate to the actual selector/function/event signal when one exists.
Choose waitUntil deliberately
For supported CDP navigation, choose load, domcontentloaded, networkidle0 or networkidle2, or a documented combination. networkidle2 is not declared deprecated merely because a keyword asks that question. These lifecycle signals do not prove application success; follow them with the relevant content check. Unsupported names cause waitUntil validation errors.
Wait for XPath
The old page.waitForXPath() method has also been removed. Pass Puppeteer’s XPath selector syntax to waitForSelector() instead:
const continueButton = await page.waitForSelector(
'::-p-xpath(//button[normalize-space()="Continue"])',
{ visible: true },
);
if (!continueButton) throw new Error("Continue button absent");
await continueButton.click();
await continueButton.dispose();
Prefer a stable CSS or ARIA selector when one is available. XPath is useful when the relationship between elements matters and the page does not expose reliable attributes.
Replace the old page.waitFor() overloads
The legacy page.waitFor() changed behavior based on the type of its first argument. Migrate each form to the explicit API:
// Old: await page.waitFor(".result");
await page.waitForSelector(".result");
// Old: await page.waitFor(() => window.appReady);
await page.waitForFunction(() => window.appReady);
// Old: await page.waitFor(500);
await delay(500);
The explicit methods are easier to read and avoid the old ambiguity between CSS and XPath strings.
Timeouts and failures
Most Puppeteer waits time out rather than hanging forever. Set defaults once, then override them only where a particular operation genuinely needs more or less time:
page.setDefaultTimeout(10_000);
page.setDefaultNavigationTimeout(30_000);
Increasing a timeout does not fix a condition that can never become true. First check the selector, frame, URL predicate, response status, and whether the action that should trigger the event actually ran.
For diagnosis and recovery examples, see:
- Timed out after waiting ms
- Waiting failed: ms exceeded
- No element found for selector
- Execution context was destroyed
- Frame detached
- Unknown value for
options.waitUntil
The central rule is simple: start event-based waits before the action that can trigger them, and wait for the narrowest observable condition that proves your next step is safe.