skip to content

In Playwright, why must page.waitForEvent('popup') be created before the click that opens the tab?

level: middleimportance: must knowfreq 76%

answer

  1. Events are not queued for late listeners
  2. The tab appears during the click
  3. Build the waiter, then trigger
  4. The un-awaited promise is deliberate
  5. Promise.all evaluates left to right

basics

~20 s

Playwright emits the popup event the moment the new tab appears and never replays it. Awaiting the click first lets the event fire with nobody listening, so the later wait blocks on a second popup that never comes.

solid answer

~40 s

Playwright's events are emitter-style: delivered to whoever is subscribed when they fire, never buffered for a subscriber that arrives later. The `popup` event fires while `click()` is still resolving, so if you `await` the click first the event is already gone and `page.waitForEvent('popup')` waits for a popup the site will never open again. The fix is ordering: create the promise without awaiting it, do the action, then await the saved promise — `const popupPromise = page.waitForEvent('popup');` then the click, then `const popup = await popupPromise;`. `Promise.all([page.waitForEvent('popup'), link.click()])` is the same trick, relying on the array being evaluated left to right. The rule generalises to `context.waitForEvent('page')`, downloads and dialogs: build the waiter first, then trigger.

code

typescript · 13 lines
typescript
import { test } from '@playwright/test';

test('opening a room in a new tab', async ({ page }) => {
  await page.goto('https://hotels.example.com/search?city=lisbon');

  // Create the waiter FIRST, without awaiting it.
  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('link', { name: 'Deluxe suite' }).click();
  const popup = await popupPromise;

  await popup.waitForLoadState();
  console.log(popup.url());
});

go deeper

for a junior

Learn the shape by heart: save the waitForEvent promise without awaiting it, then click, then await the saved promise. Never sleep after the click instead.

for a middle

Explain the mechanism, not just the recipe. Events fire once to current subscribers, the popup event fires during the click, and Promise.all works only because the array is evaluated left to right.

for a senior

Recognise the signature in a flaky suite: a popup wait that times out on CI but passes locally is an ordering race, not a slow site, and no amount of timeout tuning fixes it.

for a principal

Make it structural. A helper or fixture that owns the trigger-and-wait pairing keeps the rule out of individual tests, and a lint exception for the deliberately floating promise stops reviewers from 'fixing' it back into a race.

## The bug, in two lines ```js await page.getByRole('link', { name: 'Deluxe suite' }).click(); const popup = await page.waitForEvent('popup'); // hangs until the timeout ``` The click opens the room in a new tab, you can see it in a headed run, and yet the next line never resolves. Nothing is broken in the site and nothing is broken in the locator — the listener was simply registered too late. ## Why the event is already gone Playwright's events are emitter-style: each one is delivered to whoever is subscribed **at the moment it fires**, and it is never buffered or replayed for a subscriber that arrives afterwards. The `popup` event fires as soon as the new tab has navigated to its initial URL and that response has started loading, which happens while `click()` is still resolving. By the time the next statement calls `waitForEvent`, the event has come and gone, so the call sits waiting for a **second** popup that the booking site will never open. ## The correct order 1. Create the promise, deliberately **without** `await`, so the listener is attached now. 2. Perform the action that opens the tab. 3. `await` the promise you saved. ```js const popupPromise = page.waitForEvent('popup'); await page.getByRole('link', { name: 'Deluxe suite' }).click(); const popup = await popupPromise; ``` The un-awaited promise on the first line is the whole point, and it is why linters that flag floating promises need a local exception here. ## Promise.all is the same trick, written differently ```js const [popup] = await Promise.all([ page.waitForEvent('popup'), page.getByRole('link', { name: 'Deluxe suite' }).click(), ]); ``` The array literal is evaluated left to right, so the listener is registered before `click()` is even called. Keep the wait first: reversing the two entries recreates the original race, and it is a race that often *appears* to work. ## The same rule, one level up | You want | Wait on | Fires for | |---|---|---| | a popup this page opened | `page.waitForEvent('popup')` | popups belonging to that page | | any new tab in the session | `context.waitForEvent('page')` | every page created in the context | Use the context form when the trigger is unknown or not on the page you hold — for example a guest-profile widget that opens the invoice tab from an iframe. ## Timeouts, predicates, and what you get back - In the JavaScript API `page.waitForEvent('popup', { timeout })` defaults to `0`, meaning it does not time out on its own; it is bounded by the surrounding test timeout, which is why the failure reads as a whole-test timeout rather than a pointed error. Pass an explicit `{ timeout: 5000 }` when you want a fast, legible failure instead. - A predicate filters when several tabs may open: `context.waitForEvent('page', p => p.url().includes('/rooms/'))` ignores the analytics tab and resolves on the room one. - The page you receive is handed over **early**: it has navigated to its initial URL but may still be loading. Wait for the content you care about before acting on it rather than assuming the document is ready. ## Why this flakes rather than failing outright On a slow machine the popup's first response sometimes has not started yet when the next line runs, so the late listener still catches the event and the test passes. On a fast CI worker it never does. That asymmetry is exactly the "passes on my laptop, fails in the pipeline" signature, and it is the reason the ordering rule is worth learning as a rule rather than as a fix applied when something breaks. Any time a Playwright script both triggers something and waits for it, the wait is created first — the same shape covers downloads, dialogs and new pages.

  • Why does the wrong ordering sometimes pass locally but fail in CI?
    It is a race. On a slow machine the popup's first response has not started when the next line runs, so the late listener still catches the event. A fast CI worker gets there first and the event is missed. The ordering rule removes the race instead of tuning it.
  • How do you make that wait fail quickly and clearly instead of timing out the whole test?
    Pass an explicit timeout: `page.waitForEvent('popup', { timeout: 5000 })`. In the JavaScript API the option defaults to 0, meaning no timeout of its own, so the failure otherwise surfaces as a whole-test timeout with no hint about which wait hung.
  • What do you do when you cannot tell which element opens the tab?
    Wait on the context instead: `context.waitForEvent('page')` resolves for any page created in the session, whoever opened it. Add a predicate such as a URL check when several tabs may open and you need a specific one.

saying these in an interview costs you the question

  • Assuming Playwright buffers events for later listeners
  • Awaiting the click and then waiting for the popup
  • Adding a fixed sleep after the click instead
  • Putting the click first inside Promise.all
  • Treating the delivered popup as fully loaded