PRACTICAL GUIDE / playwright handle popup

Handle Popups and Multi-Page Journeys in Playwright Without Races

Capture Playwright popups before the click, coordinate several tabs, assert the right readiness signal, and diagnose multi-page test races.

By The Testing AcademyUpdated July 11, 20269 min read
All field guides
In this guide11 sections
  1. Model Page Ownership Before Writing Locators
  2. Capture the Popup Before the Click
  3. Choose Popup or Context Page Deliberately
  4. Wait for the State the Next Action Needs
  5. Assert Both Sides of a Return Journey
  6. Model Conditional New-Page Behavior Explicitly
  7. Attach Diagnostics to the Captured Child
  8. Diagnose Multi-Page Failures From the First Broken Contract
  9. Encapsulate Coordination, Not Business Meaning
  10. Use This Operational Checklist
  11. Finish With a Causal Multi-Page Test

What you will learn

  • Model Page Ownership Before Writing Locators
  • Capture the Popup Before the Click
  • Choose Popup or Context Page Deliberately
  • Wait for the State the Next Action Needs

A popup test is usually flaky for one reason: the test starts observing after the browser has already acted. The reliable pattern is to arm the event promise, perform the exact user action, capture the resulting Page, and wait for an application-specific state on that page. Treat the popup as a second independent page, not as a modal element inside the opener.

That model also applies to payment handoffs, OAuth consent, printable receipts, documentation links, and workflows that return data to the original tab. The difficult part is not switching tabs. It is proving which action created which page and deciding what evidence means each page is ready.

Model Page Ownership Before Writing Locators

A browser context may contain several pages, and every page can be automated without being visually foregrounded. The official Playwright pages guide distinguishes the context-level page event from a page-level popup event. Use that ownership information: listen on the opener when the product contract says a specific control opens its child; listen on the context when a new page may come from an indirect or unknown source.

Do not select a page by array position after the fact. context.pages()[1] encodes timing and assumes no extension, helper window, or earlier tab exists. An event promise ties the new object to the behavior under test.

Animated field map

Race-Free Popup Capture

Observation begins before the user action, and the captured page is validated before cross-page assertions continue.

  1. 01 / register promise

    Register page promise

    Listen on the known opener or its browser context before the trigger.

  2. 02 / trigger action

    Trigger popup action

    Perform the single user action expected to create a new page.

  3. 03 / capture page

    Capture new page

    Resolve the event to a concrete Page owned by this scenario.

  4. 04 / wait target

    Wait for target state

    Use URL, visible UI, or a domain response as the readiness signal.

  5. 05 / assert journey

    Cross-page assertion

    Verify the child result and any callback effect on the opener.

Capture the Popup Before the Click

Create the wait without awaiting it, trigger the opening action, and then await the saved promise. Awaiting waitForEvent immediately would deadlock because the click never runs. Starting it after the click introduces the opposite problem: a fast popup can be missed.

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

test('receipt opens in a dedicated page', async ({ page }) => {
  await page.goto('/orders/ORD-431')

  const popupPromise = page.waitForEvent('popup')
  await page.getByRole('link', { name: 'Printable receipt' }).click()
  const receipt = await popupPromise

  await expect(receipt).toHaveURL(/\/receipts\/ORD-431$/)
  await expect(receipt.getByRole('heading', { name: 'Receipt ORD-431' })).toBeVisible()
  await expect(receipt.getByText('$84.50', { exact: true })).toBeVisible()

  await receipt.close()
})

Keep the trigger narrow. If a helper both submits a form and clicks an unrelated link, a popup event no longer identifies which step caused the page. The test should expose the causal boundary.

Choose Popup or Context Page Deliberately

Use page.waitForEvent('popup') when one opener is known. Use context.waitForEvent('page') when an SDK, redirect, or another page in the journey might create the tab. Both events may describe the same new page, so do not wait for both unless the test specifically verifies both event surfaces.

TypeScript
test('checkout delegates to the provider', async ({ page, context }) => {
  await page.goto('/checkout')

  const providerPagePromise = context.waitForEvent('page')
  await page.getByRole('button', { name: 'Pay with ExamplePay' }).click()
  const providerPage = await providerPagePromise

  await expect(providerPage).toHaveURL(/^https:\/\/sandbox\.example-pay\.test\//)
  await expect(providerPage.getByRole('heading', { name: 'Confirm payment' })).toBeVisible()
})

A persistent context.on('page', handler) listener fits telemetry or unknown background pages, but it is too broad for most assertions. Remove long-lived listeners after the behavior they monitor, or their output can leak into later steps.

Wait for the State the Next Action Needs

The popup event proves that a page object exists, not that its application is usable. A popup may begin at about:blank, redirect through an identity provider, and render its control only after client hydration. Choose a readiness signal tied to the next operation.

For navigation, expect(page).toHaveURL() retries and documents the destination. For UI readiness, assert a stable heading or form control. For a protocol handoff, wait for the specific response or request that completes it. A generic load-state wait can finish before the business state exists, while networkidle can wait forever when the target intentionally polls.

Assert Both Sides of a Return Journey

Many multi-page flows end by closing the child and updating the opener. The test needs two contracts: the provider reports success, and the original application reflects the callback. Capture the popup first, operate on it, then return to assertions on the original page object.

TypeScript
test('OAuth connection returns to settings', async ({ page }) => {
  await page.goto('/settings/integrations')

  const consentPromise = page.waitForEvent('popup')
  await page.getByRole('button', { name: 'Connect Calendar' }).click()
  const consent = await consentPromise

  await expect(consent).toHaveURL(/\/oauth\/authorize/)
  await consent.getByLabel('Account email').fill('qa-owner@example.test')
  await consent.getByRole('button', { name: 'Allow access' }).click()
  await consent.waitForEvent('close')

  await expect(page.getByText('Calendar connected')).toBeVisible()
  await expect(page.getByRole('button', { name: 'Disconnect Calendar' })).toBeEnabled()
})

Avoid rebuilding the opener from context.pages(). Retaining both references makes page ownership obvious and protects the test from ordering changes.

Model Conditional New-Page Behavior Explicitly

Some products open a desktop help link in a new tab but navigate the current page on a compact layout. Others use a popup only when a provider SDK is available and fall back to a redirect. Do not make one test wait for whichever outcome happens first. That leaves a losing event promise alive and turns an environment mistake into an accepted branch.

Describe the intended behavior per project or scenario. A desktop project can require the popup event and preserve the opener; a mobile project can require a same-page URL transition. If both behaviors are valid in one environment, expose the product decision through a stable condition such as configuration or response data, then choose the expected path before triggering it. The test should fail when the application chooses an undocumented third path.

This distinction also improves retries. A retry should recreate the initial account and page state, not inherit a provider connection completed by the first attempt. Cleanup must handle a child that opened, a child that closed itself, and a redirect that never created one.

Attach Diagnostics to the Captured Child

Once the Page is captured, attach page-specific diagnostics before the risky interaction. Listen for pageerror, failed requests, or console errors only for the lifetime of that child, and include its final URL in a failed-test attachment. This is especially useful when an identity or payment provider renders a generic error while the opener remains healthy.

Keep diagnostic listeners separate from assertions. An asynchronous assertion thrown inside an event callback can become an unhandled rejection and obscure the scenario failure. Collect bounded evidence, remove listeners or close the child, and let the main test flow decide what constitutes failure.

Diagnose Multi-Page Failures From the First Broken Contract

When the event times out, check whether the action really opens a new browsing context. A same-tab redirect, blocked actionability, feature flag, or browser popup policy may explain the absence. Trace the click and inspect the opener URL before increasing timeouts.

When capture succeeds but locators fail, log the popup URL and title. The page may still be in an intermediate redirect, may have landed on an error page, or may belong to a different origin than expected. When only CI fails, compare environment configuration, third-party reachability, consent state, and credentials. Retries can conceal a non-idempotent provider flow, so use a fresh test account or clean connection state.

If several pages open, predicate the event instead of taking the first one. A URL predicate is appropriate only when the initial URL is distinctive; otherwise capture candidates through a scoped listener and assert the intended contract explicitly.

Encapsulate Coordination, Not Business Meaning

A small helper can remove event-ordering mistakes, but it should not hide what opens or validates the child. Return the captured Page and let the scenario assert its destination.

TypeScript
import type { Locator, Page } from '@playwright/test'

export async function openPopup(opener: Page, trigger: Locator): Promise<Page> {
  const popupPromise = opener.waitForEvent('popup')
  await trigger.click()
  return popupPromise
}

This helper is useful when the mechanics repeat. It is a poor fit when one workflow uses a context event, another may stay in the same tab, or the trigger performs multiple steps. A few explicit lines are cheaper than an abstraction with ambiguous ownership.

Use This Operational Checklist

  • Register the event promise before the exact trigger.
  • Pick popup for a known opener and page for context-wide creation.
  • Keep references to both opener and child instead of relying on page indexes.
  • Wait for a domain-specific URL, control, request, or response.
  • Assert the child outcome and the opener's resulting state.
  • Close owned pages when the test no longer needs them.
  • Record traces for CI failures and inspect the first redirect, not only the final timeout.
  • Isolate provider accounts so retries do not inherit completed consent or payment state.

Finish With a Causal Multi-Page Test

Rewrite any popup test that discovers pages after clicking. The action and event must form one visible pair, followed by a readiness assertion that explains what the user can do next. Preserve explicit page references through the return path, clean up pages the scenario owns, and diagnose failures at the first missing transition. That structure removes timing guesses while keeping the whole journey readable in a trace.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 11, 2026 / Reviewed July 11, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Playwright documentation

    Microsoft

    Canonical API, locator, fixture, browser, and test-runner behavior.

  2. 02
    Playwright best practices

    Microsoft

    Official guidance for resilient tests, isolation, and user-facing locators.

  3. 03
    WebDriver standard

    W3C

    The browser automation protocol specification used by major automation stacks.

FAQ / QUICK ANSWERS

Questions testers ask

Should I wait for popup before or after clicking in Playwright?

Create the popup event promise before the triggering click, then await the promise after the click. Registering afterward leaves a race in which the browser can open the page before Playwright starts listening.

What is the difference between page popup and context page events?

The page popup event identifies a child opened by one known page. The browser context page event observes any new page in that context, which is useful when the opener is unknown or several pages may create tabs.

Do I need to bring a Playwright popup to the front?

No. Each Page can be automated without calling bringToFront, even when another tab is visually selected. Focus only matters when the application itself has focus-dependent behavior that is part of the scenario.

Should a popup test wait for networkidle?

Prefer a URL, heading, button, or domain response that proves the popup is ready for the next step. Network-idle heuristics can be misleading on pages with analytics, streaming, or background polling.

How should I close popups during test cleanup?

Close pages created by the scenario when the test can continue without them, or close them in a finally block when later assertions may throw. The browser context also closes its pages at fixture teardown, but explicit ownership makes long tests easier to debug.