How to Test Browser Back-Forward Cache Behavior Without Mistaking Restoration Bugs for Test Flakes
By Luca Müller · August 26, 2026
A practical guide to bfcache testing, state assertions, event handling, and how to tell a restore-state bug from a flaky browser history navigation test.
A back button test can pass while the app is still wrong, or fail while the browser is doing exactly what it should. The usual trap is treating every history navigation as a reload. Modern browsers may restore a page from the back-forward cache, or bfcache, instead of reconstructing it from scratch. That changes which events fire, which state survives, and which assertions are meaningful.
If you want to test browser back-forward cache behavior reliably, the key is to test the restore path, not just the URL change. That means asserting the right lifecycle events, checking whether app state was preserved or intentionally reset, and separating browser restore behavior from actual application regressions.
bfcache and history navigation are not the same thing
A browser history navigation can end in two very different states:
- Reloaded page, the document is rebuilt and scripts start again.
- Restored page, the browser brings back a frozen page from bfcache.
The distinction matters because a restore from bfcache does not behave like a fresh load. The document can come back with DOM state, JavaScript heap state, and scroll position intact. Some events do fire, but not the same sequence you get on a normal navigation.
The most useful primary references here are the browser lifecycle docs and the Back/Forward Cache overview on MDN, plus the pagehide and pageshow events.
A test that expects a full reload every time will produce false failures on a page that restores correctly from bfcache.
What you should assert, and what you should not
For browser history navigation testing, the main question is not “did the URL change?” It is “did the page come back in the expected state for this navigation path?”
Good assertions
- The app preserves or clears state according to the product requirement.
- Event handlers reattach or remain active after restore.
- Polling, subscriptions, and timers do not double-register.
- The page shows the correct UI after navigating back and forward.
- Scroll position, focused element, and selected tab match the intended behavior.
Weak assertions
- A hard-coded expectation that
loadalways fires on back navigation. - A blanket wait for network requests after history navigation.
- A screenshot comparison that cannot distinguish restore from reload.
- An assumption that session storage, in-memory state, and DOM state are all reset together.
If you only want to know whether a page survives history navigation, assert user-visible state and lifecycle signals together. If you only assert one of them, you can miss a bug or misdiagnose a browser optimization as a defect.
The events that matter most
For bfcache testing, the two events that deserve special attention are pagehide and pageshow.
pagehidefires when the page is leaving the active view.pageshowfires when it becomes visible again, including restores from bfcache.
The pageshow event includes a persisted flag. When persisted is true, the page was restored from bfcache.
That gives you a practical assertion point:
- If
pageshow.persisted === true, verify restore-specific state. - If
pageshow.persisted === false, verify fresh-load state.
You can instrument this directly in the app under test or in a test harness page.
typescript // Attach on the page under test, or inject via evaluate() in a test harness. window.__navEvents = [];
window.addEventListener(‘pagehide’, (e) => { window.__navEvents.push({ type: ‘pagehide’, persisted: e.persisted }); });
window.addEventListener(‘pageshow’, (e) => { window.__navEvents.push({ type: ‘pageshow’, persisted: e.persisted }); });
Then in the test you can inspect whether a restore happened instead of assuming it.
import { test, expect } from '@playwright/test';
test('back navigation restores from bfcache or reloads as expected', async ({ page }) => {
await page.goto('https://example.com/account');
await page.click('a[href="/settings"]');
await page.goBack();
const events = await page.evaluate(() => window.__navEvents);
expect(events.some((e: any) => e.type === 'pageshow')).toBeTruthy();
});
The exact assertion should match your product requirement. The point is to observe the restore path explicitly.
A practical test pattern that avoids flaky history checks
The safest pattern is to write two layers of checks.
Layer 1: browser behavior probe
This layer answers whether the browser restored the page or reloaded it.
Assertions can include:
pageshow.persistedvalueperformance.getEntriesByType('navigation')entry type, where supported- Whether your app reinitialization marker ran again
Layer 2: product behavior assertion
This layer answers whether the application behaves correctly after the restore.
Assertions can include:
- Authenticated state still visible, or intentionally revalidated
- Previously entered form state still present, or intentionally cleared
- Lazy-loaded components still interactive
- WebSocket-dependent UI reconnects cleanly
A single test should not conflate these. If you do, failures become ambiguous. Was the browser wrong, or was your app wrong, or did the test simply expect the wrong lifecycle?
How to distinguish a real regression from cache-related browser flakiness
Cache-related browser flakiness usually shows up as one of three problems.
1. The page reuses stale in-memory state
This happens when app code assumes a full reload will always happen. A restored page may keep module state, event listeners, or singleton caches alive.
Look for symptoms like:
- duplicated click handlers
- stale API data still rendered after restore
- timers or polling loops continuing unexpectedly
The fix is usually to make lifecycle handling explicit. For example, initialize on first load, then refresh only the state that must be revalidated after pageshow.
2. The page is reinitialized too aggressively
This is the opposite problem. The app tears down and rebuilds state even when the browser restored the page. That can erase scroll position, form input, or transient UI state that users expect to keep.
Watch for:
- form drafts disappearing on back navigation
- focus jumping to the top of the page
- expensive re-fetches on every history action
3. The test assumes network activity proves behavior
A restored page may not make the same network calls as a reload. If your test waits for a request that never comes, it can time out even when the UI is correct.
For history navigation tests, prefer a UI or lifecycle assertion over a “wait for X network calls” pattern unless the network request is part of the explicit contract.
A focused Playwright example
This example checks whether a page returns with the right form state after a back navigation. It does not assume reload semantics.
import { test, expect } from '@playwright/test';
test('draft state survives back navigation as designed', async ({ page }) => {
await page.goto('https://example.com/editor');
await page.fill('[name="title"]', 'Draft title');
await page.click('a[href="/preview"]');
await page.goBack();
await expect(page.locator('[name="title"]')).toHaveValue('Draft title');
});
If the product requirement is the opposite, then assert that the field is cleared after restore. The important part is that the test encodes the intended UX, not a browser implementation detail.
Cases that need special attention
Single-page apps
SPAs often keep application state in memory, so back navigation can expose stale component state or duplicate subscriptions. Do not assume a route change means a clean lifecycle reset.
Check whether your router uses popstate and what it does on restore. The popstate event is easy to misuse because it is tied to history traversal, not full page teardown.
Hybrid apps and server-rendered pages
A hybrid app may load new HTML on navigation, then hydrate client state after restore. That creates mixed behavior, where some data comes from the server and some comes from the browser cache.
Verify:
- server-rendered markup does not conflict with restored client state
- hydration does not duplicate listeners
- cached DOM does not hide a missed re-render
Authentication and sensitive data
Some pages should not be eligible for bfcache, or should revalidate on restore. If back navigation can reveal sensitive data that should be cleared, treat that as a product and security issue, not a flaky test.
The test should prove the secure behavior, for example by confirming a re-auth screen appears or the protected view is invalidated after back navigation.
A simple debug checklist for flaky history tests
When a browser history test is unstable, check these points in order:
- Did the test wait for the same condition it actually needs, not for a fresh page load?
- Did the app register event listeners more than once?
- Are you asserting the right state, DOM, URL, or lifecycle marker?
- Does the page intentionally support bfcache restore, or should it force a reload?
- Is the failure browser-specific, which can point to a lifecycle compatibility issue rather than a test bug?
If the test cannot tell restore from reload, it is not yet testing the feature you think it is.
When to disable bfcache assumptions in a test suite
You usually should not try to force all browsers into one behavior. That hides the problem instead of modeling it. But there are cases where a test suite should avoid depending on bfcache at all:
- the flow is security-sensitive and must revalidate on return
- the app uses unstable timers or external widgets that are known to misbehave on restore
- the test is meant to validate navigation semantics, not browser caching
In those cases, make the assertion explicit. For example, check that the page revalidates, or that a stable app marker is recreated, rather than expecting a cached restore.
A practical decision rule
Use this rule of thumb:
- If the user expectation is “come back exactly where I was,” test restore state.
- If the user expectation is “come back to a fresh protected view,” test revalidation.
- If you only care that navigation works, do not assert bfcache details at all.
That keeps your suite honest. It also keeps you from spending debugging time on failures caused by a browser optimization that your app neither controls nor should ignore.
FAQ
How do I know if a page came from bfcache?
The most direct signal is pageshow.persisted === true. You can also inspect navigation timing data where supported, but pageshow is the clearest browser event for this case.
Should every back button test assert bfcache behavior?
No. Only assert it when page restore state is part of the requirement. Many tests should simply verify that the user ends up on the correct screen with the correct state.
Why do some tests fail only on back navigation?
Because back navigation can restore a page instead of reloading it. That changes lifecycle events, DOM persistence, and in-memory state, which can expose assumptions in the app or the test.
Is popstate enough to test browser history behavior?
No. popstate only tells you history traversal occurred. For restore behavior, combine it with pageshow, state assertions, and any app-specific reinitialization checks.
What is the biggest mistake teams make here?
They assume history navigation is equivalent to a reload. That leads to flaky waits, brittle event assumptions, and false conclusions about whether the app or the browser is at fault.