The back/forward cache (bfcache) can make a return navigation feel almost instant because the browser restores a complete page snapshot instead of rebuilding the route. The practical work is not “turning bfcache on”: it is avoiding lifecycle assumptions that make a page ineligible, then measuring real restores with browser-native signals. For product teams, bfcache is one of the few performance improvements that can help a slow route before its JavaScript bundle or API response changes.
What is bfcache, and why does it matter?
bfcache is an in-memory snapshot of a document that a browser may keep when someone navigates away. On Back or Forward, the browser can resume that document—including its JavaScript heap, DOM, scroll position, and many form values—rather than perform a network request and a fresh render. It is distinct from the HTTP cache and a service-worker cache: those cache resources or responses; bfcache preserves a live page.
That distinction changes the engineering goal. A route that has a large client bundle or an expensive initial request can still return quickly if it is restored. Conversely, a page can have excellent Lighthouse scores on first load and still give users a poor return-navigation experience if it cannot enter bfcache.
The browser decides eligibility and memory pressure can evict a saved page, so no site can guarantee a restore. The HTML specification’s session-history model is the right mental model: history traversal may reactivate an existing document rather than create a new one. Treat bfcache as a high-value progressive enhancement, then instrument it.
Which code stops a page entering bfcache?
The most common blocker is an unload listener. It was traditionally used for last-second analytics or cleanup, but it is unreliable during mobile termination and can make a page ineligible for bfcache. The Page Lifecycle API guidance recommends responding to visibility and page-transition events instead of assuming unload will run.
Start with a repository search:
rg "addEventListener\(['\"]unload|onunload|beforeunload" src app pagesbeforeunload needs more nuance. Browsers use it to warn about genuinely unsaved work. Keeping a persistent listener merely as a precaution can hurt bfcache eligibility and produces a worse mobile experience. Attach it only after the document becomes dirty; remove it immediately after a successful save or discard.
let isDirty = false;
function warnBeforeLeaving(event: BeforeUnloadEvent) {
event.preventDefault();
event.returnValue = "";
}
export function setDirty(next: boolean) {
if (next === isDirty) return;
isDirty = next;
window[next ? "addEventListener" : "removeEventListener"](
"beforeunload",
warnBeforeLeaving,
);
}Other eligibility rules vary by browser and evolve. Active connections, certain integrations, and browser extensions can affect the result. Do not cargo-cult a giant “bfcache compatibility” checklist from an old post. Use the browser’s explanation for the actual page, then fix the blocker you control.
This follows the same principle as frontend observability before hydration: navigation performance needs evidence from real page states, not an assumption made from build output.
How should an app save state without unload?
Save small, important client state when the document becomes hidden. visibilitychange is broadly useful because it fires when a user backgrounds a tab as well as when they navigate away. It is a better last reliable point for sending a small analytics payload or persisting a draft marker.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "hidden") return;
const payload = JSON.stringify({
route: location.pathname,
scrollY: window.scrollY,
savedAt: Date.now(),
});
navigator.sendBeacon("/analytics/page-hidden", payload);
});Use sendBeacon() or fetch(..., { keepalive: true }) for compact fire-and-forget telemetry; do not make a synchronous request while the user leaves. For a substantial draft, persist incrementally while the user edits—usually to IndexedDB or your application backend—not at the edge of navigation.
For resource cleanup, the pagehide event is a useful companion. Its persisted property says whether the browser intends to put the document into bfcache. A persisted page is paused, not destroyed. It may later receive pageshow and continue with the same JavaScript state.
How do you detect a bfcache restore?
Listen for pageshow and inspect event.persisted. Run restoration-specific code only when it is true; do not re-run normal application bootstrap or blindly refetch everything.
window.addEventListener("pageshow", (event) => {
if (!event.persisted) return;
// Reconcile data that could have changed while this page was frozen.
refreshNotificationBadge();
refreshSessionIfNeeded();
window.dispatchEvent(new CustomEvent("app:bfcache-restore"));
});This is especially relevant for apps that synchronize live data. A restored inbox should show a current unread count, but it does not need to discard the whole restored interface to achieve that. Refresh only data with a short correctness window. Reconnect a realtime channel if needed, resume timers carefully, and make subscriptions idempotent.
This “preserve the interface, reconcile the data” approach complements local-first web app architecture. Both treat an already-available client state as an asset rather than something to erase on each navigation.
For analytics, record restores explicitly:
window.addEventListener("pageshow", (event) => {
if (event.persisted) {
navigator.sendBeacon("/analytics/navigation", JSON.stringify({
kind: "bfcache_restore",
path: location.pathname,
}));
}
});The Navigation Timing specification also exposes navigation entries that can help segment route loads. Combine those entries with a pageshow marker in your real-user monitoring pipeline. The key reporting question is not simply “what is our average page load?” but “what percentage of eligible return navigations restored, and which routes did not?”
How can you debug bfcache misses?
Chrome DevTools provides a Back/forward cache test in the Application panel. Run it on an individual route, navigate away and back, and read the listed reasons when restoration does not happen. Chrome also documents how to test and fix bfcache issues; the important habit is reproducing the exact navigation path, including authenticated state and third-party scripts.
Use a compact debugging sequence:
- Test a representative route in a clean profile and record whether
pageshow.persistedis true. - Inspect DevTools’ ineligibility reason rather than guessing.
- Remove or scope the relevant listener or integration.
- Retest on the browsers your audience uses.
- Ship a metric for restores and misses, then watch it after releases.
Third-party tags deserve special scrutiny. A tag manager may inject an unload listener long after your application code passes local checks. The same discipline that makes Integrity-Policy useful for third-party scripts applies here: inventory externally loaded code, give it a clear purpose, and verify the browser-level outcome.
What should Next.js and React teams change?
Framework routing does not remove the document lifecycle. A client-side transition can be fast, but a full-document Back navigation still depends on browser history and eligibility. Put bfcache lifecycle handling in a small browser-only module, not inside route components that may mount multiple times.
// app/bfcache-client.ts
"use client";
export function installBfcacheHandlers(onRestore: () => void) {
window.addEventListener("pageshow", (event) => {
if (event.persisted) onRestore();
});
}Then have a top-level client provider call installBfcacheHandlers once. Its restore handler can invalidate only volatile query keys—for example, account balance, notifications, or feature flags. Avoid an unconditional router.refresh() or a full query-cache reset: it defeats the benefit of restoring a ready page and can introduce visible loading states.
The broader lesson is compatible with React Compiler migration planning: isolate a platform concern, make it measurable, and roll it out by route. Browser lifecycle code should be explicit enough to test without coupling every component to it.
A practical bfcache rollout checklist
For most production sites, this is a one- or two-sprint improvement rather than a rewrite:
- Remove
unloadhandlers from application and third-party code where possible. - Register
beforeunloadonly while there is unsaved user work. - Persist critical state earlier, using
visibilitychangeas a final small-payload signal. - On
pageshow.persisted, reconcile volatile data instead of rebuilding the app. - Test authenticated and checkout-like routes separately; their integrations differ.
- Capture restoration rate and bfcache-related errors in real-user monitoring.
- Recheck after analytics, chat, consent, or payment SDK changes.
FAQ
Is bfcache the same as the browser cache?
No. HTTP caching stores responses and assets; bfcache temporarily preserves an entire live document for history traversal. A bfcache restore can avoid both a network request and a new JavaScript initialization.
Should I remove every beforeunload handler?
Remove persistent precautionary handlers. Retain a narrowly scoped handler when a user would truly lose unsaved work, and remove it as soon as that condition no longer applies.
Can I force a page into bfcache?
No. Browsers make the final decision based on eligibility and available memory. You can improve the odds by avoiding known blockers, verify the result in DevTools, and measure restores in production.
Should a restored page refetch all its data?
Usually no. Refresh data whose correctness is time-sensitive, such as alerts or a session, while keeping the restored UI and stable data intact.