A test that passes on first load but fails after a tab is closed, reopened, or restored from back-forward cache is usually not “random.” It is often a state or lifecycle bug: a stale element handle, a page object that outlives the page, a listener attached to the wrong context, or app code that assumes a full reload instead of a history traversal.

If you need one bottom-line rule, it is this: debug Playwright tests after bfcache restore by treating the page as a new lifecycle state, then prove which references, listeners, and assumptions survive that transition. The browser may keep the document alive in bfcache, but your test code should not assume the same JS objects, handles, or network state still apply.

Playwright’s documentation is the right place to anchor your mental model, and MDN’s bfcache material is useful for the browser side of the story. The rest of this article is about turning that model into a reproducible debugging path.

First, separate three different failure types

These failures get lumped together, but they are not the same.

1. Closed tab or page context gone

This is the simplest case. The page, context, or popup was actually closed, and the test kept using an object that no longer exists. The symptom is often a detached-page style error, or a timeout waiting for an action on a dead page.

2. Reopened tab, but stale handles remain

The browser opens a new tab or page, but your test still references the old page, locator, or ElementHandle. The new page looks similar, but the automation object is not attached to it.

3. History traversal restored the page from bfcache

This is the subtle one. A back/forward navigation can restore a page from cache instead of recreating it from scratch. That means some app state survives, but not everything behaves like a cold load. If your app relies on load events, reconnects websockets incorrectly, or your test waits on the wrong signal, the failure can show up only after page.goBack() or browser navigation that hits history.

If your test only fails after back navigation, do not start by adding sleeps. Start by asking whether the app experienced a real reload or a history restore.

What usually breaks in Playwright suites

Stale element references disguised as locator problems

Playwright locators are usually resilient, but many suites still mix locators with older patterns such as storing ElementHandle values, DOM nodes, or text snapshots too early. Those objects can go stale when the page is replaced or restored.

A safer rule is to resolve elements as late as possible:

import { expect, test } from '@playwright/test';
test('re-reads the button after navigation', async ({ page }) => {
  await page.goto('https://example.com');

  const saveButton = page.getByRole('button', { name: 'Save' });
  await saveButton.click();

  await page.goBack();

  await expect(page.getByRole('button', { name: 'Save' })).toBeVisible();
});

If this fails, the bug may be in the app, the selector, or the lifecycle assumption. It is less likely to be a “locator flake” than a state continuity issue.

Page object models that cache too much

A page object that stores const submitButton = page.locator(...) is fine if page is stable. A page object that stores const submitHandle = await page.$(...) and reuses it across tab changes is a liability.

The difference matters more after tab close/reopen events, because the object graph can survive in your test code while the browser document does not.

Wrong event listener or wait strategy

After a restore, your app may emit pageshow rather than a fresh load. If your code waits on load only, the test can hang or race. If your app reruns initialization on every history traversal without checking state, it can double-bind handlers or duplicate requests.

MDN’s pages on bfcache and the pageshow event are helpful when you need to confirm what the browser is doing.

A reproducible debugging workflow

1. Reduce the test to one lifecycle transition

Do not debug the whole flow first. Isolate the exact transition that breaks:

  • close the active tab
  • open a new tab and switch to it
  • navigate away and then goBack()
  • reload versus history back, because those are not equivalent

Write a tiny test that does only the lifecycle step and one assertion after it.

import { test, expect } from '@playwright/test';
test('restores state after back navigation', async ({ page }) => {
  await page.goto('https://your-app.example/profile');
  await page.getByRole('link', { name: 'Settings' }).click();
  await page.goBack();

  await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
});

If the reduced test fails, you have a lifecycle bug. If it passes, the larger test probably leaks state from earlier steps.

2. Log the page lifecycle signals

For bfcache and restore bugs, add explicit logging for lifecycle events. This is more useful than a generic screenshot.

typescript page.on(‘console’, msg => console.log(‘browser:’, msg.text()));

await page.evaluate(() => {
  window.addEventListener('pageshow', e => {
    console.log(`pageshow persisted=${e.persisted}`);
  });
});

If pageshow.persisted is true after back navigation, the page came from bfcache. That tells you to inspect state restoration, not just DOM rendering.

3. Check whether you are holding stale references

Search the failing test and page object for these patterns:

  • ElementHandle
  • stored DOM nodes from evaluate
  • cached Frame or Page references after a popup switch
  • Promise.all around navigation with an action that no longer targets the active page

If a stored reference crosses a close/reopen boundary, assume it is invalid until you prove otherwise.

4. Verify whether the app depends on reload-only behavior

A lot of SPA code is built around the assumption that navigation equals a full refresh. That fails when the browser restores from bfcache.

Look for code that:

  • only runs in window.onload
  • resets state in a way that assumes a cold start
  • re-fetches data unnecessarily on every pageshow
  • fails to reattach event listeners after history traversal

A test that fails only on restore may be exposing a product bug, not a Playwright bug.

What to inspect in CI when local runs are clean

CI adds slow machines, headless browser differences, and timing changes, but the debugging method is still the same.

Capture the right artifacts

For this class of failure, screenshots alone are not enough. Prefer:

  • trace files
  • console logs
  • page lifecycle logs
  • network logs around the navigation boundary
  • video only if the UI state is visually ambiguous

Playwright tracing is especially useful because it shows the sequence of actions, waits, and navigation events in one place.

Confirm browser and engine differences

A history restore path can differ by browser engine, browser version, and headless versus headed execution. If the failure appears only in CI, compare:

  • Chromium versus WebKit or Firefox
  • local browser version versus CI image version
  • headless defaults versus headed local debugging

Do not assume a failure is “Playwright-specific” until you have ruled out the browser engine and version.

Make the CI job report lifecycle evidence

If the suite runs the same test on multiple browsers, include the browser name in the failure output. That gives you a quick way to see whether the issue is engine-specific.

name: e2e
on: [push, pull_request]

jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright test –project=chromium

This does not solve the bug, but it makes the failure easier to classify.

A decision table for the symptom you are seeing

Symptom Most likely cause First thing to check Good next step
detached page or closed target error Page or context was actually closed Are you using a page object after close? Recreate the page reference at the point of use
Passes before back navigation, fails after it bfcache or history traversal changed lifecycle Did pageshow fire with persisted=true? Inspect reload-only app logic
Works locally, fails in CI Browser version, timing, or engine mismatch Which browser and version is CI using? Capture trace and lifecycle logs
Intermittent after popup/tab switch Wrong page object or stale handle Are you reusing a Page from before the switch? Switch to explicit page selection and late-bound locators

A small set of fixes that usually pays off

Replace cached element handles with locators

If you still have old-style handle usage, replace it first. Locators are a better fit for dynamic pages because they re-resolve when needed.

Create page objects that accept a live page, not a frozen snapshot

A page object should be a thin wrapper around the current page instance, not a container for DOM references that outlive lifecycle transitions.

Make restore-aware waits explicit

If your app can return from bfcache, wait for the signal that actually means “usable,” not the signal that means “full reload completed.” That may be a heading, a specific network idle condition, or a stable application marker.

Treat tab closures as ownership boundaries

If a test closes a tab, the code that created that tab should also dispose of or replace every reference that depended on it. That sounds obvious, but this is where hidden state leaks into suites.

When the bug is in the app, not the test

Sometimes the test is correctly exposing a browser lifecycle bug in the product. A few examples:

  • event listeners are added on every restore, causing double-submit behavior
  • auth or session code assumes a reload and loses state on history traversal
  • websocket connections are not re-established after restore
  • form state is wiped because initialization code runs unconditionally on pageshow

That is useful signal. A flaky test is bad, but a reliable test that consistently reproduces a lifecycle defect is better than a silent production bug.

Not the best debugging path if you are still on the first failure

Skip the bfcache-specific deep dive if the error is obviously different, for example:

  • a plain selector typo
  • a network 500 before the navigation boundary
  • a test that fails before any tab change happens
  • a browser permission issue unrelated to restore behavior

In those cases, start with the ordinary Playwright debugging loop, then come back to lifecycle analysis only if the failure follows the tab transition.

A compact checklist I would use in triage

  1. Reproduce with one navigation or tab transition only.
  2. Replace any cached ElementHandle usage with locators.
  3. Log pageshow, pagehide, and the relevant app-ready signal.
  4. Confirm whether the page was restored from bfcache.
  5. Compare Chromium, Firefox, and WebKit if the issue is browser-specific.
  6. Decide whether the fix belongs in the test, the app, or both.

FAQ

Is bfcache the same as reload?

No. A reload recreates the page. A bfcache restore can bring the page back from cache with a different lifecycle path, so reload-only assumptions often break.

Why does a Playwright test fail only after I go back to a previous page?

Because back navigation may restore state differently than the first visit. The failure is usually a stale reference, missing restore-aware wait, or app code that does not handle pageshow correctly.

Should I disable bfcache to make tests pass?

Usually no. That hides the lifecycle bug instead of fixing it. It is better to make the app and test robust against restore behavior unless you have a very specific reason to exclude it.

What is the fastest way to confirm a stale handle problem?

Search for ElementHandle, stored DOM nodes, or references created before close/reopen navigation. Then replace them with late-bound locators and rerun the reduced test.

Does this only affect SPAs?

No. SPAs make it easier to hit because they often manage their own navigation state, but multi-page apps and popup-heavy flows can fail for the same reason.