When a click opens a new tab, context.waitForEvent('page') must be set up as a promise before the click, not after: if the new tab opens faster than the listener registers, the event is gone and the call waits forever. Getting the new Page object also doesn't mean it's loaded — it's created empty and then navigates, so waitForLoadState() is required before any locators will find elements. This article covers the complete patterns for new tabs, popup windows, frameLocator() for iFrames including nested Stripe-style frames where each card field lives in its own iFrame, shadow DOM piercing, and the naming discipline that prevents losing track of which page variable is which.
How Playwright models pages, contexts, and tabs
Before writing a single line of code, you need to understand the object hierarchy Playwright uses.
A Browser is the browser process. A BrowserContext is an isolated session within that browser, with its own cookies, storage, and network state. A Page represents a single tab or window within a context. When a user clicks a link that opens a new tab, Playwright sees that as a new Page being added to the existing BrowserContext.
import { chromium } from '@playwright/test';
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage(); // Tab 1
const page2 = await context.newPage(); // Tab 2 in the same sessionThis model matters because:
- Pages in the same context share cookies and local storage. If page 1 is logged in, page 2 is also logged in.
- Pages in different contexts are completely isolated. This is why Playwright uses separate contexts to simulate multiple users.
- When you need to intercept events across all tabs (not just one), use
context.on()andcontext.route(), notpage.on()andpage.route().
With that model in mind, the rest of this article makes much more sense.
Detecting a new tab with context.waitForEvent('page')
The most common scenario: a user clicks a link or button that opens something in a new tab. Your test needs to get a reference to that new tab and interact with it.
The correct pattern is to set up the listener before triggering the action that causes the new tab to open. If you trigger the action first, the new page event might fire before your listener is in place.
import { test, expect } from '@playwright/test';
test('handles a new tab opened by a link click', async ({ page, context }) => {
await page.goto('https://lab.becomeqa.com');
// Set up the listener BEFORE clicking
const newPagePromise = context.waitForEvent('page');
// This click opens a new tab
await page.getByRole('link', { name: 'Open in new tab' }).click();
// Wait for the new page and let it load
const newPage = await newPagePromise;
await newPage.waitForLoadState('domcontentloaded');
// Now interact with the new tab
await expect(newPage).toHaveURL(/\/docs/);
await expect(newPage.getByRole('heading', { level: 1 })).toBeVisible();
// The original page is still accessible
await expect(page).toHaveURL('https://lab.becomeqa.com');
});context.waitForEvent('page') returns a promise that resolves with the new Page object as soon as it's created. Note that "created" does not mean "loaded". The page exists but might still be navigating. Always follow it with waitForLoadState() before trying to find elements.
await page.click(...) then await context.waitForEvent('page'). If the new tab opens fast enough, the event fires between those two lines and waitForEvent will wait forever. Always set up the promise first, then trigger the action.Opening a new tab programmatically
Sometimes you want to control the new tab entirely from your test: open a specific URL in a new tab alongside the main page, or set up a second session to simulate a second user. Use context.newPage() directly.
test('two tabs in the same session', async ({ context }) => {
const page1 = await context.newPage();
const page2 = await context.newPage();
await page1.goto('https://lab.becomeqa.com/dashboard');
await page2.goto('https://lab.becomeqa.com/settings');
// Both pages are in the same logged-in session
await expect(page1.getByText('Welcome back')).toBeVisible();
await expect(page2.getByRole('heading', { name: 'Account Settings' })).toBeVisible();
// Bring page1 back into focus (matters for some browser-specific behaviors)
await page1.bringToFront();
await page1.getByRole('button', { name: 'New Trip' }).click();
});bringToFront() makes the page the active tab in the browser UI. This rarely affects headless test execution, but some focus-dependent behaviors (drag-and-drop, certain keyboard events) require it.
Handling popup windows
Popups (windows opened with window.open()) follow the exact same pattern as tabs. In Playwright's model, they're just new Page objects. The waitForEvent('page') approach works identically.
test('handles an OAuth popup window', async ({ page, context }) => {
await page.goto('https://lab.becomeqa.com/login');
// Listen for the popup before clicking
const popupPromise = context.waitForEvent('page');
await page.getByRole('button', { name: 'Login with Google' }).click();
const popup = await popupPromise;
await popup.waitForLoadState('networkidle');
// Interact with the OAuth popup
await popup.getByLabel('Email').fill('test@example.com');
await popup.getByRole('button', { name: 'Next' }).click();
await popup.getByLabel('Password').fill('testpassword');
await popup.getByRole('button', { name: 'Sign in' }).click();
// After OAuth completes, the popup closes and the main page updates
await popup.waitForEvent('close');
await expect(page).toHaveURL(/\/dashboard/);
});If the popup closes automatically after completing its flow (as OAuth windows typically do), you can wait for the close event on the popup page to confirm it's done before asserting on the original page.
Waiting for navigation after a link opens a new tab
A subtle variation on the new-tab pattern: links with target="_blank" open a new tab and immediately navigate to a URL. The new Page is created empty, then navigates. This can cause a race condition if you try to assert before navigation completes.
test('waits for navigation in a new tab', async ({ page, context }) => {
await page.goto('https://lab.becomeqa.com');
const newPagePromise = context.waitForEvent('page');
await page.getByRole('link', { name: 'Documentation' }).click();
const newPage = await newPagePromise;
// Wait for the specific navigation to complete, not just DOMContentLoaded
await newPage.waitForLoadState('load');
// Now it's safe to assert on the URL and content
await expect(newPage).toHaveURL(/\/docs\//);
await expect(newPage.getByRole('navigation')).toBeVisible();
});Use waitForLoadState('load') when you need all resources (images, scripts) to finish. Use waitForLoadState('domcontentloaded') for faster checks when you only care about the HTML. Use waitForLoadState('networkidle') when the page triggers additional XHR requests after load, though this one is slower and occasionally flaky on busy pages.
For cases where you know the exact URL the new tab will navigate to, waitForURL() is more precise:
const newPage = await newPagePromise;
await newPage.waitForURL('**/docs/getting-started');
await expect(newPage.getByRole('heading', { name: 'Getting Started' })).toBeVisible();iFrames: why they're annoying and how frameLocator fixes them
An iFrame is a separate document embedded inside the main page. From the browser's perspective, it has its own DOM, its own JavaScript context, and its own security origin. Standard locators (page.getByRole(), page.getByText()) only search the main page's DOM. They can't see inside iFrames.
Before Playwright introduced frameLocator(), the workaround was clunky: get the frame object, then call locator methods on it separately.
// Old way: works but verbose
const frame = page.frame({ name: 'payment-widget' });
await frame?.getByLabel('Card Number').fill('4111111111111111');frameLocator() is cleaner. It returns a locator scoped to the iFrame's content, so you can chain locators exactly the same way you would on the main page.
test('fills out a payment form inside an iFrame', async ({ page }) => {
await page.goto('https://lab.becomeqa.com/checkout');
// Locate the iFrame by its selector, then chain locators inside it
const paymentFrame = page.frameLocator('iframe[name="payment-widget"]');
await paymentFrame.getByLabel('Card Number').fill('4111111111111111');
await paymentFrame.getByLabel('Expiry Date').fill('12/28');
await paymentFrame.getByLabel('CVV').fill('123');
// Back on the main page for the submit button
await page.getByRole('button', { name: 'Pay Now' }).click();
await expect(page.getByText('Payment successful')).toBeVisible();
});You can use any valid CSS selector in frameLocator(): iframe#checkout-frame, iframe[src*="stripe.com"], iframe.payment-container. If the page has multiple iFrames, be specific enough that it matches exactly one.
element. The name, id, src, and class attributes are all valid targets for frameLocator().Nested iFrames
Payment widgets and third-party embeds sometimes nest iFrames: an outer iFrame containing an inner one. frameLocator() supports chaining directly.
test('interacts with a nested iFrame', async ({ page }) => {
await page.goto('https://lab.becomeqa.com/checkout');
// Outer iFrame
const outerFrame = page.frameLocator('iframe#payment-container');
// Inner iFrame nested inside the outer one
const innerFrame = outerFrame.frameLocator('iframe#card-number-frame');
await innerFrame.getByPlaceholder('Card number').fill('4111111111111111');
// Back to the outer iFrame for expiry and CVV (different inner frames)
const expiryFrame = outerFrame.frameLocator('iframe#expiry-frame');
await expiryFrame.getByPlaceholder('MM / YY').fill('12/28');
const cvvFrame = outerFrame.frameLocator('iframe#cvv-frame');
await cvvFrame.getByPlaceholder('CVV').fill('123');
});Stripe Elements is the classic real-world example of this pattern: each input field (card number, expiry, CVV) lives in its own separate nested iFrame for PCI compliance reasons. Each one needs its own frameLocator chain.
Shadow DOM
Shadow DOM is not iFrames, but it causes the same symptom: standard locators can't find elements. Shadow DOM is a browser feature that encapsulates a component's internal DOM. Web components, custom elements, and some UI libraries use it.
The good news: Playwright's locators pierce shadow DOM by default. page.getByRole(), page.getByText(), and page.locator() all search through shadow roots without any extra configuration.
test('locates elements inside shadow DOM', async ({ page }) => {
await page.goto('https://lab.becomeqa.com/components');
// This works even if the button is inside a shadow root
await page.getByRole('button', { name: 'Submit' }).click();
// CSS selectors need the >>> combinator to pierce shadow DOM
await page.locator('custom-login-form >>> input[type="email"]').fill('user@example.com');
});Use >>> in CSS selectors when you need to pierce shadow DOM explicitly. For most cases, prefer semantic locators (getByRole, getByLabel). They pierce shadow DOM automatically and don't require knowing the internal structure.
Where shadow DOM becomes genuinely difficult is when it's combined with iFrames: a shadow root inside an iFrame inside another iFrame. In those cases, chain frameLocator() to reach the right document first, then use semantic locators that auto-pierce the shadow root.
Common mistakes
Switching tabs before they load. The most frequent bug: you get the new page reference fromwaitForEvent('page') and immediately try to click something. The page is still blank. Always call waitForLoadState() before interacting.
// Wrong: races with navigation
const newPage = await newPagePromise;
await newPage.getByRole('button', { name: 'Accept' }).click(); // Might fail
// Correct
const newPage = await newPagePromise;
await newPage.waitForLoadState('domcontentloaded');
await newPage.getByRole('button', { name: 'Accept' }).click();test('loses track of the original page', async ({ page, context }) => {
const originalPage = page; // Rename for clarity when working with multiple tabs
const newPagePromise = context.waitForEvent('page');
await originalPage.getByRole('link', { name: 'Terms' }).click();
const termsPage = await newPagePromise;
await termsPage.waitForLoadState('load');
// Assert on terms page
await expect(termsPage.getByRole('heading', { name: 'Terms of Service' })).toBeVisible();
await termsPage.close();
// Back to original. Explicitly use originalPage, not page.
await expect(originalPage.getByRole('heading', { name: 'Sign Up' })).toBeVisible();
});page.frames() when frameLocator() is available. The older page.frames() API returns an array of Frame objects. It works but forces you to manage frame indexes or names manually. frameLocator() is chainable, type-safe, and plays nicely with Playwright's auto-waiting. Default to frameLocator() unless you have a specific reason to use the frame API directly.
Not handling iFrame load timing. iFrames load asynchronously. If the iFrame content hasn't loaded yet when your locator runs, the element won't be found. frameLocator() includes auto-waiting (Playwright retries the locator until it finds a match or times out), so this is handled automatically for most cases. If you're calling frame() directly, you need to wait for the frame's load state yourself.
FAQ
Can I close a specific tab without ending the test?Yes. Call await newPage.close() to close any Page object. The original page and context remain open and usable.
Use context.pages(), which returns an array of all open Page objects. The first element is typically the first tab opened in that context.
Yes. Chain frameLocator() calls: page.frameLocator('iframe#outer').frameLocator('iframe#inner'). Each level narrows the scope to the nested document.
waitForEvent('page') work for popups opened by JavaScript (not link clicks)?
Yes. Any window.open() call in the browser creates a new Page event on the context, regardless of whether it was triggered by a link click or JavaScript.
Make sure the iFrame exists in the DOM at the time your locator runs. If it's injected dynamically, add a page.waitForSelector('iframe#my-frame') before using frameLocator(). Also verify you're selecting the iFrame element itself, not something inside it. frameLocator() takes the selector of the tag.
Yes, this is one of Playwright's strengths over older WebDriver-based tools. Cross-origin iFrames work with frameLocator() without any special configuration or permission flags.