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.
In this guide11 sections
- 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
- Assert Both Sides of a Return Journey
- Model Conditional New-Page Behavior Explicitly
- Attach Diagnostics to the Captured Child
- Diagnose Multi-Page Failures From the First Broken Contract
- Encapsulate Coordination, Not Business Meaning
- Use This Operational Checklist
- 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.
01 / register promise
Register page promise
Listen on the known opener or its browser context before the trigger.
02 / trigger action
Trigger popup action
Perform the single user action expected to create a new page.
03 / capture page
Capture new page
Resolve the event to a concrete Page owned by this scenario.
04 / wait target
Wait for target state
Use URL, visible UI, or a domain response as the readiness signal.
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.
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.
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.
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.
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
popupfor a known opener andpagefor 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.
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.
- 01Playwright documentation
Microsoft
Canonical API, locator, fixture, browser, and test-runner behavior.
- 02Playwright best practices
Microsoft
Official guidance for resilient tests, isolation, and user-facing locators.
- 03
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.
RELATED GUIDES
Continue the learning route
GUIDE 01
20 Playwright Context, Page, Popup, and Frame Interview Scenarios
Solve 20 senior Playwright browser topology scenarios covering context isolation, multiple pages, popups, frames, permissions, events, and cleanup.
GUIDE 02
Reliable Cross-Origin Iframe Testing with Playwright FrameLocator
Use Playwright FrameLocator across nested and cross-origin iframes, build resilient frame contracts, and debug attachment, strictness, and reload failures.
GUIDE 03
Playwright Tutorial: End-to-End Testing from Scratch
Playwright tutorial for beginners: install, write your first test, TypeScript setup, codegen, fixtures, API testing, debugging, and CI tips in one guide.
GUIDE 04
Flaky Tests: Causes and How to Fix Them
Learn how to fix flaky tests with root cause analysis, stable waits, quarantine strategy, CI retries policy, and practical Playwright examples.