Modern web apps often pass the obvious navigation test and still fail the one users hit most, going back. A page can look correct on first load, yet restore stale UI, duplicate network work, lose form state, or replay side effects when the browser brings it back from the back-forward cache, or bfcache. If you only verify that the page renders, you miss a whole class of regressions that live in browser history behavior, not in the happy path.

This tutorial shows how to test bfcache state restoration with the same discipline you would use for any critical workflow. The goal is not just to click Back and see whether the page appears. The goal is to prove that a page survives browser history testing with the right state, the right events, and the right user-visible behavior after forward and back navigation.

What bfcache changes about testing

The back-forward cache is a browser optimization that keeps a page alive in memory so the user can return to it instantly using Back or Forward. Instead of reloading from scratch, the browser may freeze the page, then restore it later. That means your app may not get the same lifecycle events it gets on a full reload.

For QA, this creates a trap:

  • Some bugs only appear when the page is restored from bfcache, not when it is reloaded.
  • Some cleanup logic runs on unload or assumes a new document, but bfcache skips that path.
  • Some state lives in JS memory, and comes back unexpectedly intact.
  • Some code incorrectly assumes DOMContentLoaded or initial data fetching always runs on return.

If your test plan only covers reloads, you are not testing the same user journey that the browser actually optimizes for.

The browser events that matter most here are pageshow, pagehide, visibilitychange, and, in some cases, popstate. A correct test strategy checks both what the user sees and which events your app relied on.

What can break on back button recovery

Common regressions include:

  • A spinner or loading skeleton stays forever because the page expected a fresh mount.
  • A form restores old DOM values, but app state in memory does not match.
  • A shopping cart or wizard step duplicates entries after returning.
  • An authenticated screen reuses a stale access token or stale data view.
  • A WebSocket or polling loop resumes twice.
  • A modal or drawer is open when it should have been closed.
  • Scroll position restores incorrectly, especially on long pages or virtualized lists.
  • A feature flag or personalization snapshot becomes stale.

These bugs often hide behind a pass on first load because the code path is different when the document is restored from the cache.

A practical test model for bfcache

A good test for page state restoration needs three layers of assertion:

  1. Navigation outcome, did Back and Forward return the user to the right page?
  2. Visible state, did the page show the right data, form values, scroll position, and UI controls?
  3. Lifecycle behavior, did the app respond correctly to restore events, without repeating side effects?

That third layer is what separates real browser history testing from a shallow click-through.

Define the critical states before you automate

Before writing code, decide which state on the page is supposed to survive a history restore and which state is supposed to reset.

Examples:

  • Should a typed draft survive? Usually yes.
  • Should an open toast survive? Usually no.
  • Should a search result list preserve its scroll position? Often yes.
  • Should an in-memory temporary authorization nonce survive? Usually no.
  • Should client-side timers continue from where they stopped? It depends on the feature.

Write the expectations down in the test case. That prevents the classic mistake of letting automation assert whatever the UI happens to do.

Build a page that exposes restoration behavior

If you are testing a real app, you can instrument it with logging around browser lifecycle events. If you are building a sample project or a repro page, add this small observer first:

<script>
  const log = (msg) => {
    const el = document.getElementById('events');
    el.textContent += msg + '\n';
  };

window.addEventListener(‘pageshow’, (e) => log(pageshow persisted=${e.persisted})); window.addEventListener(‘pagehide’, (e) => log(pagehide persisted=${e.persisted})); document.addEventListener(‘visibilitychange’, () => log(visibility=${document.visibilityState})); </script>



That little logger helps you tell the difference between a reload and a bfcache restore. The pageshow event exposes whether the page was persisted.

Why this matters

A page can appear correct while still leaking state across navigation. Event logs help you verify that your app does not rely on a reload-only lifecycle.

The test matrix that catches most hidden regressions

When you test bfcache state restoration, do not limit yourself to a single Back click. Use a small matrix that covers the ways users actually move around:

  • Back from page B to page A
  • Forward from page A to page B
  • Back after submitting a form
  • Back after opening a modal
  • Back after scrolling a long list
  • Back after a network request finishes, then another after it is still in flight
  • Back after login, logout, or token refresh

For each flow, verify whether the page should be restored from cache or reloaded, then assert the visible state that matters to users.

Playwright example for browser history testing

Playwright is a good fit for this kind of flow because it makes tab-level navigation and event inspection straightforward.

import { test, expect } from '@playwright/test';
test('restores search state when navigating back', async ({ page }) => {
  await page.goto('https://example.test/search');
  await page.fill('[data-testid="query"]', 'laptop');
  await page.click('[data-testid="search"]');
  await expect(page.locator('[data-testid="results"]')).toContainText('laptop');

await page.goto(‘https://example.test/product/123’); await page.goBack();

await expect(page.locator(‘[data-testid=”query”]’)).toHaveValue(‘laptop’); await expect(page.locator(‘[data-testid=”results”]’)).toContainText(‘laptop’); });

This is a useful start, but it is not enough by itself. It verifies the UX outcome, not the browser mechanism. For that, add event capture in the app or a helper endpoint that records lifecycle events.

Add restore-event assertions when the bug is lifecycle-driven

typescript

await page.evaluate(() => {
  window.__events = [];
  window.addEventListener('pageshow', (e) => window.__events.push(`pageshow:${e.persisted}`));
  window.addEventListener('pagehide', (e) => window.__events.push(`pagehide:${e.persisted}`));
});

await page.goBack();

const events = await page.evaluate(() => window.__events);
expect(events.some((e) => e.startsWith('pageshow:'))).toBeTruthy();

If your app has a known bfcache bug, this kind of observation is often the fastest way to reproduce it in CI.

What to assert on the page

For each flow, choose assertions that reflect the actual failure mode.

Form state

  • Input values remain correct.
  • Disabled buttons do not re-enable incorrectly.
  • Validation errors do not disappear or duplicate.
  • Unsaved drafts are not lost.

List and table state

  • Filter chips match the restored query.
  • Scroll position is preserved where expected.
  • Infinite scroll does not fetch duplicate pages.
  • Virtualized rows render the right items after restore.

App state and side effects

  • Polling does not start twice.
  • WebSockets do not reconnect unnecessarily.
  • Analytics events are not double-fired.
  • Toasts or ephemeral banners do not reappear.
  • The browser URL matches the restored screen.
  • The active tab or step remains correct.
  • The page title is accurate after restore.

Detecting whether the page was restored from bfcache

Different browsers expose slightly different signals, but in practice pageshow.persisted is the most useful simple check. When true, the page came from bfcache.

A compact client-side probe can help your tests branch based on restore behavior:

window.__restoration = { pageshowPersisted: null };
window.addEventListener('pageshow', (e) => {
  window.__restoration.pageshowPersisted = e.persisted;
});

Then in the test:

typescript

const wasPersisted = await page.evaluate(() => window.__restoration.pageshowPersisted);
expect(typeof wasPersisted).toBe('boolean');

This is especially helpful when a test is flaky because the browser sometimes restores from cache and sometimes reloads, depending on the page’s eligibility.

Common reasons pages fail bfcache eligibility

A test that exercises browser history should also reveal why the page was not cached at all. Common causes include:

  • unload handlers
  • open WebSocket connections or active transport that blocks caching in some browsers
  • long-running timers or audio playback
  • certain cross-origin interactions
  • use of APIs or patterns that prevent cache restoration

The specifics vary by browser and version, so your test plan should not assume one behavior everywhere. Use official browser docs and run cross-browser checks on the flows that matter most.

A useful reference for the broad idea of browser caching and automation is the general testing and automation background on software testing and test automation, but the browser-specific rules still need empirical verification in your app.

Designing tests that do not lie to you

A common mistake is to write a test that always uses page.reload() or hard refresh and then claim that back button behavior is covered. It is not.

A better structure is:

  1. Navigate to page A.
  2. Perform user actions that create state.
  3. Navigate to page B.
  4. Use Back.
  5. Check restored state and side effects.
  6. Optionally use Forward and confirm the state remains consistent.

If you need to validate a form-heavy flow, build a specific test around the exact field interactions users perform. If you need to validate content pages, focus on scroll and scroll-linked UI. If you need to validate dashboards, check subscriptions and network calls.

The best bfcache tests are small, opinionated, and tied to one observable regression class.

Preventing false positives in automation

Browser history testing can be flaky for reasons unrelated to the app. Reduce noise by following a few rules:

  • Wait for a stable visible condition, not just navigation completion.
  • Avoid brittle selectors, especially in dynamic layouts.
  • Keep one test focused on one navigation path.
  • Reset server state between tests if the page uses persisted data.
  • Capture screenshots or logs only when a restoration assertion fails.

If your UI changes often, locator brittleness can mask real navigation failures. This is one place where a self-healing workflow can help maintain coverage. For example, Endtest’s self-healing tests use agentic AI to recover broken locators and keep runs moving when the DOM shifts, which can be useful when your goal is to replay real browser flows and preserve evidence around state restoration failures. For implementation details, their self-healing tests documentation explains how the healing behavior is applied inside the platform.

A Playwright pattern for tracing flakiness

When a back button test flakes, capture the navigation path and final UI state in the same run.

import { test, expect } from '@playwright/test';
test('captures back navigation evidence', async ({ page }) => {
  const steps: string[] = [];
  page.on('framenavigated', (frame) => {
    if (frame === page.mainFrame()) steps.push(frame.url());
  });

await page.goto(‘https://example.test/a’); await page.click(‘[data-testid=”open-b”]’); await page.goBack();

const url = page.url(); const value = await page.locator(‘[data-testid=”state-field”]’).inputValue(); steps.push(final:${url}, field:${value});

expect(steps.length).toBeGreaterThan(0); });

In a real suite, this kind of lightweight evidence is often enough to debug whether the failure happened during navigation, after restoration, or only after the app resumed work.

Cypress note: what to watch for

Cypress can test browser history flows, but because it wraps browser actions in its own command chain, you should be careful about timing. Assert only after the UI settles, and do not assume a route change implies a fresh document. If your application depends on pageshow behavior, verify it with direct DOM or app instrumentation rather than relying only on URL changes.

Edge cases worth adding to your checklist

Here are the edge cases that are easy to miss:

  • A form with both controlled and uncontrolled inputs
  • A page with auto-save that fires on blur, then reopens through Back
  • A filter state stored in both the URL and in-memory store
  • A page that uses sessionStorage and expects it to reflect the restored route
  • A page with a long list where only part of the DOM is virtualized
  • A route guarded by auth, then restored after a short token refresh window
  • A page with media, embedded maps, or chat widgets

These edge cases often fail in subtle ways, such as stale labels, duplicate requests, or scroll jumps, while the page still appears generally usable.

How to structure a minimal regression suite

If you are introducing bfcache coverage into an existing repo, start with three tests:

  1. A form draft test, verifies state survives back navigation.
  2. A scroll restoration test, verifies the user returns near the same viewport.
  3. A side-effect test, verifies a network call or subscription does not duplicate.

That small suite catches more real defects than a large set of shallow navigation checks.

For teams formalizing browser flow coverage, it helps to keep a compact internal workflow guide for navigation-heavy tests, plus a separate note for flaky browser-history cases. If you already have a tutorial on browser flows, link it from the same test plan so engineers know where to look when back button behavior changes.

CI considerations

Browser history tests are good candidates for CI, but only if the environment is stable enough to keep signal high. Use the following practices:

  • Run against a consistent browser matrix, at least Chromium plus one secondary engine.
  • Keep test data isolated per run.
  • Fail the build on state restoration mismatches, not just console errors.
  • Store traces or logs for failures involving history navigation.
  • Re-run only after capturing evidence, so intermittent bfcache behavior can be diagnosed.

If your app relies heavily on route transitions, add one history-focused smoke test per critical journey. That gives you a fast warning when a change breaks navigation state.

A simple decision rule for coverage

Ask three questions:

  • Does the user return to this page with the Back button often?
  • Does the page carry meaningful local state, like a draft, scroll, or filter?
  • Would a subtle restore bug cause data loss, duplicate actions, or a broken workflow?

If the answer to any of these is yes, add a bfcache-aware test.

Final takeaways

To test bfcache state restoration well, think like the browser, not just like the route system. A robust test proves that Back and Forward preserve the user’s real state, that expected cleanup happens, and that hidden lifecycle regressions do not sneak into production because the page only breaks after a restore.

The practical recipe is simple:

  • Define which state should survive and which should reset.
  • Use real history navigation, not reloads.
  • Assert both UI state and lifecycle behavior.
  • Capture evidence when restoration fails.
  • Keep the suite narrow, because browser history testing is most valuable when each test targets one failure mode.

That is how you test bfcache state restoration without missing the hidden regressions that users notice first.