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 session

This 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() and context.route(), not page.on() and page.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.
A common mistake is awaiting the click before setting up the listener: 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.

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.

If you're not sure what selector to use for an iFrame, open the browser DevTools and inspect the 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.

Cross-origin iFrames (iFrames serving content from a different domain than the host page) are fully supported by Playwright without any special configuration. This is one area where Playwright handles what Selenium historically struggled with.

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 from waitForEvent('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();

Losing reference to the original page. After working with a new tab, tests sometimes try to assert on the original page but use the wrong variable. Keep your page references named clearly and consistently.

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();
});

Using 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.

How do I get a list of all open tabs in the current context?

Use context.pages(), which returns an array of all open Page objects. The first element is typically the first tab opened in that context.

What if an iFrame is inside another iFrame, can I still use frameLocator?

Yes. Chain frameLocator() calls: page.frameLocator('iframe#outer').frameLocator('iframe#inner'). Each level narrows the scope to the nested document.

Does 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.

My iFrame selector works in DevTools but not in Playwright. What's wrong?

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.

Can Playwright interact with cross-origin iFrames (different domain)?

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.

→ See also: Network Interception, Mocking, and Stubbing in Playwright | Page Object Model in Playwright: From Messy to Maintainable | Playwright Locators: getByRole, getByLabel, getByText, getByTestId Compared | Waiting Strategies in Playwright: No More sleep()