How to Fix Playwright page.waitForEvent Failures
Fix Playwright page.waitForEvent timeouts and hangs by arming waits before actions, checking event scope, predicates, lifecycles, dialogs, and timeout types.
Direct fix: create the page.waitForEvent() promise before the action that should emit the event, perform the action, then await the promise. If it still times out, verify the event name and source object, inspect any predicate, keep the page or browser context alive, and identify which timeout actually failed.
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Open window' }).click();
const popup = await popupPromise;
This ordering prevents a race in which the popup, download, or other event fires before the listener is registered. It is the pattern shown in the official Page API and Pages guide.
How page.waitForEvent works
page.waitForEvent(event, options?) waits for a named event on a Playwright Page and resolves with that event’s data. The options can include a predicate and a timeout. If the page closes before the event occurs, the wait throws instead of resolving. See the API reference for the current signature and event list.
| Situation | Wait on | Typical event |
|---|---|---|
| A page opens from the current page | page |
'popup' |
| A download starts | page |
'download' |
| Any new page opens in a context | browserContext |
'page' |
1. Register the wait before the trigger
Do not await the event before clicking the control that emits it. That serializes the test in the wrong order:
// Wrong: this can wait forever because the click has not happened yet.
const popup = await page.waitForEvent('popup');
await page.getByRole('button', { name: 'Open window' }).click();
Use a promise without awaiting it, run the action, and await the promise afterward:
import { test, expect } from '@playwright/test';
test('opens the account popup', async ({ page }) => {
await page.goto('https://example.com');
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Open window' }).click();
const popup = await popupPromise;
await popup.waitForLoadState();
await expect(popup).toHaveURL(/account/);
});
The same rule applies to downloads:
const downloadPromise = page.waitForEvent('download');
await page.getByText('Download file').click();
const download = await downloadPromise;
await download.saveAs('artifacts/report.pdf');
2. Confirm the event and its scope
Popup versus new page
page.waitForEvent('popup') is for a popup opened by that page. If any page can be created in the browser context, wait on the context instead:
const pagePromise = context.waitForEvent('page');
await page.getByRole('link', { name: 'Open report' }).click();
const reportPage = await pagePromise;
A popup event may become available after navigation to its initial URL has reached the point where its network response starts loading. If you need to observe the request itself, use the context routing or request events described in the Page API rather than treating popup as a request event.
Download versus response or request
Use 'download' when the browser starts an attachment download. Use request or response events when your assertion concerns network traffic. Waiting for the wrong event leaves a correctly written promise pending.
3. Inspect predicates and timeout values
A predicate filters event data. The wait resolves only when the predicate returns a truthy result, so a predicate can make a real event look absent.
const popupPromise = page.waitForEvent('popup', {
predicate: popup => popup.url().includes('/checkout'),
timeout: 15_000
});
await page.getByRole('button', { name: 'Checkout' }).click();
const checkout = await popupPromise;
Debug predicates by first waiting without one, or by logging the value being tested:
const popupPromise = page.waitForEvent('popup', {
predicate: popup => {
console.log('popup URL:', popup.url());
return popup.url().includes('/checkout');
},
timeout: 15_000
});
Playwright Test has separate test, assertion, action, navigation, fixture, and global timeout scopes. An error from the click, an assertion, or the overall test is different from a waitForEvent timeout. Read the error type and call log before changing configuration. The Timeouts guide lists these scopes.
Increase an event timeout only when the correct event is expected after a legitimate delay. A longer timeout cannot fix a wrong event name, an event on the wrong object, a predicate that rejects every event, an action that emits nothing, or a page that closes.
4. Check page and browser-context lifecycle
The Page API documents that a pending event wait errors if its page closes before the event fires. Context waits have the same lifecycle concern. Look for code that closes a page, context, or browser in a fixture, finally block, navigation flow, or error handler.
page.on('close', () => console.log('page closed'));
context.on('close', () => console.log('context closed'));
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Open window' }).click();
const popup = await popupPromise;
Keep the object that owns the event alive until the wait resolves. If the application intentionally closes the source page, wait on the context or redesign the flow so the event is observed before cleanup.
5. Resolve JavaScript dialogs that block actions
Without a dialog listener, Playwright automatically dismisses JavaScript alert, confirm, prompt, and beforeunload dialogs. Once you register a page.on('dialog') or context dialog handler, the handler must call accept() or dismiss(). Otherwise the action that opened the dialog can stall and the event wait may never be reached.
page.on('dialog', async dialog => {
console.log(dialog.type(), dialog.message());
await dialog.accept();
});
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Open window' }).click();
const popup = await popupPromise;
Install the handler before the action, and remove or narrow broad handlers if they interfere with other tests.
6. Separate actionability failures from event failures
Locator actions auto-wait for uniqueness, visibility, stability, pointer-event reception, and enabled state. If those checks fail, the click itself raises a timeout. That is not the same as a successful click followed by an event wait that times out. Use the call log to identify which operation failed; the auto-waiting guide explains the checks.
| Symptom | Likely inspection | Next step |
|---|---|---|
| Event wait times out | Event name, source, trigger, predicate, event timeout | Arm the correct wait before the trigger and inspect predicate output. |
| Error says page or context closed | Lifecycle and cleanup | Keep the owner alive or correct the flow that closes it. |
| Click hangs or times out | Dialog handler and actionability call log | Accept or dismiss registered dialogs; fix locator/actionability issues. |
| Broader test timeout | Test, assertion, action, navigation, fixture, or global scope | Identify the reported timeout class before changing a value. |
7. A systematic debugging checklist
- Read the full error and call log. Identify whether the failure came from the action, event wait, assertion, or test timeout.
- Confirm the event is emitted by the operation you are performing.
- Confirm the wait is attached to the correct
PageorBrowserContext. - Create the wait promise before the triggering action.
- Temporarily remove the predicate and log the event data.
- Check for page or context closure listeners and fixture cleanup.
- Check registered dialog handlers and ensure every handler resolves the dialog.
- Use tracing or console logging around the action and wait to find where execution stops.
- Only then adjust the event timeout for a known, legitimate delay.
Reliable patterns for common events
Popup with a URL assertion
const popupPromise = page.waitForEvent('popup', { timeout: 10_000 });
await page.getByRole('link', { name: 'Terms' }).click();
const popup = await popupPromise;
await popup.waitForLoadState();
if (!popup.url().includes('/terms')) {
throw new Error(`Unexpected popup URL: ${popup.url()}`);
}
Download with a deterministic path
const downloadPromise = page.waitForEvent('download', { timeout: 15_000 });
await page.getByRole('button', { name: 'Export CSV' }).click();
const download = await downloadPromise;
const failure = await download.failure();
if (failure) throw new Error(`Download failed: ${failure}`);
await download.saveAs('artifacts/export.csv');
Any new page in a context
const newPagePromise = context.waitForEvent('page', { timeout: 10_000 });
await page.getByText('Open in new tab').click();
const newPage = await newPagePromise;
await newPage.waitForLoadState();
Performance and reliability notes
Registering a wait before an action is cheap and removes a race. Keep predicates small and deterministic; expensive asynchronous work inside a predicate can delay resolution and make diagnosis harder. Prefer locator actions over arbitrary sleeps so Playwright can perform its actionability checks. Use a timeout that reflects the application’s expected delay, while keeping the surrounding test timeout large enough to include setup and assertions.
For parallel tests, create the page and context inside the test or fixture that owns them. Avoid sharing mutable pages between tests, because another test can navigate or close the page while an event wait is pending. When retries are enabled, ensure each retry creates a fresh context and does not reuse a promise from a failed attempt.
Or skip the browser setup
If your goal is a clean screenshot rather than testing a browser event, ScreenshotNeo provides a single HTTP request. Its capture flow accepts cookie and consent banners, removes more than 60 known consent platforms plus newsletter popups and chat widgets, and lets you turn each cleanup step off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; the response identifies the result with X-Page-Verdict and X-Billed headers.
See the ScreenshotNeo documentation for all options.
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 image = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', image));
ScreenshotNeo also supports full-page captures with lazy images loaded, CSS-element capture, device presets and custom viewports, retina scale, PDFs, custom CSS and JavaScript, clicks before capture, selector waits, delays and network idle, request blocking, custom headers and cookies, timezone and geolocation, transparent backgrounds, resizing, configurable caching, signed links, asynchronous jobs with signed webhooks, bulk capture for up to 100 URLs per call, a usage API, and an OpenAPI specification. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.
Plans include 1,000 screenshots per month free with no card. Paid plans start at $5 for 3,000 screenshots; yearly billing gives two months free, and every feature is available on every plan. Create a free ScreenshotNeo account.
Cost and operational considerations
- Event waits themselves do not add a separate Playwright service charge; the cost is the browser execution environment and your test runtime.
- Long timeouts can increase CI minutes when a trigger is broken. Fix event scope and ordering before raising limits.
- ScreenshotNeo bills only clean shots. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, and the verdict headers let you audit responses.
- For repeated screenshots, choose a cache TTL that matches how often the source changes. For changing pages, disable or shorten caching.
FAQ
Why does waitForEvent time out even though I clicked?
The click may emit a different event, emit it on the browser context, be blocked by a dialog, fail actionability checks, or be rejected by your predicate. Confirm the event source and inspect the action log.
Can I call waitForEvent after the action?
Usually no. Events can fire immediately, so create the promise first and await it after the action.
Should I set a very large timeout?
Only when the correct event is known to arrive after a legitimate delay. A large timeout hides wrong event names and lifecycle bugs.
What happens if the page closes?
A pending page event wait errors when its page closes before the event. Keep the page alive or wait on the context when the event belongs to a newly created page.
How do I handle a dialog during the action?
Register a dialog handler before the action and call accept() or dismiss(). If no handler is registered, Playwright automatically dismisses dialogs.
When is ScreenshotNeo a better fit?
Use it when you need a clean screenshot or PDF through an API, especially when consent banners, popups, chat widgets, failed loads, or bot checks would make browser setup fragile.


