How to fix "Frame was detached" errors in Playwright?

Playwright

"Frame was detached" means the iframe you were interacting with was removed from the DOM or replaced while your script held a reference to it. This commonly surfaces when SPAs re-render a checkout widget, a payment iframe, or an authentication popup after user actions, causing the original frame reference to point to a node that no longer exists. The error can also appear as navigating frame was detached when the frame itself navigates as part of a multi-step flow.

Common mistake

// Grab a frame reference early
const frame = page.frame({ name: 'payment-widget' });

// Fill in some form fields on the main page first
await page.getByLabel('Order total').fill('99.00');
await page.getByRole('button', { name: 'Proceed to payment' }).click();

// The button click may have re-mounted the iframe — frame reference is now stale
await frame.getByLabel('Card number').fill('4242 4242 4242 4242');

The fix

Re-acquire the frame reference after any action that might remount it, and wait for the iframe element to be present before resolving the frame:

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

test('completes payment in iframe', async ({ page }) => {
  await page.goto('https://shop.example.com/checkout');

  await page.getByLabel('Order total').fill('99.00');
  await page.getByRole('button', { name: 'Proceed to payment' }).click();

  // Wait until the iframe is attached to the DOM
  await page.waitForSelector('iframe[name="payment-widget"]');

  // Acquire a fresh frame reference after the UI has settled
  const frame = page.frame({ name: 'payment-widget' });
  if (!frame) throw new Error('payment-widget frame not found');

  await frame.getByLabel('Card number').fill('4242 4242 4242 4242');
  await frame.getByLabel('Expiry').fill('12/28');
  await frame.getByLabel('CVC').fill('123');
  await frame.getByRole('button', { name: 'Pay now' }).click();
});

For Stripe and similar embedded widgets that use frameLocator, Playwright handles frame re-attachment transparently:

// frameLocator re-queries the iframe on each use, avoiding stale references
const stripe = page.frameLocator('iframe[title="Secure card frame"]');
await stripe.getByPlaceholder('1234 1234 1234 1234').fill('4242 4242 4242 4242');

Why it works

page.frame() returns a snapshot reference to the Frame object at query time. If the application removes and re-inserts the iframe element, the old Frame object is detached and any operation on it throws. frameLocator() avoids this by lazily resolving the frame on each action, always finding the currently attached instance. Waiting for the iframe selector with waitForSelector before calling page.frame() ensures the element exists in the DOM before the reference is taken.

Tips

  • Prefer page.frameLocator() over page.frame() for any iframe that might be remounted — it behaves like a regular locator and auto-retries.
  • If the frame is inside an SPA that unmounts and remounts it during flow transitions, add a waitForSelector at the transition point rather than at test start.
  • For cross-origin frames, Playwright can interact with them but cannot evaluate() JavaScript inside them — keep assertions to observable UI state only.
  • A related error that surfaces when the main page navigates while you hold DOM references is covered in execution context was destroyed.