Handling bfcache Restores in Web Vitals
The back/forward cache keeps a whole document — DOM, JavaScript heap, scroll position — alive after the user navigates away, and restores it instantly when they come back. From the browser’s point of view nothing was loaded; from your reporter’s point of view a page that had already flushed its beacons is suddenly visible again and accumulating new interactions. This guide sits under Web Vitals API Implementation and covers what a restore does to each metric, the reporting pattern that keeps the numbers honest, and how a large bfcache share distorts an aggregate that does not account for it.
Getting this wrong is common and quiet. The usual symptoms are a p75 that looks suspiciously good, interaction data attributed to the wrong page view, and a beacon count that does not match your analytics session count.
Prerequisites
- A reporter built on the
web-vitalslibrary or your own observers, flushing onvisibilitychange— the base pattern is in Using the web-vitals npm Library Correctly. - A beacon schema with room for a navigation-type field and a per-page-view identifier.
- A way to test restores: navigate away and press back, with DevTools open on the Application panel to confirm the page was actually cached.
How to handle restores correctly
Step 1 — Understand what a restore does to each metric
| Metric | On restore | What to report |
|---|---|---|
| LCP | No new entry; the original value stands | A synthetic LCP measured from pageshow |
| FCP | No new entry | A synthetic FCP from the restore timestamp |
| CLS | Continues accumulating on the same document | Reset your own accumulator per restore |
| INP | Continues; the pre-restore worst still counts | Reset per restore, report separately |
| TTFB | Meaningless — nothing was fetched | Omit, or report zero with the type flagged |
The web-vitals library handles most of this for you: it resets its internal state on pageshow with persisted: true and reports fresh LCP and FCP values measured from the restore. What it cannot do is decide how you want those values aggregated, which is the part that skews dashboards.
Step 2 — Detect the restore and start a new page view
// page-view.js — a restore is a new page view, with the same document.
let pageViewId = crypto.randomUUID();
let navType = 'navigate';
addEventListener('pageshow', (event) => {
if (!event.persisted) return; // ordinary load, not a restore
pageViewId = crypto.randomUUID(); // new logical page view
navType = 'back_forward_cache';
// Anything you accumulate yourself starts again from here.
resetLocalAccumulators();
});
// Also useful: the Navigation Timing type covers non-bfcache back navigation.
const nav = performance.getEntriesByType('navigation')[0];
if (nav?.type === 'back_forward' && navType === 'navigate') {
navType = 'back_forward'; // restored from HTTP cache, not bfcache
}
export function tagBeacon(metric) {
return { ...metric, pageViewId, navType };
}
Distinguishing back_forward_cache from back_forward matters: the first had no network activity at all, the second re-fetched from the HTTP cache and has real TTFB and LCP values. Collapsing them hides a genuine performance difference.
Step 3 — Flush before the document is frozen
A page entering the bfcache is not unloaded — unload and beforeunload never fire, and a page with an unload listener is often ineligible for caching at all. The only reliable flush point is visibilitychange to hidden, and it must be synchronous.
// The last safe moment. pagehide also fires, but visibilitychange is the
// one that covers tab switches and OS-level backgrounding as well.
addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden') return;
flushQueuedBeacons(); // navigator.sendBeacon, synchronous call
});
// Never do this — it makes the page ineligible for the bfcache entirely:
// addEventListener('unload', flushQueuedBeacons);
Registering any unload handler, including one added by a third-party script, disqualifies the page from the bfcache in Chromium. If your bfcache hit rate is near zero, a stray unload listener is the first thing to look for.
Step 4 — Decide how restores are aggregated, and write it down
There is no universally correct answer, but there is a correct process: pick one, label it on every chart, and keep it stable.
Include them. This matches what CrUX does and what the assessment reflects — a restore is a real page view a real user experienced, and it was fast. Choose this when the number is meant to represent user experience.
Exclude them. This matches what an engineer needs when optimising the load path, because a restore exercises none of it. Choose this for the working dashboards a team uses to prioritise.
Report both. The honest option, and the one that ends recurring arguments: a headline number including restores, and a “fresh loads” cut next to it.
The one thing that is definitely wrong is having half your dashboards silently do one and half the other.
Step 5 — Watch the restore rate itself
The share of page views that are bfcache restores is a useful metric in its own right. It rises when navigation patterns change and falls, abruptly, when someone ships code that makes pages ineligible.
// Report eligibility failures directly — Chromium tells you why.
if ('notRestoredReasons' in performance.getEntriesByType('navigation')[0] || {}) {
const reasons = performance.getEntriesByType('navigation')[0].notRestoredReasons;
if (reasons) {
navigator.sendBeacon('/rum', JSON.stringify({
metric: 'bfcache_blocked',
value: 1,
reasons: (reasons.reasons || []).map((r) => r.reason).slice(0, 5),
route: window.__route,
}));
}
}
The notRestoredReasons API names exactly what disqualified the page — an unload handler, an open WebSocket, a Cache-Control: no-store header. Alerting on a sudden drop in restore rate catches a whole class of regression that no vitals metric would show.
Verifying it works
- Restores appear as their own navigation type in the data, with near-zero LCP and TTFB. If they are absent entirely, the reporter is not resetting on
pageshow. - Page view ids are unique per restore. A restore that reuses the previous id will double-count interactions against one page view.
- The fresh-loads p75 is worse than the all-views p75. If they are identical, restores are not being labelled and the split is not real.
- Restore rate matches expectations — typically 10–30% on mobile content sites. Near zero means something is blocking eligibility.
- No
unloadlisteners in production, including inside third-party bundles. Audit with thenotRestoredReasonsdata rather than by reading source.
What a restore is worth, and why it is worth protecting
It is tempting to treat the bfcache as an accounting nuisance — a category that has to be labelled so the aggregate stays honest. It is more than that. A restore is the fastest possible navigation on the web: no request, no parse, no hydration, no paint from scratch. On a mobile content site where a quarter of page views are restores, the bfcache is doing more for perceived performance than any optimisation the team has shipped in a year.
That reframes the eligibility question. Anything that disqualifies a page from the cache is a performance regression with a specific, measurable cost: the difference between a 320 ms restore and a 2,640 ms fresh load, multiplied by the share of navigations that would have been restores. On the traffic in the chart above that is roughly 27% of mobile page views paying an extra 2.3 seconds — a larger effect than most image or bundle work.
Three practices keep it protected:
Treat restore rate as a monitored metric, alerted on like any other, with the rules from Alerting & Regression Detection. A drop is a regression even though no vitals metric moved.
Review third-party scripts for unload listeners before adding them. A single vendor snippet can disqualify every page it appears on, and nothing in your own code will show why.
Test back navigation as part of the release checklist. It takes ten seconds with the Application panel open, and it catches the class of change — a new no-store header, a persistent connection, a service-worker change — that no automated performance test currently covers.
Edge cases and gotchas
Cache-Control: no-storedisqualifies the page. Authenticated pages frequently set it reflexively; only set it on responses that genuinely must not be stored.- Open connections block eligibility. A live WebSocket, an in-flight fetch with
keepalive, or an activeIndexedDBtransaction can all prevent caching. - Timers keep running conceptually but are frozen. A
setIntervalthat appears to have missed ten minutes has not run; do not compute elapsed time from wall clock inside a restored page. - The restore is instant but stale. Content that changed while the page was cached is not refreshed. Refetch on
pageshowwhere freshness matters. - iOS Safari caches aggressively. Restore rates are higher on iOS than Chromium, so the skew from including restores is larger on iOS-heavy traffic.
- Analytics session counts diverge. Most analytics tools count a restore as a page view; if your RUM does not, the two systems disagree by exactly the restore rate and someone will eventually notice.
FAQ
Does the web-vitals library handle bfcache for me?
It resets its internal state on a persisted pageshow and reports fresh values, so the raw metrics are correct. What it does not do is tag them — you still need to attach a navigation type so the aggregation decision is yours rather than accidental.
Should restores be included in my headline p75?
Include them if the number represents user experience, since CrUX does. Exclude them on the engineering dashboards you use to optimise the load path. The important part is labelling which one a chart shows.
Why is my bfcache restore rate zero?
Almost always an unload listener, a no-store cache header, or an open connection. The notRestoredReasons field in Navigation Timing names the cause per navigation.
Does a restore reset CLS for the browser?
The browser keeps accumulating on the same document, so shifts after a restore are added to the same session windows. Reset your own per-page-view accumulator if you want restore-scoped numbers — and never report that value as CLS.
Related
- Web Vitals API Implementation — the lifecycle this pattern extends.
- Using the web-vitals npm Library Correctly — the reporter that already resets on restore.
- Segmenting TTFB by CDN Cache Status — the other navigation-type split that changes what a p75 means.