Waiting for things in BAP
Most timeouts in automation come from waiting for the wrong thing. BAP has five explicit waits plus several implicit ones, and picking correctly is usually the difference between a flaky script and a reliable one. This guide covers each wait, what it actually observes, and which waits you don't need to write.
- A Browserless API token from your account dashboard
- The BAP SDK installed and connecting, from the BAP Quickstart
Pick a wait
| You're waiting for | Use |
|---|---|
| An element to appear, or disappear | waitForSelector() |
| A navigation you just triggered to finish | waitForNavigation() |
| The page to stop making requests | waitForNetworkIdle() |
| Some JavaScript condition to become true | waitForFunction() |
| A specific request or response | waitForRequest() / waitForResponse() |
| A page event to fire once | waitForEvent() |
| A fixed amount of time | waitForTimeout(), and almost never |
Every wait accepts a timeout that overrides the 30-second default for that call. When one fires, you get a TimeoutError, covered in error handling.
Waits you don't need to write
Interaction and extraction methods already wait for their selector. click(), type(), text(), html(), and mapSelector() all default to wait: true, which means an explicit waitForSelector() before them is redundant:
- TypeScript
- Python
// Redundant: click() already waits for the selector.
await page.waitForSelector("#submit");
await page.click("#submit");
// Enough on its own.
await page.click("#submit");
# Redundant: click() already waits for the selector.
page.wait_for_selector("#submit")
page.click("#submit")
# Enough on its own.
page.click("#submit")
Reach for an explicit waitForSelector() when you need to wait for something you aren't about to act on, such as a loading spinner disappearing or a result count rendering before you extract.
Wait for an element
waitForSelector() takes visible and hidden booleans rather than Playwright's state enum. hidden is the one worth knowing: it's how you wait for a spinner or overlay to go away.
- TypeScript
- Python
// Present in the DOM.
await page.waitForSelector(".results");
// Present and actually rendered.
await page.waitForSelector(".results", { visible: true });
// Gone, which is how you wait out a loading overlay.
await page.waitForSelector(".spinner", { hidden: true, timeout: 15_000 });
# Present in the DOM.
page.wait_for_selector(".results")
# Present and actually rendered.
page.wait_for_selector(".results", visible=True)
# Gone, which is how you wait out a loading overlay.
page.wait_for_selector(".spinner", hidden=True, timeout=15000)
Wait for navigation
goto() waits for the page to load on its own. waitForNavigation() is for navigations you trigger some other way, usually a click, and it must be in flight before the navigation completes or you'll wait for one that already happened. In TypeScript that means starting the wait without awaiting it, then awaiting after the click. The Python sync API can't express that ordering, so wait for an element unique to the destination instead, which sidesteps the race in both languages.
Both take waitUntil, which decides what "loaded" means: commit, load, domContentLoaded, firstContentfulPaint, firstMeaningfulPaint, interactiveTime, or networkIdle. domContentLoaded returns soonest and is enough when your target is in the initial HTML; networkIdle waits longest and suits pages that assemble themselves with XHR.
- TypeScript
- Python
// Start the wait first, so a fast navigation can't finish before it begins.
const navigated = page.waitForNavigation({ waitUntil: "networkIdle" });
await page.click("#login");
await navigated;
console.log(await page.url());
// Cheaper when the content you want is in the first response.
await page.goto("https://example.com", { waitUntil: "domContentLoaded" });
page.click("#login")
# The sync API blocks, so the wait can't be started before the click.
# Waiting for an element unique to the destination avoids the race entirely.
page.wait_for_selector("#dashboard")
print(page.url())
# Cheaper when the content you want is in the first response.
page.goto("https://example.com", wait_until="domContentLoaded")
Wait for the network to settle
waitForNetworkIdle() returns once the page stops making requests. Two options shape it: idleTime (default 500 milliseconds) is how long quiet counts as idle, and concurrency (default 0) is how many in-flight requests you'll tolerate while still calling it idle.
Raise concurrency on pages that keep a long-poll or analytics beacon open forever, since with the default those pages never reach idle and you wait out the full timeout instead.
- TypeScript
- Python
// Strict: no requests in flight for half a second.
await page.waitForNetworkIdle();
// Tolerant: ignore up to two long-lived connections.
await page.waitForNetworkIdle({ concurrency: 2, idleTime: 1000 });
# Strict: no requests in flight for half a second.
page.wait_for_network_idle()
# Tolerant: ignore up to two long-lived connections.
page.wait_for_network_idle(concurrency=2, idle_time=1000)
Wait for a JavaScript condition
waitForFunction() evaluates an expression in the page until it returns something truthy. It's the escape hatch for conditions no selector expresses, such as a global that a third-party script sets, or a computed count.
Polling defaults to every 100 milliseconds. pollingType swaps that for browser-driven modes: raf re-evaluates each animation frame, and mutation re-evaluates on DOM changes, which is cheaper than fast interval polling on a busy page.
- TypeScript
- Python
// Wait for a third-party script to signal readiness.
await page.waitForFunction("window.appReady === true");
// Re-evaluate on DOM changes instead of on a timer.
await page.waitForFunction(
"document.querySelectorAll('.row').length > 20",
{ pollingType: "mutation", timeout: 20_000 },
);
# Wait for a third-party script to signal readiness.
page.wait_for_function("window.appReady === true")
# Re-evaluate on DOM changes instead of on a timer.
page.wait_for_function(
"document.querySelectorAll('.row').length > 20",
polling_type="mutation",
timeout=20000,
)
Pass completeOnPromiseResolution: true when your expression returns a promise and you want the wait to end once it settles, whatever it resolves to.
Wait for an event
waitForEvent() resolves the next time a page event fires, which is the one-shot counterpart to the listeners in page events. watchEvent() starts watching without blocking, so you can trigger the action first and collect the event afterward.
- TypeScript
- Python
// Block until the next console message.
const event = await page.waitForEvent("console", { timeout: 10_000 });
# Block until the next console message.
event = page.wait_for_event("console", timeout=10000)
Why not a fixed sleep
waitForTimeout() exists and takes a duration in milliseconds, but it's the wrong tool nearly every time: too short and the script is flaky, too long and every run pays for the worst case. Worse, it holds a billed session open doing nothing. Wait on the condition you actually care about instead. The honest use for a fixed sleep is pacing something deliberately, such as spacing interactions on a site that rate-limits by timing.
FAQ & Troubleshooting
My waitForNetworkIdle() always times out
The page keeps a connection open, usually analytics, a long-poll, or a websocket, so in-flight requests never reach zero. Raise concurrency to tolerate those, or wait for a selector that proves your content arrived instead.
waitForNavigation() resolved immediately, or timed out for no reason
It has to be waiting before the navigation completes. If you await the click first and then call it, a fast navigation is already done and you're waiting for the next one. In TypeScript, start the wait without awaiting, click, then await it. In Python's sync API, where that ordering isn't expressible, wait for an element unique to the destination instead.
Should I use waitForSelector() before every click?
No. click(), type(), and the extraction methods default to wait: true and already wait for their selector. Add an explicit wait only for things you aren't about to act on.
waitForFunction() never resolves even though the condition is true in DevTools
The expression runs in the page, so it can't see variables from your script's scope, and it must return a value rather than only having a side effect. Check that it's an expression, not a statement, and that the global you're testing exists on window at that point.