Resource library

QA How-To

How to Fix Playwright "Frame was detached"

Fix Playwright Frame was detached by tracing iframe replacement, navigation races, shared state, and CI failures with runnable tests and verified repairs.

22 min read | 2,963 words

TL;DR

Trace the frame lifecycle, replace stale Frame references with a unique FrameLocator, and wait for an inner ready signal. If the failure occurs during navigation or only in CI, inspect those boundaries before changing timeouts.

Key Takeaways

  • A saved Frame object cannot reach an iframe that the application replaced.
  • Use a unique FrameLocator and wait for meaningful content inside the current iframe.
  • Record frameattached and framedetached events before the action that triggers the failure.
  • Await navigation and page helpers before querying content on the destination.
  • Compare one-worker and normal-worker runs to reveal shared data or CI resource pressure.
  • Match the Playwright Docker image to the installed package and probe URLs inside the container.

If you need to fix Playwright Frame was detached, the error usually appears while a test is reading or acting inside an iframe that the page removed or replaced. Capture which frame disappeared, then locate the current iframe and wait for an application signal that it is ready.

Error: frame.evaluate: Frame was detached

The method prefix varies with the operation. Playwright's source uses the message "Frame was detached" when a frame is detached, while navigation failures can instead report "net::ERR_ABORTED; maybe frame was detached?". Those lines do not prove the same cause. This guide shows how to distinguish an actual iframe replacement from navigation cancellation, shared-page races, and resource failures. The examples use Playwright Test with TypeScript and include a verification command for each repair.

TL;DR

Replace a saved Frame object with a locator that describes the iframe's current DOM position. Wait for a visible, meaningful element inside that iframe before acting:

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

test('uses the currently attached checkout frame', async ({ page }) => {
  await page.setContent('<iframe name="checkout" srcdoc="<button>Pay</button>"></iframe>');
  const checkout = page.frameLocator('iframe[name="checkout"]');
  await expect(checkout.getByRole('button', { name: 'Pay' })).toBeVisible();
  await checkout.getByRole('button', { name: 'Pay' }).click();
});

Save it as tests/frame-smoke.spec.ts and run npx playwright test tests/frame-smoke.spec.ts. This addresses a stale iframe reference, but it cannot make a frame that is continuously removed stable. If the failure happens at page.goto(), inspect the navigation and server separately. The execution context navigation guide covers a related error with a different lifecycle boundary.

What the Error Actually Means

A Frame object identifies one specific browsing context. Playwright emits frameattached when a frame joins the page, framenavigated when it commits navigation, and framedetached when it leaves. Once that particular object is detached, its isDetached() result is true; continuing to call evaluate() or query through it cannot reach a replacement iframe. See the official Frame API for these events and isDetached().

An iframe element can be recreated with the same name, CSS selector, and visible content. To a human the checkout panel looks unchanged, but the new element owns a different frame. That is why a Frame captured during setup can fail after a React rerender, a modal reopen, or a payment widget refresh. A FrameLocator instead stores how to find the iframe, so a later action can resolve the current one. The official FrameLocator API describes that relationship. A locator still needs a stable target and can fail if the application keeps replacing the iframe throughout the action.

Do not confuse frame detachment with a detached DOM element inside an intact frame. A locator retrying after an element is removed may eventually succeed. A saved ElementHandle may become stale, and a saved Frame cannot be revived. Nor is every net::ERR_ABORTED a frame removal: a navigation may be canceled by another navigation or by the server. Read the operation prefix and timeline before choosing a fix. If the message says the page or browser closed, use the target page closed guide instead.

Root-Cause Decision Table

Symptom Root cause Fix
A saved frame.evaluate() fails after a panel refresh Old Frame object points at a removed iframe Resolve the current iframe with frameLocator()
The iframe element disappears and returns during a widget load App replaces the iframe before it is ready Assert a stable iframe and an inner ready signal
Failure follows a click that changes the top-level route Test races a navigation with work on the old document Await the click and assert the destination before querying
Only a third-party embed fails or never settles External widget reloads or is blocked Mock the integration boundary or assert the host contract
Detachment coincides with a test helper running another navigation Unawaited or overlapping async operations Await each owner operation and isolate tests
One worker fails while another passes against the same account Shared server state causes cross-test rerenders Give tests separate data or serialize the conflicting flow
CI-only failure has browser crash or memory pressure nearby Resource or browser installation problem Inspect trace and logs, then control workers and dependencies
Docker run fails while local host run passes Container has different network and browser environment Probe from the container and match the image to the package

Use the table to form a hypothesis, then run the narrow command in the matching section. A larger timeout is appropriate only when the correct frame eventually becomes ready and the issue is a measured delay; it cannot restore a destroyed browsing context.

1. Fix Playwright Frame Was Detached When You Kept an Old Frame Object

page.frame({ name }) returns a Frame for the matching frame at that moment. This is convenient for inspection, but storing it across application transitions is fragile. The following test deliberately replaces an iframe and proves that the original frame is detached. It then acts through a frame locator that resolves the current element.

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

test('reacquires a replacement iframe', async ({ page }) => {
  await page.setContent(
    '<iframe name="checkout" srcdoc="<button>Pay</button>"></iframe>'
  );
  const oldFrame = page.frame({ name: 'checkout' });
  expect(oldFrame).not.toBeNull();

  await page.evaluate(() => {
    const old = document.querySelector('iframe[name="checkout"]');
    old?.remove();
    const next = document.createElement('iframe');
    next.name = 'checkout';
    next.srcdoc = '<button>Pay</button>';
    document.body.append(next);
  });

  expect(oldFrame?.isDetached()).toBe(true);
  const pay = page.frameLocator('iframe[name="checkout"]')
    .getByRole('button', { name: 'Pay' });
  await expect(pay).toBeVisible();
  await pay.click();
});

Save this as tests/frame-replacement.spec.ts. Verify with:

npx playwright test tests/frame-replacement.spec.ts

The passing assertion on isDetached() is the key evidence: the frame identity changed even though the iframe name and button text stayed the same. In a real checkout, choose a stable iframe selector such as a documented title or a test id owned by your app. Avoid selecting the first arbitrary iframe, because analytics and fraud widgets can add other frames. If the iframe itself is stable but a button vanishes, diagnose the inner element rather than replacing every frame reference. The Playwright locator visibility guide helps with that separate case.

2. Fix Playwright Frame Was Detached During an Iframe Rerender

Some apps render a loading iframe, remove it, and mount the real widget after fetching configuration. Starting an action as soon as any iframe exists can target the temporary instance. Assert the outer iframe's identity and an inner readiness condition before typing or clicking. The readiness condition should reflect behavior that matters to the user, such as an enabled payment button, not a fixed sleep.

This runnable example simulates a host replacing a placeholder iframe. The test waits for the new frame's heading. The replacement is deterministic so you can see what the assertion actually proves.

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

test('waits for the final iframe content', async ({ page }) => {
  await page.setContent(
    '<iframe title="Payment" srcdoc="<p>Loading</p>"></iframe>'
  );
  await page.evaluate(() => {
    const first = document.querySelector('iframe[title="Payment"]');
    first?.remove();
    const ready = document.createElement('iframe');
    ready.title = 'Payment';
    ready.srcdoc = '<h1>Payment ready</h1><button>Pay</button>';
    document.body.append(ready);
  });

  const frameElement = page.locator('iframe[title="Payment"]');
  await expect(frameElement).toHaveCount(1);
  const payment = page.frameLocator('iframe[title="Payment"]');
  await expect(payment.getByRole('heading', { name: 'Payment ready' }))
    .toBeVisible();
  await payment.getByRole('button', { name: 'Pay' }).click();
});

Save as tests/frame-ready.spec.ts and verify with npx playwright test tests/frame-ready.spec.ts. For an actual widget, observe the DOM to learn whether it supplies a reliable ready state.

3. Fix Playwright Frame Was Detached After a Route Change

A click may cause a top-level navigation that destroys all child frames from the old page. Code that starts the click without awaiting it and immediately reads an iframe creates an avoidable race. Await the navigation-triggering action, then locate content on the destination. Do not retain a Frame from before the route change.

This test intercepts a destination URL so it is independent of an external server. It clicks a link, waits for the page's final URL, and asserts content served by that route.

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

test('finishes navigation before using destination content', async ({ page }) => {
  await page.route('https://app.example.test/next', route =>
    route.fulfill({
      status: 200,
      contentType: 'text/html',
      body: '<h1>Next page</h1><iframe title="Help" srcdoc="<p>Ready</p>"></iframe>',
    })
  );
  await page.setContent(
    '<iframe title="Old panel" srcdoc="<p>Old</p>"></iframe>' +
    '<a href="https://app.example.test/next">Continue</a>'
  );

  await page.getByRole('link', { name: 'Continue' }).click();
  await expect(page).toHaveURL('https://app.example.test/next');
  await expect(page.frameLocator('iframe[title="Help"]').getByText('Ready'))
    .toBeVisible();
});

Save as tests/frame-navigation.spec.ts; run npx playwright test tests/frame-navigation.spec.ts. Playwright's click waits for initiated navigations, and the URL assertion makes the destination explicit. If the application changes routes without a document navigation, assert the new route's UI instead. Do not assume that waitForLoadState('networkidle') means a dynamic iframe has stabilized; an active analytics or streaming connection can make that state unsuitable. When the failure is a navigation timeout rather than detachment, see the Playwright timeout guide.

4. Fix Playwright Frame Was Detached Around a Third-Party Widget

Payment, identity, and support widgets can replace their own iframe after initialization, consent changes, or authentication. Your test may have no control over that lifecycle. Decide whether the test must validate the vendor UI or only your app's integration contract. For a host-app regression test, mock the external endpoint and assert the host's visible state. For a vendor end-to-end test, use a sandbox account and a documented ready signal, and expect occasional external outages to need separate diagnosis.

The example below verifies the host contract by intercepting a widget document request. It avoids relying on a live vendor and still checks that the correct iframe exists and its content can be reached.

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

test('renders a controlled widget response', async ({ page }) => {
  await page.route('https://widget.example.test/embed', route =>
    route.fulfill({
      status: 200,
      contentType: 'text/html',
      body: '<h1>Widget ready</h1><button>Confirm</button>',
    })
  );
  await page.setContent(
    '<iframe title="Support widget" src="https://widget.example.test/embed"></iframe>'
  );

  const widget = page.frameLocator('iframe[title="Support widget"]');
  await expect(widget.getByRole('heading', { name: 'Widget ready' }))
    .toBeVisible();
  await widget.getByRole('button', { name: 'Confirm' }).click();
});

Save as tests/frame-widget.spec.ts and verify with npx playwright test tests/frame-widget.spec.ts. The Playwright network interception guide explains related routing patterns.

5. Fix Playwright Frame Was Detached When Async Actions Overlap

A helper that starts page.goto() without awaiting it can remove the frame while the caller is still querying the old page. Similar problems arise from forEach(async ...), floating promises, and a cleanup hook that closes a page before the test finishes. The fix is to give each page transition a clear awaited boundary. Parallelize independent work, but do not run two conflicting navigations against the same page.

This small test uses a named helper that returns its promise. The caller awaits the helper before using the page, so there is no hidden navigation outstanding.

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

async function openReceipt(page: Page): Promise<void> {
  await page.goto('https://app.example.test/receipt');
}

test('awaits its page helper', async ({ page }) => {
  await page.route('https://app.example.test/receipt', route =>
    route.fulfill({
      status: 200,
      contentType: 'text/html',
      body: '<iframe title="Receipt" srcdoc="<p>Paid</p>"></iframe>',
    })
  );

  await openReceipt(page);
  await expect(page.frameLocator('iframe[title="Receipt"]').getByText('Paid'))
    .toBeVisible();
});

Save as tests/frame-async.spec.ts. Verify with npx playwright test tests/frame-async.spec.ts --repeat-each=5.

6. Fix Playwright Frame Was Detached When Tests Share Mutable State

Playwright Test supplies an isolated page fixture to each test, but application state can still be shared. Two workers may sign in as the same account, replace the same draft, or alter a feature flag. The first test's app responds by rerendering its iframe while the second is using it. This is an application data collision, not a reason to cache a frame longer. Give each worker a distinct record or account; if isolation is impossible, run the conflicting group serially while you repair the data design.

The following test keeps each worker's iframe in its own page and uses testInfo.workerIndex to label the data. In a real app, use the index to allocate an independent server-side tenant or account as well.

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

test('keeps a frame tied to its own worker data', async ({ page }, testInfo) => {
  const label = 'Worker ' + testInfo.workerIndex;
  await page.setContent(
    '<iframe title="Draft" srcdoc="<p>' + label + '</p>"></iframe>'
  );
  await expect(page.frameLocator('iframe[title="Draft"]').getByText(label))
    .toBeVisible();
});

Save as tests/frame-isolation.spec.ts and run npx playwright test tests/frame-isolation.spec.ts --workers=2 --repeat-each=4. The flaky test root cause guide covers evidence collection across repeated runs.

7. Diagnose CI Resource Pressure Before Changing Timeouts

A browser process that crashes or a container killed for memory can surface as canceled navigation or closed-page errors near frame detachment. The exact failure message matters: a real framedetached event identifies a frame lifecycle change, while a crash event points to a different cause. Keep a trace for the first retry and reduce CI workers to one while collecting evidence. The official CI guide recommends one worker as a stable starting point for constrained runners.

Use this complete configuration if your test files live in tests/. It records a trace on the first retry, then permits the local default worker count.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  retries: process.env.CI ? 1 : 0,
  workers: process.env.CI ? 1 : undefined,
  use: { trace: 'on-first-retry' },
});

Save as playwright.config.ts in a sample project and verify with:

CI=1 npx playwright test tests/frame-smoke.spec.ts --workers=1

The frame-smoke.spec.ts file is defined in the TL;DR. For a failing application test, run that file instead and inspect the trace with npx playwright show-trace <path-to-trace.zip>, replacing the placeholder with the path printed by your run. The GitHub Actions Playwright guide covers pipeline setup.

8. Fix Container-Only Frame Failures at the Environment Boundary

Docker does not inherently detach iframes. It can, however, change the conditions under which a widget loads: localhost inside the test container refers to that container, the browser may lack system dependencies in a custom image, and a mismatched Playwright image can lack the browser executable expected by the installed package. Fix the environment before treating every canceled request as a timing problem.

For a project with an existing tests/frame-smoke.spec.ts, this command runs the deterministic smoke test in the official image. Replace the tag placeholder with a published tag matching your installed @playwright/test package. The image contains browser binaries and dependencies, but your project still needs npm ci.

docker run --rm -v "$PWD:/work" -w /work   mcr.microsoft.com/playwright:v<your-playwright-version>-noble   sh -c 'npm ci && npx playwright test tests/frame-smoke.spec.ts'

Verify the project package version with npm ls @playwright/test before selecting the image, then run the Docker command. If the smoke test passes in the container but the real integration test fails, probe the application and widget URLs from that same container. For a Compose service named app, use http://app:<internal-port> inside the test service, not the host's loopback address. The official Docker guide documents image matching, and the Docker for Playwright guide covers practical setup. If a request is blocked by CSP or an authentication redirect, record its status and console output; waiting longer will not change that response.

How to Verify the Fix

First run the smallest test that used to fail, using its exact browser project and data. Add a temporary event log to prove whether the iframe is truly removed. Attach listeners before the action that triggers the problem:

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

test('records the frame lifecycle', async ({ page }) => {
  const events: string[] = [];
  page.on('frameattached', frame => events.push('attached ' + frame.name()));
  page.on('framedetached', frame => events.push('detached ' + frame.name()));

  await page.setContent(
    '<iframe name="checkout" srcdoc="<p>Ready</p>"></iframe>'
  );
  const detached = page.waitForEvent('framedetached');
  await page.locator('iframe[name="checkout"]').evaluate(element =>
    element.remove()
  );
  const removedFrame = await detached;
  expect(removedFrame.name()).toBe('checkout');
  expect(events).toContain('detached checkout');
});

Save as tests/frame-events.spec.ts and run npx playwright test tests/frame-events.spec.ts. In your real test, place the listeners before the suspect click or navigation and write the captured names and URLs to the test log. A framedetached event near the failed call supports the replacement hypothesis; absence of that event invites inspection of the navigation error, page close, browser crash, or server response. Frame events are page-scoped, so attach them to the page that owns the iframe.

Next run the repaired test repeatedly with npx playwright test tests/<failing-file>.spec.ts --repeat-each=10 --workers=1, substituting the actual path. Then restore your normal worker count and repeat. Compare both runs: a failure only under parallel load points toward shared state or resources. Review the trace at the failing step, including its DOM snapshot before the action and any navigation requests. Finally run the whole relevant suite in CI or a matching container. One green local retry is weaker evidence than a trace-backed passing run in the environment where the error appeared.

Prevent It From Coming Back

Use frame locators built from meaningful iframe attributes. Keep page transitions in one awaited flow, and assert an observable ready state before interacting with a newly mounted widget. Prefer web-first assertions over arbitrary waitForTimeout() calls. A sleep can end during the next iframe replacement and make the error less reproducible.

Give each parallel test distinct server-side data. Keep vendor-dependent checks separate from host-app contract tests so a third-party outage has a narrow failure signature. Preserve traces for retries in CI and review failures instead of raising retries until the suite looks green. The official retries guide classifies a test that passes only after retry as flaky; that status is an investigation signal.

During code review, look for a Frame or ElementHandle stored across clicks, route changes, modal closes, and rerenders. Replace it with a current locator when the DOM can change. If a test truly needs a Frame object for a special API, reacquire it after the transition and check isDetached() before using it. This check is diagnostic, not a lock against a later detach; the application can still remove the frame between the check and the next operation.

Interview Questions and Answers

Q: What does a detached frame mean in Playwright?

The browser removed a particular frame from the page's frame tree. A saved Frame object still refers to that old identity and cannot address a replacement iframe. I check framedetached events and the application action that preceded them.

Q: Why is a FrameLocator usually safer than page.frame()?

A frame locator describes how to find an iframe when an action runs. page.frame() returns the specific current Frame, which becomes stale if the iframe is recreated. I still need a unique selector and a ready-state assertion.

Q: Can I fix the error by raising the test timeout?

Only if the actual problem is that the correct frame appears late and remains stable. A removed frame will not return because the clock runs longer. I inspect frame events and trace snapshots before changing budgets.

Q: How do you distinguish iframe replacement from page navigation?

I record frameattached, framedetached, and URL changes around the failing action. A child frame disappearing while the main page stays put suggests a widget rerender. A destination URL change suggests that queries against the previous document are misplaced.

Q: What if the iframe belongs to a payment provider?

I separate the host integration contract from a sandbox end-to-end test. The contract test uses a controlled widget response and asserts iframe wiring; the sandbox test waits on provider-documented UI and preserves request evidence for external failures.

Q: Why can the error happen only in CI?

CI may run more tests together, use shared accounts, have fewer resources, or reach a different application address. I compare one-worker and normal-worker runs, inspect traces and browser logs, and probe URLs from the CI environment.

Common Mistakes

  • Saving const frame = page.frame(...) in a suite-level variable and expecting it to survive page transitions. Reacquire the frame or use a frame locator.
  • Adding waitForTimeout(5000) after every iframe action. Wait for a specific user-visible ready state instead.
  • Assuming a framedetached message proves Playwright itself removed the iframe. Application JavaScript, navigation, and embedded providers can initiate removal.
  • Treating net::ERR_ABORTED; maybe frame was detached? as conclusive evidence of a detached child frame. Inspect navigation, response, and page events.
  • Increasing retries without opening a trace. A retry may hide shared state or a widget lifecycle defect.
  • Using iframe alone as a selector when several frames exist. Match a stable title, name, or test id and verify uniqueness.
  • Reusing the same account across parallel tests while the app streams updates into an iframe. Isolate the data or serialize that scenario.
  • Copying a Docker image tag from another project. Match the installed Playwright package version and confirm browser availability.

Conclusion

To fix Playwright Frame was detached, identify which frame identity disappeared and what action removed it. For a replaced iframe, use a current frame locator and assert the widget's ready state. For navigation, concurrency, or container-only failures, repair the responsible boundary and verify the same test in the environment that originally failed.

Interview Questions and Answers

How would you triage a Playwright Frame was detached failure?

I identify the failing API call and attach page frameattached and framedetached listeners before the triggering action. Then I inspect the trace and compare the failing frame's name and URL with the current iframe. If no frame removal occurred, I move to navigation, page close, or browser crash evidence.

What is the difference between Frame and FrameLocator?

Frame is a handle to a particular frame identity at a point in time. FrameLocator stores a way to locate an iframe when an operation runs. I use FrameLocator for dynamic embedded UI and reacquire a Frame only when a Frame-specific API is necessary.

Why might a React rerender produce this error?

A component can unmount an iframe and mount a new element with the same attributes. The old Frame object remains detached, although the page looks unchanged. I assert a stable inner state in the new iframe and investigate why the component remounts.

How do you test a third-party iframe reliably?

I put host wiring under a controlled network response and keep a separate sandbox integration test for provider behavior. The live test waits for a provider-specific ready condition and records network and frame events. This separates application defects from external availability.

Why is waitForTimeout a weak solution?

A fixed sleep has no relationship to iframe readiness and may finish before or after a replacement. A web-first assertion waits for a concrete state, such as a visible heading inside the intended frame. That condition is both faster on healthy runs and more informative on failure.

What evidence points to parallel test interference?

The failure appears with multiple workers and disappears with one, while tests share an account or mutable record. I inspect server-side updates and allocate distinct data per worker. Serial execution can be a temporary diagnostic but does not establish isolation.

How would you distinguish a detached frame from a browser crash?

A detached child frame yields a framedetached event while the page can continue. A browser crash produces a page crash or closed-context signal and often additional process logs. I compare event order and trace coverage rather than relying on the final line alone.

Frequently Asked Questions

What does Frame was detached mean in Playwright?

The frame involved in an operation was removed from the page's frame tree. A saved Frame object refers to that removed identity even if a visually identical iframe appears afterward. Inspect framedetached events to learn when it disappeared.

How do I fix a detached iframe in Playwright?

Locate the current iframe with page.frameLocator() and assert meaningful content inside it before acting. If the app repeatedly replaces the iframe, diagnose its loading or rerender behavior; a locator alone cannot stabilize an unstable widget.

Does FrameLocator automatically fix every frame detach?

No. It resolves the iframe for an action, which avoids retaining an old Frame object. The action may still fail if the iframe is removed during the operation or the selector matches the wrong frame.

Can increasing a Playwright timeout fix Frame was detached?

A timeout can help only when the correct frame eventually becomes ready and stays present. It cannot revive a frame that has been removed. Use event logs and a trace to distinguish delay from replacement.

Why does the error happen after clicking Continue?

The click may navigate the top-level page and destroy child frames from the old document. Await the click, assert the destination URL or UI, and then create a locator for content on the new page.

Why does the failure occur only in CI?

CI can expose data collisions between workers, browser crashes under resource pressure, or network differences inside a container. Compare worker counts, review traces and browser logs, and probe the same URLs from the CI runtime.

Is net::ERR_ABORTED; maybe frame was detached? the same error?

It is a different navigation error string that suggests a possible detachment but does not prove one. Check URL changes, request failures, and frame events before treating it as an iframe replacement.

Related Guides