What really happens when a visitor goes back
The back/forward cache, bfcache for short, is not an ordinary store for files. It keeps the complete page including its JavaScript state in memory. web.dev spells out the difference: bfcache is a snapshot of the entire page in memory, including the JavaScript heap, whereas the HTTP cache contains only the responses for previously made requests (web.dev). On a back navigation that snapshot is simply shown again. There is no new network request, no new document construction and no new load event - none of the triggers a classic measurement script waits for.
This is not a fringe case. All major browsers include a bfcache, Chrome since version 96, plus Firefox and Safari (web.dev). And it is used more often than most reports suggest: Chrome usage data shows that 1 in 10 navigations on desktop and 1 in 5 on mobile are either back or forward (web.dev). Not every one of them ends up in the cache, more on that later, but the order of magnitude shows what is at stake. On a site with a lot of internal navigation, that share decides whether your report reflects reality or a filtered subset of it.
- The complete DOM tree including computed styles and scroll position
- The JavaScript heap with all variables, objects and running state machines
- Paused timers and observers that continue exactly where they were frozen
- The contents of form fields, without the page having to persist them itself
The HTTP cache saves network requests, but the page build still runs in full: parse the markup, compute styles, execute scripts, fire events. The bfcache skips exactly that part. For measurement this is the decisive difference, because every usual hook - DOMContentLoaded, load, a freshly executed measurement script - only exists on a real build.
Why page views drop when the page gets faster
web.dev names the consequence for measurement explicitly: if you use an analytics tool to measure visits to your site, you might notice a decrease in the total number of page views reported as Chrome enables bfcache for more users (web.dev). The sentence before that one is the more uncomfortable one: you are likely already underreporting page views from other browsers that implement bfcache, because many popular analytics libraries do not measure bfcache restores as new page views (web.dev).
That explains a pattern which regularly leads agencies to the wrong conclusion. After a performance project or after a browser update, page views fall without campaigns, rankings or server logs showing any drop. The obvious reading - fewer visitors - is wrong. It is the same visitors, routing part of their navigations past the measurement. Anyone who knows the difference between a server log and a measurement script finds the gap in an hour; anyone who treats them as the same thing searches in the wrong place for weeks.
| Navigation | Browser rebuilds the page | Measurement script without pageshow | CrUX field data |
|---|---|---|---|
| First visit | yes | counts | counts |
| Reload | yes | counts | counts |
| Back, restored from bfcache | no | does not count | counts |
| Back, without bfcache | yes | counts | counts |
| Forward, restored from bfcache | no | does not count | counts |
The table also shows why the deviation is not spread evenly across your pages. An article index that readers return to after every post loses far more than a landing page someone enters once and leaves again. The pages with the strongest internal navigation are exactly the ones that look weakest in the report. How to reconstruct paths through a website cleanly is covered in our article on user paths and page transitions.
The distribution tips downward although the site got faster
The second kind of damage is quieter than the missing page views, and it hits exactly the metrics agencies use to document their work. What matters is which page loads disappear: according to web.dev, the page loads being replaced by bfcache restores would likely have been some of the fastest page loads in your dataset (web.dev). So it is not a random cross-section that vanishes, it is precisely the top end.
The result is fewer fast page loads in your dataset, which will likely skew the distribution slower - despite the fact that the performance experienced by the user has probably improved.
— web.dev, Back/forward cache
CrUX is measured at the 75th percentile, that is, the value that 75 percent of page views achieved (web.dev). Remove the fastest loads from that dataset and the cut-off point moves backwards. The page got faster for the user, the number in the report got worse. If you then start tuning the wrong things, the result degrades further - which is why separating field data from lab data is not the last step but the first one in any root cause hunt (field data versus lab data).
In the case of a bfcache restore, LCP and FCP are the same (web.dev). Reporting both values unchanged mixes two metrics that normally sit far apart and produces a distribution that never existed in that form.
For Cumulative Layout Shift, web.dev recommends keeping your existing Performance Observer but resetting the current CLS value to 0 (web.dev). Otherwise the restored view counts shifts that were reported long ago.
The same applies to Interaction to Next Paint: keep the observer, reset the current INP value to 0 (web.dev). Otherwise the page drags the longest interaction of the previous view into the new measurement window.
web.dev advises annotating all page load metrics with their respective navigation type: navigate, reload, back_forward or prerender (web.dev). Without that label you cannot tell later which value came from which kind of navigation.
pageshow and persisted: the two lines that close the gap
The remedy is small and sits in the same source. To include bfcache restores in your page view count, set listeners for the pageshow event and check the persisted property (web.dev). That property is true if the page was restored from bfcache and false otherwise (web.dev). Nothing more is needed for the first step - the care goes into the two traps that follow immediately after.
// Handle the first build and a bfcache restore separately
window.addEventListener('pageshow', (event) => {
const entry = performance.getEntriesByType('navigation')[0];
const type = event.persisted
? 'back_forward'
: (entry ? entry.type : 'navigate');
if (event.persisted) {
// No new page build: drop the counters of the previous view
resetWebVitals();
}
sendPageView({
path: location.pathname,
navigationType: type,
fromBfcache: event.persisted === true
});
});
window.addEventListener('pagehide', (event) => {
// persisted === true means the page moves into the bfcache
flushQueue({ endOfSession: event.persisted !== true });
});Two traps are hidden in there. First, pageshow also fires on the initial build: during the initial page load, the pageshow event fires after the load event (MDN). Attach the listener without a case distinction and you count the first visit twice, turning under-reporting into over-reporting. Second, the name promises more than the event delivers: despite the name, the pageshow event does not fire when the page is actually shown to the user (MDN). Actual visibility remains the job of visibilitychange.
An extra page view on its own is not enough. The restored state carries the metrics of the previous view with it: accumulated layout shifts, the longest interaction, timers that were already running. Without a reset those values land in the report a second time and skew it in the other direction. Which events our JavaScript tracker evaluates for this is documented in the reference.
Record the navigation type instead of blending averages
A plain counter does not answer the next question: how many of your navigations come out of the cache at all? Every navigation carries its type with it. PerformanceNavigationTiming knows the values navigate, reload, back_forward and prerender (web.dev); back_forward means, according to MDN, that navigation is through the browser's history traversal operation (MDN). Attach that type to every metric and you can evaluate the distributions separately instead of blending them into one average in which both effects hide each other.
function navigationType() {
const entry = performance.getEntriesByType('navigation')[0];
return entry ? entry.type : 'navigate';
}
// Collect the reasons why a page did NOT come from the bfcache
function bfcacheBlockers() {
const entry = performance.getEntriesByType('navigation')[0];
if (!entry || !('notRestoredReasons' in entry)) {
return null;
}
return entry.notRestoredReasons;
}- Report page views per navigation type, not only as a total
- Group LCP, INP and CLS by navigation type - back_forward is systematically better
- Track the share of back_forward navigations as a metric of its own and watch it over time
- Measure the share of restores among all back_forward navigations: that is your hit rate
- Compare periods only within the same navigation type
Why your page never reaches the cache in the first place
A high hit rate is not automatic. web.dev is explicit that website owners should not be expecting a 100 percent bfcache hit ratio for all back_forward navigations (web.dev). The most common self-inflicted exclusion reason is an old acquaintance: on desktop, Chrome and Firefox have chosen to make pages ineligible for bfcache if they add an unload listener, which is less risky but also disqualifies a lot of pages (web.dev).
That very listener is on its way out. Chrome describes the gradual deprecation as changing the default so that unload handlers stop firing on pages unless a page explicitly opts in to re-enable them (Chrome for Developers). The rollout to all origins started in 2026 and runs over eight milestones, or about 32 weeks (Chrome for Developers). The same source gives the reason: on desktop Chrome and Firefox, unload is reasonably reliable but it has a negative impact on a site's performance by preventing the usage of bfcache (Chrome for Developers). What that means for collecting data on exit is covered in our article on the end of unload in Chrome.
- An unload listener: a hard exclusion on desktop (web.dev). The replacement is pagehide, which fires in all cases where the unload event fires and also fires when a page is put in the bfcache (web.dev).
- Cache-Control: no-store restricts a page's eligibility for bfcache and should only be set on pages that contain sensitive information where caching of any sort is never appropriate (web.dev).
- Open connections and running observers: web.dev strongly recommends closing connections and removing or disconnecting observers during the pagehide or freeze event (web.dev).
- References through window.opener: it is best to avoid creating window.opener references (web.dev).
Why a particular page stayed outside is not a matter of guesswork. The Chrome team has added the NotRestoredReasons API to help expose the reasons why pages do not use bfcache, so developers can improve their bfcache hit rates (web.dev). MDN describes the notRestoredReasons property of PerformanceNavigationTiming as returning an object providing report data on the reasons why the current document was blocked from using the back/forward cache on navigation (MDN). The same page names a limitation: the property may return null even when the navigation type is reported as back/forward (MDN). In addition, Lighthouse 10.0 added a bfcache audit that performs a similar test (web.dev).
Keep CrUX and your own measurement apart
Comparing your own numbers with the Chrome User Experience Report, CrUX for short, leads to the same cause from the other direction. CrUX considers bfcache restores as page navigations even though they do not result in a conventional page load (web.dev). Because the web APIs do not treat these as a page load, RUM solutions need to take extra steps for these pages to be counted if they want to match CrUX (web.dev). And because these are considerably faster page loads that can result in overall better performance being reported for a site, leaving them out results in worse overall numbers (web.dev).
As long as your own measurement omits bfcache restores while CrUX counts them, you are comparing two different populations. The gap does not close by measuring more often, only by both sides using the same definition of a page view. Tools that collect and report on the Core Web Vitals metrics treat bfcache restores as separate page visits in their dataset (web.dev) - if you do not mirror that, you are comparing apples with a subset of apples.
In practice: before you file a deviation as a defect, check whether both numbers cover the same navigations. Chrome has also added navigation types to CrUX, making it possible to see the number of bfcache navigations even without measuring it yourself (web.dev). That is the fastest cross-check: if CrUX shows a meaningful back_forward share and your own analysis knows none at all, the gap is on your side. Our Core Web Vitals test is a starting point for measuring the core metrics.
The opposite direction matters just as much: prefetching and prerendering shift the numbers too, upward instead of downward. How to separate prerender hits cleanly is covered in the article on speculation rules; for applications that handle page changes in the browser themselves, see the article on soft navigation. bfcache helps there as well: it can still help when going back to a single-page application rather than doing a full re-initialisation of that app again from the start (web.dev).
A check plan for site owners and agencies
The effort is modest, the order is what matters. Measure first, then clear the causes, then measure again - otherwise nobody can say afterwards which step worked and which one merely coincided with a browser update.
- Take stock: does your measurement script count a pageshow event with persisted set to true? If not, every restore is missing from your numbers.
- Attach the navigation type to every metric and split the analysis by navigate, reload, back_forward and prerender.
- Reset the Web Vitals state on every restore so that CLS and INP do not enter the report twice.
- Find unload listeners and replace them with pagehide - including in embedded third-party scripts, not only in your own code.
- Limit Cache-Control: no-store to the pages that genuinely need it, such as account and checkout areas.
- Evaluate notRestoredReasons and work through the most frequent reasons in order.
- Hold the back_forward share before and after the change against the CrUX field data.
- Write down the switch date and the old and new counting definition so the jump in the numbers stays explainable later.
For agencies the last point pays off twice: a jump in the numbers that is documented is not a dispute, an undocumented one is. Record the switch date and the change of definition in the acceptance test report and check in parallel whether your field data covers all browsers at all - the blind spot around iPhone users affects the distribution in much the same way as the bfcache gap.
If you would rather not close the gap yourself, measure it with a tool that knows both events out of the box. Cookieless Analytics by XICTRAQ counts restores as a navigation type of their own, without cookies and without a consent banner, and the Core Web Vitals measurement records LCP, INP and CLS separately per navigation type. How the analysis is structured is documented in the analytics reference; how measurement loss caused by content blockers differs from this is covered in the article on adblocker losses in first-party measurement. Teams looking after several client projects side by side will find the right frame in the agency access.
This article draws on: web.dev, Back/forward cache (as of 03 September 2026); web.dev, Why is CrUX data different from my RUM data? (as of 17 December 2025); Chrome for Developers, Deprecating the unload event (as of 14 July 2026); MDN Web Docs, Window: pageshow event (as of 28 July 2026); MDN Web Docs, PerformanceNavigationTiming: notRestoredReasons (as of 01 September 2026); MDN Web Docs, PerformanceNavigationTiming: type (as of 09 September 2025). All figures and quoted statements come from these sources; the deep links are stored with the article.