How to Fix Puppeteer Timeouts While Waiting for an Event
Diagnose Puppeteer timeouts by matching the wait to the real condition, fixing selectors and event order, and handling frames, navigation, and network events.
A Puppeteer timeout means the operation did not observe its condition before the deadline. The error does not tell you whether the selector is wrong, the page is in the wrong state, a frame changed, navigation was missed, or the chosen wait is watching the wrong kind of event. Fix the timeout by naming the condition, proving it can occur in the current page or frame, and using the Puppeteer wait that observes it.
Puppeteer defines TimeoutError as an error raised when an operation is terminated by a timeout. A timeout is therefore a symptom, not a diagnosis. (TimeoutError reference)
Fast diagnosis checklist
- Write down what should happen: an element appears, becomes visible, disappears, a predicate becomes true, a request or response arrives, or navigation completes.
- Log the current URL, title, and frame tree. Confirm the script is on the page and in the frame where the condition exists.
- Check the exact selector and requested state. A selector can match no nodes, match a hidden node, or target an element that never becomes actionable.
- Register waits before the action that triggers them. For navigation, create the navigation promise before clicking.
- Use a locator for a normal user-like action; use lower-level waits when you need explicit control over state, polling, or timeout.
- Increase a timeout only after the condition is known to be valid and merely slow. A longer timeout cannot make an impossible predicate succeed.
Choose the wait that matches the condition
| What you expect | Use | What to verify |
|---|---|---|
| Element enters the DOM or reaches visible/hidden state | page.waitForSelector(selector, options) |
Selector, state, frame, and whether the state is reachable |
| Click or fill should wait for action preconditions | page.locator(selector).click() or .fill() |
Element is present, visible, enabled, in the viewport, and stable |
| Application state becomes true | page.waitForFunction(predicate, options, ...args) |
Predicate runs in page context and eventually returns a truthy value |
| Matching request or response occurs | page.waitForRequest() or page.waitForResponse() |
URL or predicate matches the actual network event |
| URL changes or a reload occurs | page.waitForNavigation(options) |
Wait is armed before the click, submit, or other trigger |
| Element is inside an iframe | Get the correct Frame, then call its wait |
Frame identity and whether the frame navigated |
Puppeteer documents these waits separately: Page.waitForSelector, page interactions and locators, Frame.waitForFunction, and the Page API.
Complete diagnostic script
Run this small script against the failing URL. It records the URL, title, frames, and a screenshot before the wait, then reports the timeout separately from other failures.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({headless: true});
const page = await browser.newPage();
page.setDefaultTimeout(30_000);
try {
await page.goto('https://example.com', {waitUntil: 'domcontentloaded'});
console.log({
url: page.url(),
title: await page.title(),
frames: page.frames().map(frame => ({url: frame.url(), name: frame.name()}))
});
await page.screenshot({path: 'before-wait.png', fullPage: true});
await page.waitForSelector('#target', {visible: true});
} catch (error) {
if (error.name === 'TimeoutError') {
console.error('Timed out while waiting:', error.message);
console.error('URL at failure:', page.url());
await page.screenshot({path: 'timeout.png', fullPage: true});
} else {
throw error;
}
} finally {
await browser.close();
}
Fixing selector and visibility timeouts
waitForSelector waits for a selector to appear. It can also require visibility or hidden state. Its default timeout is 30 seconds; pass a different timeout, set timeout: 0 to disable it, or change the page default. The official documentation states: “If the selector doesn’t appear after the timeout milliseconds of waiting, the function will throw.” (API reference)
// Presence in the DOM (hidden elements count)
await page.waitForSelector('[data-testid="results"]');
// Must be visible
await page.waitForSelector('[data-testid="results"]', {
visible: true,
timeout: 15_000
});
// Must become hidden or be removed
await page.waitForSelector('.loading-spinner', {
hidden: true,
timeout: 20_000
});
Prove the selector is correct
const selector = '[data-testid="results"]';
console.log('matches:', await page.$$eval(selector, nodes => nodes.length));
console.log('html:', (await page.content()).slice(0, 2000));
Inspect the rendered DOM rather than the original response HTML. Client-side applications may add different attributes, render a different route, or replace the node after hydration. Prefer stable attributes such as data-testid or an accessible role over generated class names.
Do not ask for an impossible state
A selector that is present but permanently hidden will time out with visible: true. A spinner that is never rendered will time out with hidden: true only if Puppeteer is waiting for it to first appear under the chosen semantics. Check the application state and choose the state that actually represents readiness.
Use locators for actions
For a normal click or fill, Puppeteer recommends locators. They wait for action preconditions such as presence, visibility, enabled state, viewport position, and a stable bounding box. This avoids a separate selector wait that can pass just before the element moves or becomes covered.
const submit = page.locator('button[type="submit"]');
await submit.click();
const email = page.locator('input[name="email"]');
await email.fill('dev@example.com');
Set a per-locator timeout only when the action has a known, legitimate delay:
await page.locator('[data-testid="slow-result"]')
.setTimeout(60_000)
.click();
If you need to inspect intermediate states, use waitForSelector or a custom predicate instead of adding arbitrary sleeps.
Wait for application state with waitForFunction
Use waitForFunction when readiness is not represented by one selector: a loading flag changes, a count reaches a value, or a global object is populated. The predicate runs in the page context and must eventually return a truthy value. (Frame.waitForFunction)
await page.waitForFunction(
() => window.app && window.app.status === 'ready',
{timeout: 30_000, polling: 'mutation'}
);
Use interval polling when the value changes without a DOM mutation:
await page.waitForFunction(
expected => document.querySelectorAll('.result').length >= expected,
{timeout: 30_000, polling: 100},
20
);
Keep predicates deterministic and cheap. A predicate that references a variable unavailable in the page context, checks the wrong global, or waits for a state that the application never sets will run until timeout.
Fix navigation races
If a click causes navigation, arm waitForNavigation before clicking. Start both promises together so the navigation cannot win the race before the wait is registered.
const [response] = await Promise.all([
page.waitForNavigation({waitUntil: 'networkidle2'}),
page.click('a[href="/dashboard"]'),
]);
console.log('landed at', page.url(), 'status', response?.status());
Do not use “click, then start waiting” for a navigation-triggering action. The new document may begin and finish before the second statement subscribes. Some single-page applications change the URL without a traditional navigation; in that case wait for a route-specific selector or application state instead.
Wait for requests and responses
When the real event is network activity, wait for that event rather than guessing with a selector or delay. Register the request or response wait before the action that sends it.
const responsePromise = page.waitForResponse(response =>
response.url().includes('/api/products') &&
response.request().method() === 'GET' &&
response.status() === 200
);
await page.click('[data-testid="load-products"]');
const response = await responsePromise;
const data = await response.json();
const requestPromise = page.waitForRequest(request =>
request.url().includes('/api/save') && request.method() === 'POST'
);
await page.click('button[type="submit"]');
const request = await requestPromise;
console.log(request.postData());
Match the actual URL, method, status, and request type. A service worker, cache, GraphQL endpoint, or redirect may mean the URL differs from what you assumed.
Handle frames and detached elements
Content inside an iframe is not part of the main page DOM. Find the frame, then wait within that frame:
const frame = page.frames().find(f => f.url().includes('/checkout'));
if (!frame) throw new Error('Checkout frame not found');
await frame.waitForSelector('input[name="cardnumber"]', {visible: true});
A frame-level selector wait is suitable when navigation replaces the document in that frame. An ElementHandle-level wait is scoped to its current element and does not cross navigation; it also fails after the element is detached. Prefer a fresh page or frame locator after navigation:
// Avoid retaining this handle across a navigation
const handle = await page.$('#target');
await page.reload();
// handle may now refer to a detached element
// Re-query after navigation
await page.waitForSelector('#target', {visible: true});
const freshHandle = await page.$('#target');
try {
// use freshHandle
} finally {
await freshHandle?.dispose();
}
See the Frame.waitForSelector and ElementHandle.waitForSelector references for their different scopes and lifetimes.
Timeout configuration without hiding bugs
// One operation
await page.waitForSelector('#report', {timeout: 45_000});
// All waits on this page
page.setDefaultTimeout(45_000);
// Navigation timeout is separate
page.setDefaultNavigationTimeout(60_000);
// Disable a selector timeout only when you have an external cancellation plan
await page.waitForSelector('#never-timeout', {timeout: 0});
Use a timeout that reflects the slowest legitimate environment. Keep a finite deadline in CI and production so stuck pages release resources. If a wait is intentionally unbounded, add your own cancellation or job deadline.
Common timeout errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
Waiting for selector ... failed |
Wrong selector, wrong route, or element is in a frame | Log page.url(), inspect page.content(), enumerate frames, and query the rendered DOM |
Selector matches but visible: true times out |
Node is hidden, covered, zero-sized, or not yet displayed | Check computed visibility and layout; wait for the application state that reveals it |
| Click followed by navigation timeout | Wait was registered after the click, or click does not navigate | Use Promise.all; for an SPA, wait for route state or a post-click selector |
| Request wait never resolves | URL predicate, method, status, or trigger is wrong | Log requests and responses, then match the observed endpoint exactly |
waitForFunction always times out |
Predicate runs in the wrong context or never becomes truthy | Test the expression in DevTools, pass arguments explicitly, and verify the state transition |
| Element handle is detached | React/Vue rerender or navigation replaced the node | Discard the handle and query the page or frame again |
| Works locally but fails in CI | Different viewport, authentication, timing, URL, or browser version | Record environment details, save screenshots and HTML on failure, and use condition-based waits |
| Timeout after a consent or bot screen | The expected page never loaded | Detect the interstitial, authenticate or handle consent, then wait for the real page condition |
Reliability and performance practices
- Prefer one meaningful condition over chains of arbitrary
waitForTimeoutcalls. Fixed sleeps slow successful runs and still fail when the page is slower. - Use
domcontentloadedor a specific post-load condition when full network idle is not required. Third-party analytics and long polling can prevent network-idle waits from completing. - Keep selectors stable and scoped. A narrow selector reduces accidental matches and makes failures easier to interpret.
- Capture the URL, console errors, failed requests, HTML, and a screenshot when a timeout occurs. These artifacts distinguish a bad wait from a failed page load.
- Close pages and browsers in
finallyblocks. A timed-out operation should not leak browser processes. - Use finite operation and navigation deadlines in workers. Retry only idempotent operations and recreate stale pages after navigation or browser crashes.
Minimal reusable helper
export async function waitForVisible(page, selector, timeout = 30_000) {
try {
return await page.waitForSelector(selector, {visible: true, timeout});
} catch (error) {
if (error.name !== 'TimeoutError') throw error;
const details = {
selector,
url: page.url(),
title: await page.title().catch(() => ''),
frames: page.frames().map(frame => frame.url()),
};
console.error('Puppeteer wait failed', details);
await page.screenshot({path: 'wait-failure.png', fullPage: true}).catch(() => {});
throw error;
}
}
Or skip the browser setup
If your goal is to obtain a clean screenshot rather than debug a browser workflow, ScreenshotNeo provides a single GET request for a PNG, JPEG, WebP, or PDF. It accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and each response identifies the result with X-Page-Verdict and X-Billed headers.
See the ScreenshotNeo API documentation for all options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo also offers an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. One thousand screenshots per month are free with no card; paid plans start at $5 for 3,000 screenshots. Create a free ScreenshotNeo account.
FAQ
Does increasing the timeout fix Puppeteer?
Only when the condition is correct and legitimately slow. It cannot fix a wrong selector, wrong frame, missed navigation event, or predicate that never becomes true.
Should I use waitForTimeout?
Use it only for a deliberate short pause that has no observable condition. Prefer selector, function, request, response, or navigation waits for readiness.
Why does a selector wait work before reload but fail afterward?
Reloading replaces the document. Re-query the page or frame after navigation instead of reusing an old ElementHandle.
What if the page is blocked by a CAPTCHA?
Automation cannot make a blocked page satisfy its normal selector condition. Detect the block explicitly, use an authorized access path, or capture a page that is available to your workflow.
Which Puppeteer version should I read?
Match the documentation to the package installed in your project. The official pages used here were labeled across Puppeteer 25.10.0 to 25.12.0, and older versions can differ.


