Core Web Vitals

Bfcache: The Free Speed You Lose With One Header

The back/forward cache makes back navigation instant and lifts field metrics for free, unless something blocks it. Blockers from no-store to unload handlers.

5 min read
Core Web VitalsCachingPerformance
939 words5 min read

What bfcache is

When a visitor leaves a page and then presses Back, the browser can either reload the page or restore it exactly as it was, from memory, in a few milliseconds. The second is the back/forward cache, bfcache. It keeps the whole page, DOM, JavaScript state, scroll position, frozen, and thaws it on return.

For visitors it means back navigation is instant, which on a site where people browse a list, open an item and go back is most navigation. For Core Web Vitals it matters directly: a bfcache restore is counted as a page view with an LCP of effectively zero and no layout shift, and those views are in the field data Google uses. A site with a decent share of back navigations that are bfcache-eligible has better p75 metrics than the same site with bfcache blocked, with no code made faster.

Browsers try to use it on every navigation. Pages opt out by accident.

Testing eligibility

Chrome DevTools, Application panel, Back/forward cache. Click "Test back/forward cache". Chrome navigates away and back and reports whether the page was restored and, if not, every reason it was not. The reasons are specific and this is the whole diagnosis.

Also in the field: the web-vitals library reports restored pages via pageshow with persisted: true, and Chrome's Performance panel shows a back-forward-cache restoration event. The Chrome UX Report includes a navigation type breakdown, so you can see what share of your navigations are restores.

The blockers, in order of how often we find them

Cache-Control: no-store

The main HTML response carrying Cache-Control: no-store makes the page ineligible in most browsers (Chrome has been relaxing this for some cases since 2023, but do not rely on it). It arrives from: a security or session plugin adding it to every response; a hosting platform's default for dynamic pages; a framework setting it on any response that reads cookies; a well-meaning developer copying a header list.

Fix: use no-store only on responses that genuinely must never be stored, such as pages showing sensitive account data. For ordinary pages, Cache-Control: private, no-cache or max-age=0, must-revalidate prevents shared caching without blocking bfcache. Check the header on your HTML response now; this is the single most common cause.

unload event handlers

Any window.addEventListener('unload', ...) or onunload makes the page ineligible in Chrome (Firefox and Safari differ but also disfavour it). Analytics libraries, old tracking scripts, some chat widgets and a few frameworks' older versions register unload handlers to send final beacons.

Fix: replace unload with pagehide (which fires on bfcache entry too) and visibilitychange. Modern analytics SDKs have done this; update them. Find the offender via the bfcache test's reason list or by searching loaded scripts for unload. The Permissions-Policy header unload=() blocks the handler from registering, as a blunt fix for third-party scripts you cannot change.

beforeunload handlers, conditionally

beforeunload itself is allowed, but Chrome will not cache a page where a beforeunload handler is registered and the form is dirty, and some browsers penalise the handler regardless. Register it only when there is something to protect, and remove it when the form is saved or empty.

Open connections

An open WebSocket, WebRTC connection, a pending fetch with a body stream, or an in-progress IndexedDB transaction at the moment of navigation blocks caching. Chat widgets and live-update features are the usual sources.

Fix: close connections in pagehide and reopen in pageshow if event.persisted. Widgets that do not do this are candidates for loading on interaction rather than on every page.

Pages that are not GET

A page that was the result of a POST (a form submission rendering its own result) is not cached. Redirect after POST (the pattern that is good practice anyway) so the visible page is a GET.

window.opener and named windows

A page opened via window.open from another, or holding a reference to an opener, may be excluded. Use rel="noopener" on links that open new tabs, which you want for security too.

Extensions and browser-specific reasons

Some extensions inject content that blocks caching; the test tool reports these as extension-related and there is nothing to do server-side.

What your code must handle when bfcache works

A restored page did not reload. Anything that depends on being fresh needs a pageshow listener:

JavaScript
window.addEventListener('pageshow', (event) => {
  if (event.persisted) {
    // Page restored from bfcache: refresh time-sensitive UI,
    // re-fetch a cart count, reconnect a socket, re-run analytics page view.
  }
})

Analytics should count a restore as a page view (most libraries do). Timers keep running from where they froze, so anything computing elapsed time from Date.now() at load needs care. Consent state and login state may have changed on the other page; re-read them.

Verifying after fixes

Run the DevTools test on the home page, a listing page and a detail page. All three should report "Restored from back/forward cache". Then watch the navigation type breakdown in your RUM over the next weeks; the share of back-forward restores should rise and the p75 LCP for those page groups should fall.

Why it is worth an hour

Nothing else in performance work gives instant navigation and a field-metric improvement in exchange for removing a header and an event handler. It is invisible in Lighthouse, which is why it is so often left broken. Checking bfcache eligibility is on the checklist of every Core Web Vitals audit we run, and on more than half of the sites we test, one no-store header or one legacy analytics snippet is turning off the fastest cache the browser has.

All writingHire me for this