QA How-To
How to Use Playwright popup and new tab handling (2026)
Learn playwright popup and new tab handling with event-first patterns, Page objects, shared sessions, routing, closure, multiple tabs, and CI debugging.
25 min read | 2,555 words
TL;DR
Start `page.waitForEvent('popup')` before the trigger, await the resulting `Page`, and interact with it normally. Use `context.waitForEvent('page')` for any new page in the context, and route at context level when the initial popup request matters.
Key Takeaways
- Register the popup or page event wait before the action that creates the Page.
- Treat popups and tabs as Page objects, with no window-handle switching required.
- Use the opener's popup event for known relationships and context page for broader discovery.
- Assert useful URL and UI readiness instead of adding sleeps or universal load waits.
- Route at BrowserContext level to intercept a popup's first navigation request.
- Keep same-user tabs in one context and create another context for real session isolation.
- Wait for application-driven closure before asserting the opener's updated state.
Playwright popup and new tab handling uses Page events, not browser-window switching APIs. Start page.waitForEvent('popup') before the click when the new page has a known opener, perform the trigger, await the new Page, and then use ordinary locators and web-first assertions on it. Use context.waitForEvent('page') when any page in the BrowserContext may open the tab.
A popup and a new tab are both represented by Playwright's Page class. You do not need a window handle, and you normally do not need to bring a tab to the foreground. This guide covers correct event sequencing, readiness, multiple pages, shared authentication, first-request routing, page objects, cleanup, and CI debugging with current 2026 APIs.
TL;DR
const popupPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Open report' }).click();
const popup = await popupPromise;
await expect(popup).toHaveURL(/\/reports\/42$/);
await expect(popup.getByRole('heading', { name: 'Report 42' }))
.toBeVisible();
| Situation | Event to wait for | Scope |
|---|---|---|
| A known page opens a popup or target blank tab | page.waitForEvent('popup') |
Only pages opened by that Page |
| Any page in the context may open a page | context.waitForEvent('page') |
All new pages in the BrowserContext |
| Need to observe every future page | context.on('page', handler) |
Ongoing listener, manage cleanup and errors |
| Need to intercept the popup's first navigation | context.route() before the trigger |
Context routing sees the initial request |
| Need a truly separate user session | browser.newContext() |
Isolated cookies and storage |
The non-negotiable rule is to register the wait before the action. Otherwise a fast popup can open before Playwright begins listening, leaving the test to wait for an event that already happened.
1. Understand Page, popup, tab, and BrowserContext
Playwright models every browser tab or popup window as a Page. A BrowserContext can contain multiple pages, and those pages share context-level state such as cookies, permissions, locale, emulation, and network routes. This model replaces Selenium-style window handles with object references.
The word popup describes the relationship to an opener. When a page uses window.open() or a link with target="_blank", the opener emits a popup event. The BrowserContext also emits a page event for the same newly created Page. Playwright does not need to distinguish whether the browser visually renders it as a separate window or tab.
const pagesBefore = page.context().pages();
console.log('open pages:', pagesBefore.length);
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Preview invoice' }).click();
const preview = await popupPromise;
const opener = await preview.opener();
expect(opener).toBe(page);
popup.opener() returns the opening Page, or null for a page without an available opener. It is useful when relationship itself matters, but most tests already have the opener reference.
Pages act independently for automation. You can interact with an opener and popup in sequence without bringToFront(). Reserve bringToFront() for behavior that genuinely depends on tab visibility or focus, and recognize that background throttling can be browser-specific.
2. Set up Playwright popup and new tab handling
Create a Playwright Test project, then write the flow as one test with explicit Page variables.
npm init playwright@latest
npx playwright test tests/popup.spec.ts
This complete test routes the popup destination at context level, so no application server is needed:
import { test, expect } from '@playwright/test';
test('opens and verifies a report tab', async ({ page, context }) => {
await context.route('https://reports.example.test/42', route => {
return route.fulfill({
contentType: 'text/html',
body: '<h1>Report 42</h1><p role="status">Ready</p>',
});
});
await page.setContent(`
<a href="https://reports.example.test/42" target="_blank">
Open report
</a>
`);
const reportPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Open report' }).click();
const report = await reportPromise;
await expect(report).toHaveURL('https://reports.example.test/42');
await expect(report.getByRole('heading', { name: 'Report 42' }))
.toBeVisible();
await expect(report.getByRole('status')).toHaveText('Ready');
});
The route is on context because a Page-level route cannot intercept the popup's first navigation request. The event wait starts before the click. Once captured, report is a normal Page with locators, assertions, navigation, screenshots, and event APIs.
For basic runner and project setup, see the Playwright automation guide.
3. Register the event before the triggering action
Event timing is the central synchronization issue. Do not click first and then call waitForEvent(). The browser may create the new page immediately, especially for a local or cached target.
// Correct.
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Continue with provider' }).click();
const popup = await popupPromise;
// Incorrect race.
// await page.getByRole('button', { name: 'Continue with provider' }).click();
// const popup = await page.waitForEvent('popup');
Starting the promise does not block. The click runs while the listener is active, and awaiting the promise afterward retrieves the Page emitted for that action. This is the same event-first principle used for downloads and other action-triggered browser events.
Some teams use Promise.all():
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('link', { name: 'Terms' }).click(),
]);
Both forms are valid. The separate promise form is often easier to read and annotate. Do not put two independent clicks in the same Promise.all(), and do not run concurrent interactions against one Page merely to reduce test time.
If the click sometimes navigates the same tab instead of opening a popup, clarify whether that is a supported responsive variation or a defect. A popup-only event wait will time out on same-tab navigation. Model documented alternatives explicitly rather than hiding them with a broad catch.
4. Choose page popup events or context page events
Use page.waitForEvent('popup') when a known opener performs a known action. It expresses the relationship precisely and ignores unrelated pages opened elsewhere in the same context.
Use context.waitForEvent('page') when the new page can originate from any existing page, or when you are testing infrastructure that creates a page without a useful opener reference.
const newPagePromise = context.waitForEvent('page');
await page.getByRole('link', { name: 'Open documentation' }).click();
const documentation = await newPagePromise;
await expect(documentation).toHaveURL(/\/docs/);
For ongoing observation, attach a listener:
const discovered: string[] = [];
const recordPage = (newPage: import('@playwright/test').Page) => {
discovered.push(newPage.url());
};
context.on('page', recordPage);
// Perform the workflow that may open several pages.
context.removeListener('page', recordPage);
An ongoing async listener can create unhandled failures if its Promise rejects outside the test flow. Prefer waitForEvent() for one expected page. When using on(), capture evidence safely, remove the listener when no longer needed, and make final assertions in the main test.
Remember that both the opener popup event and context page event fire for an opener-created page. Do not wait for both unless you intentionally need to verify event relationships, or you may add unnecessary complexity around the same Page.
5. Wait for useful readiness, not arbitrary load states
The popup event becomes available after initial navigation has started and the response begins loading. It does not guarantee that every component or request is finished. In most tests, use a URL assertion and a locator assertion for the exact state you need. Playwright's web-first assertions retry while the popup renders.
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'View receipt' }).click();
const receipt = await popupPromise;
await expect(receipt).toHaveURL(/\/receipts\/R-4821$/);
await expect(receipt.getByRole('heading', { name: 'Receipt R-4821' }))
.toBeVisible();
await expect(receipt.getByRole('status')).toHaveText('Paid');
Call waitForLoadState('domcontentloaded') or 'load' only when that generic lifecycle boundary is genuinely useful. You should not need it for most popup interactions. Avoid treating networkidle as a universal readiness signal, since applications can keep analytics, streaming, or polling connections active.
For redirects, use expect(page).toHaveURL() or page.waitForURL() with the intended destination. If an OAuth popup traverses several pages, wait for the provider form, perform the interaction, and then wait for the callback or closure that represents success.
The Playwright web-first assertion examples explain why observable state is stronger than fixed timeouts. A sleep after popup capture only guesses how long rendering needs.
6. Interact with opener and popup without switching handles
Keep descriptive variables for each Page and use them directly. There is no global active-window pointer that must be switched.
const detailsPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Compare plan details' }).click();
const details = await detailsPromise;
await details.getByRole('tab', { name: 'Limits' }).click();
await expect(details.getByRole('heading', { name: 'Usage limits' }))
.toBeVisible();
await page.getByRole('checkbox', { name: 'I reviewed plan limits' }).check();
await expect(page.getByRole('button', { name: 'Continue' })).toBeEnabled();
This code moves between Page objects simply by calling methods on the appropriate variable. It remains reliable even if the browser visually keeps the popup in front.
If focus is a product requirement, assert it deliberately. For example, evaluate document.hasFocus() or verify focus-driven behavior after bringToFront(), but keep such tests limited and cross-browser aware. Most functional flows should not depend on operating-system window focus in headless CI.
Use context.pages() for inventory, not as the primary capture mechanism. Taking the last page after a click assumes no other page opened and that array order represents your event. An explicit wait links the resulting Page to the trigger and fails clearly if the event never occurs.
7. Handle multiple popups and unknown ordering
When one action opens multiple pages, collect the exact number through context events or wait for business-identifiable destinations. Do not assume that network timing equals business order.
const opened: import('@playwright/test').Page[] = [];
const capture = (newPage: import('@playwright/test').Page) => opened.push(newPage);
context.on('page', capture);
await page.getByRole('button', { name: 'Open monthly reports' }).click();
await expect.poll(() => opened.length).toBe(2);
const january = opened.find(item => item.url().includes('/reports/january'));
const february = opened.find(item => item.url().includes('/reports/february'));
expect(january).toBeDefined();
expect(february).toBeDefined();
context.removeListener('page', capture);
expect.poll() lets the test wait for the collection condition without a sleep. Once identified, assert each Page's content. Avoid non-null assertions until the generic expectation proves the Page exists, or create a helper that throws a diagnostic error with the observed URLs.
If actions open pages sequentially, prefer separate event waits around each action. That makes ownership and failure location obvious. Multiple popups are uncommon in good UX, so verify the product requirement before encoding a complex collector.
Clean up extra pages when the remainder of the test does not need them. Closing a popup reduces resource use and prevents later context-wide events or screenshots from including irrelevant state.
8. Test shared authentication and isolated sessions
Pages inside one BrowserContext share cookies and context-level configuration. That is appropriate for a target blank link that should open as the same signed-in user or an OAuth popup that returns credentials to its opener.
const accountPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Account portal' }).click();
const account = await accountPromise;
await expect(account.getByRole('heading', { name: 'Your account' }))
.toBeVisible();
await expect(account.getByRole('button', { name: 'Sign out' })).toBeVisible();
Do not create a new BrowserContext simply to represent a new tab. That would isolate cookies and storage and change the behavior under test.
Create a separate context when the requirement is truly another user or an unauthenticated session:
const guestContext = await browser.newContext();
const guestPage = await guestContext.newPage();
await guestPage.goto('/public-profile/qa-engineer');
await expect(guestPage.getByRole('link', { name: 'Sign in' })).toBeVisible();
await guestContext.close();
Storage sharing across tabs is nuanced. Cookies and local storage are origin-scoped and shared within the context, while session storage behavior follows browser page semantics. Test the actual product workflow rather than assuming every storage mechanism behaves identically.
For reusable authenticated setup, review Playwright authentication and storage state.
9. Route and observe the popup's first request correctly
The opener's popup event is emitted only after the initial request response begins loading. A route attached to the new Page at that point is too late for that first navigation. Register context.route() before triggering the popup when the initial document must be mocked, blocked, or inspected.
await context.route('https://billing.example.test/checkout', async route => {
expect(route.request().method()).toBe('GET');
await route.fulfill({
contentType: 'text/html',
body: '<h1>Checkout</h1><button>Pay $20.00</button>',
});
});
const checkoutPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Open checkout' }).click();
const checkout = await checkoutPromise;
await expect(checkout.getByRole('heading', { name: 'Checkout' })).toBeVisible();
Context-level request listeners also see the initial request. Attach them before the trigger and filter by URL so unrelated assets do not pollute evidence. Remove temporary listeners in long-running custom contexts.
If a service worker intercepts requests, routing behavior can differ. Use the supported context option to block service workers in tests that require deterministic network interception, and confirm that doing so matches the layer you intend to test.
Never mock a third-party identity or payment provider and then claim full provider integration coverage. A route-mocked popup proves your application's browser wiring. Keep a smaller controlled integration check for the real contract where policy and environment allow it.
10. Close pages and verify return behavior
Some workflows close the popup after completion and update the opener through storage, messaging, or a callback request. Wait for the popup's close event and then assert the opener's observable state.
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Connect calendar' }).click();
const popup = await popupPromise;
const closePromise = popup.waitForEvent('close');
await popup.getByRole('button', { name: 'Authorize' }).click();
await closePromise;
expect(popup.isClosed()).toBe(true);
await expect(page.getByRole('status')).toHaveText('Calendar connected');
As with opening, start the close wait before the action that causes closure. Do not call popup.close() when the requirement is that the application closes itself after authorization, because manual closure bypasses that behavior.
Call await popup.close() when cleanup is the test's responsibility and application-driven close is not under test. isClosed() is a synchronous state check, while the close event coordinates an expected asynchronous closure.
If the popup closes too quickly to interact with, inspect redirects, console errors, and opener messaging. Capture the Page immediately through the event-first pattern. A slow-motion mode may help local diagnosis, but it should not become suite synchronization.
11. Encapsulate popup handling in page objects
A page object method can return the new Page while keeping event timing next to the trigger. Type the return value and avoid hiding assertions that every caller needs to understand.
import type { Page } from '@playwright/test';
export class BillingPage {
constructor(private readonly page: Page) {}
async openInvoice(invoiceNumber: string): Promise<Page> {
const popupPromise = this.page.waitForEvent('popup');
await this.page
.getByRole('link', { name: `Open invoice ${invoiceNumber}` })
.click();
return popupPromise;
}
}
The test can assert destination and content:
const invoice = await new BillingPage(page).openInvoice('INV-1002');
await expect(invoice).toHaveURL(/\/invoices\/INV-1002$/);
await expect(invoice.getByRole('heading', { name: 'Invoice INV-1002' }))
.toBeVisible();
Returning a Page is often more flexible than storing the popup as mutable state on the page object. A dedicated popup page object is useful when the destination has many behaviors, but its constructor should accept the captured Page.
Avoid helpers named switchToNewTab() that select context.pages().at(-1). That imports a handle-switching mental model and loses the trigger relationship. Name methods by business behavior, such as openInvoice, and capture the event inside them.
12. Debug Playwright popup and new tab handling in CI
Classify the failure before changing timeouts. If the event wait times out, the trigger may not have run, the link may have navigated in the same tab, a browser policy may have blocked the popup, or the listener may have started too late. If content times out, the Page was captured but navigation, authentication, routing, or rendering failed.
Retain traces on failure and inspect the click, page event, URL sequence, console messages, and requests. Attach safe listeners before the trigger during diagnosis:
context.on('page', newPage => {
console.log('new page:', newPage.url());
newPage.on('pageerror', error => console.log('popup error:', error.message));
});
Remove broad logs after repair, especially when URLs can contain secrets or personal data. OAuth callbacks commonly carry sensitive query parameters.
Run the focused test in each relevant browser project. Popup policies, focus, and external protocols can differ. Keep third-party dependencies controlled in normal regression and reserve real-provider checks for a suitable environment.
Do not add waitForTimeout() after capture. Assert the destination and meaningful UI state. If the popup closes unexpectedly, register the close listener immediately and inspect whether application logic, Content Security Policy, or an external redirect caused it.
Interview Questions and Answers
Q: How does Playwright represent a new tab or popup?
Both are Page objects inside a BrowserContext. I capture the new Page from the opener's popup event or the context's page event, then use normal locators and assertions. There is no window-handle switch.
Q: Why must waitForEvent('popup') start before the click?
The popup can open immediately. If the listener starts afterward, the event may already be gone and the test will wait until timeout. Starting the promise first connects the captured Page to the triggering action.
Q: When would you use context.waitForEvent('page')?
I use it when any existing Page may create the new page or when the opener relationship is not useful. For a known page and action, the narrower popup event communicates intent better.
Q: Do you need bringToFront() before interacting with a popup?
Normally no. Each Page can be automated directly regardless of visual focus. I use bringToFront() only for a focus-specific product behavior and keep that test cross-browser aware.
Q: How do you intercept the popup's initial navigation?
I register browserContext.route() before the trigger. The popup event is emitted after the initial response starts, so a route attached to the new Page is too late for its first request.
Q: Do tabs share authentication state?
Pages in the same BrowserContext share cookies and context-level state, subject to normal origin rules. A separate BrowserContext represents an isolated session or another user, not merely another tab.
Q: How do you verify an application-closing OAuth popup?
I capture the popup before opening it, start a close-event wait before the authorization action, and await closure. Then I assert the opener's connected state. I avoid manually closing the popup because that would bypass the behavior being tested.
Common Mistakes
- Clicking before registering the popup or page event wait.
- Selecting
context.pages().at(-1)instead of capturing the event tied to the trigger. - Treating tabs as Selenium window handles that require switching.
- Calling
bringToFront()for every popup without a focus requirement. - Waiting for
networkidleor a fixed sleep instead of useful UI readiness. - Attaching
page.route()after capture and expecting to intercept the initial request. - Creating a new BrowserContext when the new tab should share login state.
- Using one context when the scenario actually requires a separate user.
- Leaving ongoing context listeners attached across unrelated steps.
- Assuming multiple popups arrive in a stable network order.
- Manually closing a popup when application-driven closure is the behavior.
- Logging OAuth or payment URLs that may contain sensitive values.
Conclusion
Reliable Playwright popup and new tab handling is event-first and object-oriented. Register the correct wait before the trigger, capture the returned Page, and assert the destination's meaningful state. Use the opener popup event for a known relationship and the context page event for broader discovery.
Keep shared sessions in one BrowserContext, create separate contexts only for real isolation, route initial popup requests at context level, and avoid handle switching or arbitrary sleeps. Apply the basic pattern to one popup flow in your suite, then add closure, opener-state, and error evidence based on the actual risk.
Interview Questions and Answers
Explain Playwright's model for multiple tabs.
Every tab or popup is a Page inside a BrowserContext. I capture a new Page through an event, keep a descriptive variable, and call methods on the relevant Page. The context defines shared session and emulation state, so no global window switch is needed.
What race condition commonly breaks popup tests?
The test performs the click before registering `waitForEvent`. A fast page opens before the listener exists, so the later wait times out. I always create the event promise first and keep it adjacent to the trigger.
How do you decide between page popup and context page events?
I use `page.waitForEvent('popup')` for a known opener and action because it is narrowly scoped. I use `context.waitForEvent('page')` when any page may create the new Page. Ongoing discovery can use a managed context listener.
How do you synchronize after a popup is captured?
I wait for the behavior needed by the test, typically an expected URL and a web-first locator assertion. I avoid arbitrary sleeps and do not use network idle as a universal readiness definition.
How would you test two different logged-in users across pages?
I use two BrowserContexts, each with its own storage state or login setup. Two Pages in one context represent the same context-level session and are not sufficient for isolated users. I close the extra context through a fixture or explicit cleanup.
Why must first-popup-request routing be context-level?
The new Page becomes available through the popup event only after the first response has started. At that point a Page route cannot affect the initial request. A route registered on the BrowserContext before the trigger can intercept it.
How do you test a popup that closes itself?
I register a close-event wait before the action that should cause closure, await it, and confirm `isClosed()` if needed. I then assert the opener's resulting state. I do not manually close the popup because that bypasses application behavior.
Frequently Asked Questions
How do I handle a new tab in Playwright?
Start `page.waitForEvent('popup')` before clicking the target blank link, perform the click, and await the returned Page. Use normal locators and assertions on that Page without switching a window handle.
What is the difference between popup and page events in Playwright?
The opener's `popup` event captures pages opened by that specific Page. The BrowserContext `page` event captures every new Page created in the context, including opener-created popups.
Do I need Promise.all for a Playwright popup?
No. Starting the event promise, performing the action, and awaiting the promise is correct and often clearest. `Promise.all()` is also valid when it keeps the event wait and one trigger together.
Should I call waitForLoadState after capturing a popup?
Usually not. Assert the expected URL and meaningful UI locator, which retry until the required state appears. Use a specific load state only when that lifecycle boundary is itself useful.
Do Playwright tabs share cookies?
Pages in the same BrowserContext share cookies and context configuration, subject to origin rules. Use a separate BrowserContext when the scenario needs another user or an isolated session.
How do I mock the first request of a popup?
Register `context.route()` before the action that opens the popup. A route added to the captured Page is too late for its initial navigation request.
How do I know whether a popup closed in Playwright?
Start `popup.waitForEvent('close')` before the action expected to close it, then await that event. `popup.isClosed()` provides a synchronous state check after closure.