How to Measure bfcache Hit Rate in Real-User Monitoring

This guide sets up the field metric for Back/Forward Cache (bfcache), part of Advanced Caching Strategies & CDN Architecture. DevTools tells you whether a page can be restored on your machine; only field data tells you how often it is restored for real users, on real devices, with real third-party scripts and consent states — and why it is not when it fails.

The core measurement is simple: on every page show, record whether the page was restored (event.persisted) or loaded, and for back/forward loads that were not restored, record the browser's notRestoredReasons. From that you compute a hit rate per template and a ranked list of blockers. The subtleties are in counting correctly — distinguishing back/forward navigations from other loads, avoiding double counting, and handling browsers that expose less information.

From page show to bfcache dashboard Pipeline from the pageshow event through classification and reason collection to a hit-rate dashboard. From page show to bfcache dashboard pageshow persisted? Classify restore / bf-miss / other Reasons misses only Beacon template + browser Dashboard hit rate + top reasons

Rapid Diagnosis

  • Do you know your restoration rate? If not, start here; most teams are surprised by how low it is.
  • Does your RUM count restored pages at all? Some setups only fire on load, so restorations are invisible.
  • Are back/forward reloads distinguished from fresh loads? Navigation Timing's type is back_forward for them.
  • Do you collect reasons? Without reasons, a low rate is not actionable.

Root Cause Analysis: Why bfcache Is Hard to See

1. Restorations do not fire load events. Analytics tied to DOMContentLoaded/load never see them.

2. Misses look like normal loads. A back navigation that reloads is a regular page view unless you check type.

3. Reasons are browser-specific. notRestoredReasons is Chromium-only; Safari and Firefox users contribute hit/miss data without reasons.

4. Evictions look like misses. A page evicted from bfcache for memory reasons reloads like an ineligible one; notRestoredReasons helps separate them.

Classifying each page show How to classify page shows from pageshow persisted and navigation type into bfcache outcomes. Classifying each page show pageshow.persisted Navigation type Classification true (n/a — restored) bfcache hit false back_forward bfcache miss false navigate / reload not a bfcache candidate false prerender activation not a bfcache candidate

Step-by-Step Resolution

1. Classify every page show

javascript
function classifyShow(event) {
  if (event.persisted) return 'hit';
  const nav = performance.getEntriesByType('navigation')[0];
  return nav?.type === 'back_forward' ? 'miss' : 'none';
}
addEventListener('pageshow', (event) => {
  const outcome = classifyShow(event);
  if (outcome === 'none') return;
  send({ bf: outcome, template: document.body.dataset.template, ...(outcome === 'miss' ? reasons() : {}) });
});
// trade-off: pageshow with persisted=false fires on the initial load too; the
// navigation type check is what isolates back/forward misses.

Expected outcome: one row per back/forward navigation, classified as hit or miss.

2. Collect and flatten notRestoredReasons

javascript
function reasons() {
  const nrr = performance.getEntriesByType('navigation')[0]?.notRestoredReasons;
  if (!nrr) return {};
  const out = [];
  (function walk(node, depth) {
    for (const r of node.reasons ?? []) out.push(`${depth ? 'frame:' : ''}${r.reason}`);
    for (const child of node.children ?? []) walk(child, depth + 1);
  })(nrr, 0);
  return { reasons: [...new Set(out)].slice(0, 6) };
}
// trade-off: cross-origin frames report a masked reason ("masked") for
// privacy, so third-party blockers may appear only as "frame:masked". Pair with
// DevTools tests to identify which embed it is.

Expected outcome: a small set of reason strings per miss.

3. Report the hit rate per template and browser

sql
SELECT template, browser,
       COUNTIF(bf = 'hit') / COUNT(*) AS hit_rate,
       COUNT(*) AS bf_navigations
FROM rum.bfcache WHERE ts > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY 1, 2 HAVING bf_navigations > 200 ORDER BY bf_navigations DESC;
-- trade-off: Safari and Firefox do not expose reasons, but their hit rates are
-- still meaningful; do not drop them from the hit-rate view.

4. Rank reasons by impact

Count misses per reason per template, weighted by template traffic, and fix from the top.

Hit rate by template before and after fixes (Chromium) Bar chart of bfcache hit rate for three templates before and after eligibility fixes. Hit rate by template before and after fixes (Chromium) Search results before 18% Search results after 86% Product page before 31% Product page after 79%

Verification

Cross-check your hit rate with DevTools tests on the same templates: templates that pass in DevTools should show high field rates; low field rates on passing templates point to user-specific blockers (consent-dependent vendors, logged-in widgets). After each fix, the corresponding reason's share should fall and the hit rate rise within days.

Worked Example: A Travel Site Dashboard

A travel site added the classification beacon and found an overall Chromium hit rate of 27%. The top reasons were unload-listener (from an old affiliate tracking script) on 55% of misses, main-resource-has-cache-control-no-store on search results (22%), and masked frames (14%, later traced to a map embed). Fixing the first two over two sprints raised the hit rate to 71%; replacing the map embed with a static image and on-click loading took it to 83%. The dashboard became part of the weekly performance review, and a later regression — a new chat widget with an unload handler — was caught within two days.

Common Mistakes

  • Counting only Chromium. Hit rates in Safari and Firefox matter too, even without reasons.
  • Reporting hit rate over all page views. The denominator should be back/forward navigations, not all loads.
  • Ignoring SPA in-app navigations. They are not bfcache navigations; exclude them from the metric.
  • Sending reasons on every page view. Only misses need reasons; keep beacons small.

Edge Cases

Pages restored multiple times. A page can be restored, navigated away from and restored again; each pageshow with persisted is a separate hit.

Prerendered pages. Activation of a prerendered page is a different mechanism; classify it separately.

Iframes reporting their own navigations. Only the top-level document's pageshow should feed this metric.

Privacy. Reason strings are browser-defined and not personal, but keep template names coarse and avoid sending URLs with query strings.

Connecting Hit Rate to User Outcomes

A hit rate on its own can feel abstract to stakeholders. Make it concrete by joining bfcache outcomes to engagement: for sessions that went back to a listing page, compare the number of subsequent product views, the bounce rate after the back navigation, and conversion, split by whether the return was restored or reloaded. On browse-heavy sites the difference is usually striking — restored returns lead to more onward exploration because the list is instantly there, scrolled to where the user left it. Present the metric alongside that comparison and the case for fixing the remaining blockers makes itself.

Also chart Core Web Vitals for back/forward navigations separately: restored navigations report near-zero LCP and should be excluded from LCP optimisation work, while reloads on back navigations often have worse LCP than first visits because the HTTP cache has been partly evicted. Both views help explain why the overall p75 moves when the hit rate changes.

FAQ

What is a good bfcache hit rate?

In Chromium, well-tuned sites reach 80–90% of back/forward navigations. Rates below 50% almost always have fixable blockers. Browser memory limits and evictions prevent 100%.

Does the web-vitals library help?

Yes. It reports metrics for restored pages with navigationType: 'back-forward-cache', so you can see restored-page LCP and INP. It does not compute the hit rate or collect reasons; add the beacon described here.

Should restored page views count in analytics?

Yes — they are real views. Many analytics tools count them via pageshow; check yours, or your page view counts will drop when bfcache improves.

Is notRestoredReasons available in all Chromium browsers?

It ships in Chromium-based browsers from version 123. Older versions return undefined; handle that gracefully.

How do I attribute "masked" reasons?

Masked reasons come from cross-origin frames. Use DevTools' bfcache test on the template to see which frames block, or temporarily remove embeds in a test environment to isolate the culprit.

Can I alert on the hit rate?

Yes, and it is worth it: a sudden drop usually means a new blocker shipped — a vendor update, a new widget, a header change. Alert on a sustained drop (for example ten percentage points over two days) per high-volume template, and include the top new reason in the alert so the owning team knows where to look.