Designing for the Back/Forward Cache: Eligibility, Restoration and State
Learn how the browser's back/forward cache restores whole pages, what silently blocks it, and how to handle restored state with pageshow and pagehide.
This article is published in English.
Sources: The Browser Feature Most Frontend Developers Completely Ignore
When a user presses Back, most applications quietly redo work the browser could have skipped: fetching HTML again, running every script, rebuilding the DOM and reloading data. The back/forward cache (bfcache) exists to avoid exactly that, yet everyday code often disables it without anyone noticing, or mishandles the page it brings back. This guide explains how bfcache differs from ordinary caching, which habits block it, how to detect and react to a restored page, and how to verify the behavior on your own screens.
Why history navigation deserves its own attention
Performance work tends to focus on the first visit. Teams compress images, split bundles, preload fonts, trim hydration cost, tune API calls and watch Largest Contentful Paint. All of that is worthwhile, but it quietly assumes that every page view starts from a blank tab and a freshly typed URL.
That is not how people browse. A shopper opens a product, returns to the results, opens another item, goes back again. A reader follows a link from an article and comes back to finish it. Someone revisits a half-completed form. The Back and Forward buttons are among the most heavily used controls in any browser, and these journeys are routine rather than exotic.
Browsers optimize this pattern with bfcache. Instead of tearing a page down when the user leaves, the browser may keep a snapshot of the whole document in memory. When the user returns through session history, it can reveal that snapshot rather than repeating the network request, HTML parsing, script execution, data loading and rendering. Every major engine implements some version of this, although the exact eligibility rules and internals vary between browsers.
From the user's side, a successful restore feels close to instant. The scroll offset is where they left it, the DOM is intact, form fields may still hold their values, and the JavaScript heap simply continues. The catch is twofold: many teams never check whether their pages qualify, and code that assumes each visit is a brand-new page can misbehave when an old one comes back.
So bfcache is more than a free speed boost. It exposes a more realistic lifecycle: a page may go dormant while staying alive, then wake up later without its startup code running a second time.
How bfcache differs from the HTTP cache
The shared word "cache" causes the first confusion. It is easy to assume bfcache just means reusing CSS, scripts, images or API responses that were downloaded earlier. That is the HTTP cache, and it works at a much finer grain.
The HTTP cache stores individual responses. Returning to a page may let the browser skip some downloads, but the document itself still has to be rebuilt: HTML parsed, scripts executed, framework bootstrapped, DOM created, subscriptions re-established and fresh requests sent.
bfcache keeps the entire page, including its DOM and its JavaScript heap. When the user navigates away, script execution can be paused; when they come back via history, the browser shows the preserved page and resumes it. That is why a bfcache hit beats even a well-tuned reload served largely from the HTTP cache: there is simply far less work left to do.
This changes how you should reason about navigation. On a bfcache restore, your normal startup path does not necessarily run again. A module-level variable can still hold its earlier value. The DOM still reflects whatever the user did before leaving. Timers and other runtime activity pick up where they stopped instead of being created from scratch.
Code built on the belief that returning always means a full reload can therefore miss updates, keep outdated state on screen, or create duplicates when it tries to set up something that already exists. The mental model to adopt is simple: the browser did not re-render an old HTML response; it woke up a suspended page.
A large win that needs almost no new interface
Most performance improvements cost something visible: redesigned components, new loading states, server rendering, or a different data-fetching strategy. bfcache is unusual because the browser already implements the mechanism.
Your job is not to build a cache. It is to avoid blocking the browser from using the one it has, and to make your page's lifecycle compatible with being restored.
That makes it one of the highest-leverage optimizations available to many teams. A page restored from memory avoids repeated transfers and most of the processing of a normal navigation. The gain is largest on slow devices and networks, but even fast machines show a clear difference between rebuilding a page and simply revealing it.
It also preserves the experience, not just the timing. Picture someone halfway through a long article who opens a related link and then comes back. A conventional reload has to reconstruct the page and then try to restore the scroll position, often after layout has shifted. A bfcache restore returns them almost exactly where they were.
Product listings make the point even more clearly. Someone narrows a catalog with four or five filters, works their way down a long list, taps into a single product and then heads back. If the list comes back immediately in the same state, the journey feels continuous. If it reloads, refetches, clears the filters or snaps back to the first row, the site comes across as brittle, however good its first-load metrics look.
Performance is not only about how quickly people get into an application. It is equally about how much repeated effort the product spares them as they move between screens.
How the unload habit silently removes the benefit
Part of the reason bfcache gets overlooked is that nothing visibly fails when a page is ineligible. The browser just performs an ordinary history navigation. Everything still works; users simply lose the instant restore.
The unload event is the classic culprit. Older code uses it to send analytics, release resources or tell a server the page is closing, on the theory that navigation always destroys the page and offers one last chance to clean up.
That theory no longer matches reality. A page may be suspended for bfcache instead of destroyed, and unload has long been unreliable, particularly on mobile, where it frequently never fires at all. Behavior differs across browsers, but registering an unload listener can make a page ineligible for bfcache in some of them. Current platform guidance strongly advises against unload and recommends lifecycle events that fit the modern model. At the time of writing, Chrome is also in the process of deprecating unload, so check the current browser documentation for the latest status.
The listener does not even have to come from your own code. Analytics snippets, ad tags, support chat widgets and older dependencies sometimes register one internally. It passes code review because it has no visible effect, yet it can strip a browser-level optimization from every visit to that page.
Replacing unload is not a matter of renaming the event. The right substitute depends on what the code is actually for:
- Analytics can be sent with
navigator.sendBeacon()or a suitably configuredfetchrequest (for example withkeepalive) when the page's visibility changes to hidden. - Warnings about unsaved form data can use
beforeunload, but that listener should be added only while there are unsaved changes and removed once they are saved, rather than attached for the page's whole life. - Resources tied to the active page can be paused and resumed through lifecycle events that are compatible with bfcache, covered next.
The broader lesson: navigating away is not the same as the end of a page's life. Code built on that equation ends up both less reliable and slower than it needs to be.
Detecting restoration with pageshow and pagehide
Two events bracket the hidden period. pagehide fires as a navigation takes the document off screen, and pageshow fires when navigation makes a document current again, which includes a restore from bfcache. Both events carry a persisted property. On pageshow, persisted is true when the page came out of bfcache; on pagehide, it indicates that the browser is trying to keep the page. Unlike unload, pagehide is designed to coexist with bfcache.
The snippet below shows the basic shape. The pageshow handler returns early on a normal load, so the refresh logic runs only when a preserved page comes back. The pagehide handler pauses real-time connections whenever the page is hidden, whether or not it ends up cached. The helper functions are placeholders for your own application logic.
window.addEventListener("pageshow", (event) => {
if (!event.persisted) {
return;
}
refreshSessionState();
refreshTimeSensitiveData();
});
window.addEventListener("pagehide", () => {
pauseRealtimeConnections();
});
Notice what this does not do: it does not refetch everything. Rerunning the whole data layer on every restore would throw away much of the benefit. The goal is to refresh only the information whose correctness can meaningfully change while the page sits suspended.
What that means depends on the page:
- A news front page may want to look for fresh headlines.
- An account screen may need to confirm the session was not ended in another tab.
- A checkout step may need to re-validate stock and pricing.
- A reference page of static docs can usually skip restoration logic entirely.
The real improvement is that the application can now tell a fresh load apart from a restore. Without that distinction, teams usually pick between two poor options: never refresh and risk stale content, or rerun full initialization and forfeit most of the speed. An application that understands bfcache handles a restore as a distinct phase of the lifecycle, keeping what is still valid and refreshing what can no longer be trusted.
When restored state becomes a correctness problem
Instant restoration sounds purely positive until the preserved page contains something that should no longer be shown.
Consider a user who opens an account dashboard, moves on to another page, logs out from a second tab and finally hits Back. The restored snapshot may still display details from the earlier session. The browser is not breaking any security rule by remembering a page within the same browsing session, but your application may need to check that the session is still valid before allowing any further interaction.
Time-sensitive data raises the same issue. A ticket may have been bought in another tab, a record edited by a colleague, or an availability window may have closed. A restored page can be a perfectly faithful picture of the past and still be the wrong source of truth for the present.
A common reaction is to disable caching entirely. That is usually too blunt. The page can keep its DOM and interaction state while refreshing just the data that must be current, and pageshow is the natural place to do that reconciliation. Official guidance states plainly that pages whose content changes often can pull in fresh data once restored, rather than opting out of bfcache altogether.
Security-sensitive pages still need deliberate cache and session policies, and how browsers treat headers like Cache-Control: no-store has been changing. Chrome, for instance, has started allowing certain no-store pages into bfcache when specific conditions are met. Avoid building on a single simplified assumption about how every browser handles that header, and check current browser documentation before relying on it.
The safer approach is to decide explicitly:
- which state can safely be preserved as it is;
- which state must be revalidated after a restore;
- which user actions always require a fresh authorization check on the server, regardless of what the interface currently shows.
That last category matters most. The client can refresh its view, but the server must never trust a restored page to prove that a user is still signed in.
Pausing long-lived connections during suspension
Open connections such as WebSockets, IndexedDB handles, shared locks and in-flight requests complicate bfcache, because the browser has to freeze the page while other tabs and documents keep running. A frozen page that holds a shared lock or an open database connection can get in the way of pages that are still active. Eligibility rules differ between browsers, and in some cases an open or active connection is enough to keep a page out of the cache. The recommended pattern is to release or suspend such resources as the page is hidden, then re-establish them with care once it becomes visible.
That does not mean tearing down every event listener or observer. State that lives entirely inside the page, such as DOM listeners, can usually stay frozen without harm. Focus instead on anything that reaches beyond the frozen document to outside systems, or anything that ought not to look live while nobody is viewing the page.
A real-time dashboard, for example, might close its WebSocket in pagehide and open a new one when the page is restored. The implementation must guard against duplicates, because both the normal startup path and the restoration handler may try to connect; a single connect() function that checks whether a connection already exists is a simple way to make that idempotent. An IndexedDB wrapper might close its database connection before suspension and reopen it lazily the next time it is needed.
This discipline pays off beyond bfcache. It forces the team to decide who owns each external resource, when it should exist and what happens when the document goes inactive. Those contracts are equally valuable for background tabs, mobile devices that freeze pages, and interrupted sessions.
Single-page applications still benefit
bfcache operates on navigations between documents that the browser manages. Route changes handled in JavaScript, where no new document loads, remain outside its scope. Going from /products to /products/42 through a client-side router is still something the SPA and its framework manage on their own.
Even so, SPAs have plenty to gain. People routinely leave one for a separate page, a third-party site, a sign-in provider or another app entirely, and later return. In that case the browser can bring back the whole application instead of fetching bundles again, re-rendering every component and rerunning setup. Official guidance points out this benefit explicitly, while noting that in-app route changes are outside bfcache's control.
Those returns can expose framework-level assumptions:
- root initialization expected to run every time the document becomes visible;
- a data layer that assumes its caches always start empty;
- a real-time connection recreated while the old one is also resuming;
- a component displaying figures that were correct at departure time and are now possibly out of date.
No framework abstracts away the browser's own lifecycle. Whether you use React, Vue, Angular or Svelte, the code still runs inside a document the browser may preserve and restore. A framework is good at organizing state, yet it has no way to judge for you which business data is still valid after suspension. In React, for instance, components do not remount on a bfcache restore, so effects that ran on mount will not run again; a pageshow listener is how the application learns that time has passed.
Many frontend developers know their framework's mount and unmount behavior far better than the browser's document lifecycle. bfcache shows that mounting and unmounting describe only one layer of what actually happens to a page.
Testing bfcache in minutes
bfcache stays ignored partly because it is hard to tell whether it is working. Going back may already seem fast, and the Network panel is not much help, since a restored page skips the usual request sequence entirely.
Chrome DevTools ships a purpose-built bfcache check, found under the Application panel. You run it, Chrome navigates away and back, and it reports whether the page was restored. When the page is not eligible, DevTools lists actionable reasons and identifies which frames are responsible.
Make this part of routine performance checks for important pages, particularly search results, articles, product listings, dashboards and any screen users frequently revisit. Run it with third-party scripts enabled as well, since eligibility is often blocked by code outside your own bundle.
For field data, the notRestoredReasons information exposed through the Performance API (on the navigation timing entry) can explain why real history navigations were not restored. Support is not universal, so treat it as progressive instrumentation rather than something your code depends on across browsers. It is still valuable, because no local test can recreate every browser extension, third-party iframe, open connection, header or browser condition your users bring with them. A lightweight alternative is to record event.persisted from pageshow in your analytics to measure your actual hit rate.
Do not expect every history navigation to hit the cache. Browsers evict snapshots to manage memory, and some blockers are outside the page's control. The realistic aim is to remove the avoidable blockers and to handle restoration correctly whenever it does happen.
Wrapping up
bfcache is easy to overlook precisely because nothing in your codebase turns it on. There is no package, no component and no configuration file that makes it look like part of the architecture; the browser simply tries to give users a better experience. That invisibility cuts both ways: when it works, users get near-instant history navigation for almost no code; when it is blocked, the site slows down without a single error; and when restoration is mishandled, stale state reappears because the application assumed that returning always meant reloading.
A practical checklist for your most-revisited pages:
- Remove
unloadlisteners, including those added by third-party scripts, and move their work topagehide,visibilitychangeorsendBeacon(). - Attach
beforeunloadonly while there are unsaved changes. - Close or pause external connections in
pagehideand reconnect idempotently on restore. - Use
pageshowwithevent.persistedto refresh only data that can go stale, and keep authorization checks on the server. - Run the DevTools back/forward cache test and track real-world restore rates where the APIs allow it.
The underlying shift is in how you picture a page. Rather than a single straight line from creation to destruction, its life can include setup, hiding, freezing, restoring, reconciling and carrying on. Teams spend a great deal of effort making page reconstruction faster, while the browser is often ready to skip that reconstruction entirely and hand users back the page they already had.