How to fix flaky Playwright popup/new-tab tests (`page.waitForEvent('popup')`)?

Playwright

Popup and new-tab tests become flaky when page.waitForEvent('popup') is called after the action that opens the popup. Just like the download event race condition, the popup event fires as soon as the browser opens the new page — if your listener isn't registered yet, the event is lost and waitForEvent hangs until timeout. This pattern affects any target="_blank" links, window.open() calls, and OAuth redirect flows that open in a new browser context.

Common mistake

test('opens receipt in new tab', async ({ page }) => {
  await page.goto('https://app.example.com/orders');

  // Click opens a new tab...
  await page.getByRole('link', { name: 'View receipt' }).click();

  // ...but the popup event may have already fired by now
  const popup = await page.waitForEvent('popup');
  await popup.waitForLoadState();
  await expect(popup.getByText('Receipt #1042')).toBeVisible();
});

The fix

Register the waitForEvent('popup') promise before clicking, then await it afterward:

import { test, expect } from '@playwright/test';

test('views receipt in new tab', async ({ page }) => {
  await page.goto('https://app.example.com/orders');

  // Register listener BEFORE the click
  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('link', { name: 'View receipt' }).click();

  // Resolve the popup reference
  const popup = await popupPromise;

  // Wait for the new page to be ready
  await popup.waitForLoadState('domcontentloaded');

  // Verify popup URL before asserting content
  expect(popup.url()).toContain('/receipts/');

  await expect(popup.getByRole('heading', { name: /Receipt #/ })).toBeVisible();
  await expect(popup.getByText('Total')).toBeVisible();
});

For OAuth or external auth flows that open in a popup:

test('connects Google account via popup', async ({ page }) => {
  await page.goto('https://app.example.com/settings/integrations');

  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('button', { name: 'Connect Google' }).click();

  const authPopup = await popupPromise;
  await authPopup.waitForLoadState('networkidle');

  // Fill OAuth credentials in the popup
  await authPopup.getByLabel('Email').fill('user@gmail.com');
  await authPopup.getByRole('button', { name: 'Next' }).click();
  await authPopup.getByLabel('Password').fill(process.env.GOOGLE_TEST_PASS!);
  await authPopup.getByRole('button', { name: 'Sign in' }).click();

  // Wait for popup to close (OAuth redirect back to app)
  await authPopup.waitForEvent('close');

  // Assert main page reflects connected state
  await expect(page.getByText('Google connected')).toBeVisible({ timeout: 15000 });
});

Why it works

page.waitForEvent('popup') attaches a one-time listener to the Playwright Page object's internal event emitter. When the browser opens a new tab or popup window via window.open() or target="_blank" navigation, Playwright emits the popup event with the new Page object as the argument. If no listener is registered at that moment, the event is discarded. Setting up the promise first ensures the listener is active before any browser action that could trigger the popup, eliminating the timing gap.

Tips

  • Always call popup.waitForLoadState() after resolving the popup — the popup event fires as soon as the new page object exists, before the page has loaded any content.
  • For popups that perform a redirect chain (common in OAuth), use waitForLoadState('networkidle') or waitForURL with the expected final URL rather than domcontentloaded.
  • If the popup closes itself after completing (like OAuth), use popup.waitForEvent('close') to know when the flow is complete before asserting on the parent page.
  • Context-level popup detection — context.on('page', handler) — is useful when you don't know in advance which page will open a popup.