July 9, 2026
How to Test Browser Back/Forward Cache (bfcache) Without Missing State Restoration Bugs
Learn how to test browser back forward cache behavior, catch state restoration bugs, and verify page lifecycle handling with Playwright, Selenium, and cross-browser workflows.
When a user clicks Back, most frontend teams assume the page will either reload cleanly or restore from cache in a predictable way. The problem is that modern browsers do something more nuanced: they may restore the page from the back/forward cache, or bfcache, which brings the whole page back from memory instead of rebuilding it from scratch. That difference is exactly where state restoration bugs hide.
If you only test browser navigation with regular reloads, you can miss bugs that appear when timers resume, stale DOM state survives, analytics double-fire, or session data is out of sync with the visible UI. That is why teams that need reliable navigation flows eventually have to test browser back forward cache behavior explicitly, not just as part of generic smoke tests.
This guide explains what bfcache changes, which bugs usually slip through, and how to build practical tests around browser history state restoration, page lifecycle events, and cross-browser differences. It also covers when to use Playwright, Selenium, Cypress, or a platform like Endtest, an agentic AI test automation platform, for real-browser coverage across navigation and session continuity workflows.
What bfcache actually changes
The back/forward cache is a browser optimization that keeps a page in memory after you navigate away from it, so hitting Back or Forward can restore the page instantly. The page is not reloaded in the normal sense. JavaScript state, DOM state, scroll position, and some browser-managed state can come back as-is.
That means your app has two distinct return paths:
- A full reload, where your app bootstraps from scratch.
- A bfcache restore, where your previous page instance is resumed.
For testing, this distinction matters because many app assumptions are tied to load events, cleanup logic, and initialization code. If a page comes back from bfcache, code that only runs on load may not run again. Code that should be idempotent may not be. A stale websocket connection may still look connected. A timer may resume unexpectedly. A React component may render with old in-memory state but fresh server-side data.
The core bfcache test question is not “does Back work”, it is “does the page behave correctly when the browser restores a previous execution context?”
For browser background reading, MDN’s bfcache overview is a good starting point, and the page lifecycle model is worth reading alongside it.
Bugs that only show up on bfcache restore
Here are the common failure modes that generic navigation tests miss.
1. State is visually preserved, but business logic is stale
A page can look correct while internal data is wrong. Examples:
- A cart badge still shows the old quantity after a product removal elsewhere.
- A filter panel still displays selected options, but the data table is out of date.
- A form keeps field values, but server validation errors have disappeared or reappeared incorrectly.
This happens when UI state, in-memory stores, and server state are not reconciled on restore.
2. Event listeners are duplicated
If your page attaches listeners in pageshow, visibilitychange, or custom mount logic without guarding for repeated attachment, then a restored page can accumulate listeners. The result is double analytics, duplicate API calls, or UI actions firing twice.
3. Timers and polling resume in the wrong way
A page that polls every 10 seconds may continue polling after restore, even if the app expects a fresh start. Or the polling interval may get recreated, causing duplicate fetches. Long-running retry loops can also wake up on restore and flood the backend.
4. Session continuity breaks
Authentication state is often the hardest edge case. A page restored from bfcache may still show a signed-in user even after the session expired in another tab. If your app does not revalidate session status on resume, the user may interact with a dead session until an API call fails later.
5. Scroll position and focus are wrong
Browser-managed scroll restoration usually helps, but single-page apps sometimes override it. After a bfcache restore, you might end up with a page at the top, focus lost, or a modal reopened with broken keyboard state.
6. Third-party widgets behave inconsistently
Embedded chat, payment, maps, and analytics widgets often assume a full reload. Some are safe, some are not. Restore-from-cache can revive a DOM subtree with stale third-party script state.
The browser lifecycle events you need to care about
The test strategy starts with the lifecycle events most relevant to bfcache.
pageshow and pagehide
The most important pair is pagehide when leaving and pageshow when returning. On a bfcache restore, pageshow receives a persisted flag in browsers that support it, which indicates the page came back from cache.
visibilitychange
This is useful for detecting tab backgrounding and foregrounding, but it is not a substitute for bfcache handling. It helps you model session timers, autosave, and activity tracking.
beforeunload and unload
These are important because some browser behaviors around bfcache are affected by unload handlers. Overuse of unload has historically interfered with caching and can also create brittle cleanup logic.
freeze and resume
Some browsers expose page lifecycle signals for freezing and resuming. They are useful when your app has long-lived state or background work.
For web platform specifics, the Page Lifecycle API is helpful, especially if your app uses timers, workers, or autosave.
What to test, not just what to click
A good bfcache test checks behavior, not just navigation.
Verify the page instance is restored correctly
You want to know whether the restored page preserved the right things and reset the wrong things.
Check:
- Form values that should persist
- Draft text in inputs and textareas
- Scroll position
- Expanded or collapsed panels
- Selected tabs or filters
- Focus state after restore
- Notifications that should not reappear
- Stale loading spinners that should disappear
Verify session and data freshness rules
The page should know what state is allowed to survive restore.
Check:
- Is the user still authenticated?
- Has the CSRF token expired?
- Is the account status still valid?
- Does the dashboard need to refresh data on restore?
- Should cached data be revalidated in the background?
Verify single-fire side effects
A restore should not re-trigger things that should happen once per page lifetime unless explicitly intended.
Check:
- Analytics page view calls
- Tracking pixels
- Websocket subscriptions
- Redux or Zustand store rehydration
- Service worker message handlers
Verify cleanup and reinitialization
If the user leaves a page that held a resource, then returns later, the app should not leak or duplicate resources.
Check:
- Event listeners are not duplicated
- Intervals are not recreated twice
- Websocket reconnect logic is correct
- Observers, such as
ResizeObserverorIntersectionObserver, are reset properly
Build bfcache-focused manual test cases first
Before automating anything, write a handful of manual cases that make the restore behavior visible.
A useful manual matrix looks like this:
- Open a page with a form.
- Enter data, scroll halfway down, and select a tab.
- Navigate to a different same-site page.
- Click Back.
- Confirm whether the page restored from cache or reloaded.
- Compare the DOM, network activity, and app state to the expected result.
Repeat with:
- Logged-in and logged-out sessions
- Mobile and desktop layouts
- Pages with no user input, and pages with rich state
- Chrome, Firefox, Safari, and Edge
- Fast and slow networks
A quick manual check can reveal whether your app has a consistent restore story. If the page works only when it reloads, that is a sign your initialization logic is too tied to full load events.
How to detect bfcache in automation
You do not need to guess whether the browser used bfcache. In many cases, you can observe it through lifecycle events or evaluate page state on return.
Playwright example
This example listens for pageshow and checks whether the page came from bfcache after a back navigation.
import { test, expect } from '@playwright/test';
test('restores correctly from back navigation', async ({ page }) => {
await page.goto('https://example.com/account');
await page.evaluate(() => {
window.__pageshowPersisted = null;
window.addEventListener('pageshow', (e) => {
window.__pageshowPersisted = e.persisted;
});
});
await page.goto(‘https://example.com/help’); await page.goBack();
const persisted = await page.evaluate(() => window.__pageshowPersisted); expect(typeof persisted).toBe(‘boolean’); });
This does not prove every restore path in every browser, but it gives you a concrete signal to assert against in a regression suite.
Selenium Python example
With Selenium, you can validate state after navigation and use JavaScript to inspect page lifecycle signals.
from selenium import webdriver
browser = webdriver.Chrome() browser.get(‘https://example.com/account’) browser.execute_script(“window.__pageshowPersisted = null; window.addEventListener(‘pageshow’, e => window.__pageshowPersisted = e.persisted)”) browser.get(‘https://example.com/help’) browser.back()
persisted = browser.execute_script(‘return window.__pageshowPersisted’) print(persisted)
Why Cypress needs extra care
Cypress is good at app-level assertions, but bfcache is tricky because the runner sits alongside the browser and the app. For history navigation tests, you may need to think carefully about app reset behavior between specs, test isolation, and whether the browser will actually exercise the restore path you care about.
In practice, Cypress is often better for asserting post-navigation UI state than for verifying the browser’s caching internals. For true multi-browser coverage, cross-browser real-device execution is usually more reliable.
A practical test checklist for state restoration bugs
Use this checklist when you create or review bfcache tests.
1. Start with a page that has meaningful state
Pick pages with at least one of these properties:
- Forms with unsaved input
- Filters or sort controls
- Infinite scroll or pagination
- Authentication-dependent data
- Websocket-backed UI
- Autosave or draft handling
2. Move away from the page in a way that can enable bfcache
Navigate to another same-origin or cross-origin page, depending on what you are testing. Some browser policies and page features affect whether the page is eligible for cache restore.
3. Return with Back or Forward, not with a direct URL load
The point is to exercise browser history state restoration, not a normal page visit.
4. Assert both visible and invisible state
Do not stop at “the heading is visible”. Also check:
- Request counts
- Session flags
- Store values
- Console warnings
- Focus and scroll
- Event listener behavior where possible
5. Compare against the expected lifecycle
Ask what should happen on a cached restore, and what should happen on a full reload. Your assertions should match the intended product behavior, not a generic assumption.
If your app uses the same initialization path for reload and restore, you are probably leaving bugs on the table or doing too much work on every navigation.
How to write resilient assertions
State restoration tests become flaky when they overfit to implementation details. To reduce brittleness, focus on observable behavior.
Prefer user-visible invariants
Good assertions include:
- The draft text is still present
- The selected tab is still active
- The list is not empty after restore
- The user remains signed in or is redirected appropriately
- The page does not double-submit on restore
Use network assertions carefully
A restore from bfcache may not trigger the same network calls as a reload. That is the point. Do not write tests that assume every page revisit must refetch everything. Instead, assert the data freshness rule your app actually needs.
Guard against timing issues
Because restore can be fast, UI events may happen before your test is ready to observe them. Use explicit waits for the thing you care about, not fixed sleeps.
For example, if your app refreshes account data on pageshow, wait for the refreshed DOM or a known request rather than pausing for an arbitrary duration.
Cross-browser differences matter here
bfcache behavior is not identical across browsers, and not every page is eligible in every engine. That means a test that passes in Chrome can still fail in Safari or Firefox, and the reverse can happen too.
At minimum, run your navigation and restore tests in:
- Chrome
- Firefox
- Safari
- Edge
That is one reason teams often use a real-browser platform for broad coverage. Endtest is one option if you want cross-browser runs across real browsers, including workflows that validate session continuity and navigation behavior without building your own browser farm. Its AI Test Creation Agent can generate editable platform-native steps, which can be useful when you need repeatable navigation scenarios rather than hand-written scripts.
You do not need a platform for every bfcache test, but you do need a way to cover the browsers your users actually use.
Edge cases that deserve explicit coverage
Authentication transitions
Test what happens when a page is cached while logged in, then the session expires before the user returns. Decide whether to show stale content briefly, force a refresh, or redirect immediately.
Multi-tab usage
Open the same account in two tabs, change state in one, then return to the first tab through Back/Forward. This is where session continuity issues are easiest to miss.
Unsaved changes dialogs
A page with unsaved edits may need to warn on navigation away, but the warning logic should not block restore or produce duplicate prompts.
Offline or flaky network conditions
If the browser restores the page, then the next network call fails, does your app recover cleanly? bfcache can hide network instability until the user takes the next action.
SPA routing and native history
Single-page apps complicate the picture because internal route transitions do not always behave like full document navigations. Test both router transitions and native browser Back/Forward, because they exercise different layers of the stack.
A small pattern for reliable restore handling
A useful pattern is to separate “initial load” logic from “restore” logic.
For example:
- Initial load, set up listeners, fetch bootstrap data, render skeletons.
- Restore, revalidate session, refresh volatile data, and avoid attaching duplicate listeners.
That split makes tests clearer because you can assert whether the app performs a light resume or a full rebootstrap.
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
refreshSessionIfNeeded();
syncVolatileState();
}
});
The exact implementation depends on your stack, but the design goal is simple, keep restore behavior explicit instead of hoping the page behaves like a fresh load.
Where bfcache tests fit in your pipeline
These tests are more valuable than a one-off manual check, but they do not need to run in every quick PR job.
A good split is:
- PR checks, a narrow set of restore assertions on critical pages
- Nightly or pre-release runs, broader browser coverage
- Cross-browser runs, especially for Safari and Firefox parity
- Session-sensitive flows, run whenever auth, routing, or lifecycle code changes
If your team already uses test automation in CI, add a small bfcache suite to the same pipeline rather than creating a separate manual ritual. The important thing is to catch changes when page lifecycle code, auth handling, or route transitions are modified.
A simple decision framework
Use these questions to decide how deep your bfcache testing should go.
- Does the page hold user input or draft state, if yes, test restore behavior.
- Does the page rely on timers, subscriptions, or background sync, if yes, test for duplicate side effects.
- Does the page touch authentication or account state, if yes, test session revalidation on restore.
- Does the page need to behave consistently across Safari, Firefox, and Chromium, if yes, run real-browser coverage.
- Does your app use heavy client-side routing, if yes, test native history navigation and not just router transitions.
If the answer is yes to any of those, a simple Back button smoke test is not enough.
Closing thoughts
The hardest part of bfcache testing is not the browser mechanics, it is deciding what your application promises when a page comes back from memory. Once you define that contract, the tests become much clearer. You can check whether form state persists, whether stale data gets refreshed, whether listeners stay single-bound, and whether session handling survives browser history navigation.
In other words, the goal is not just to see that Back works, it is to make sure the user returns to a page that behaves correctly after a cached restore. That is what separates a superficially passing navigation test from a meaningful regression suite.
If your current browser checks mostly cover reloads, add a small set of bfcache cases now. It is one of the most practical ways to catch state restoration bugs before users do.