Fixing Analytics and Errors Lost to the Back-Forward Cache (bfcache)
The browser's back-forward cache skips page loads entirely — your analytics and error tracking never fire. Here's the pagehide/pageshow fix.
Three weeks ago I was debugging a checkout funnel. Conversion rate looked fine overall, but the "return to cart" step showed a 40% drop in pageviews compared to the actual return-to-cart button clicks we were tracking. Forty percent of users were apparently clicking the button and vanishing before the cart page could record them.
Spoiler: they weren't vanishing. They were hitting the back button.
When Chrome restored the cart page from bfcache, the page load event never fired. The analytics script never re-ran. Forty percent of our "back to cart" navigations were invisible. We'd been misreading funnel data for months.
If you've ever wondered why your SPA route change tracking still shows weird gaps, or why Safari ITP undercounting isn't the only source of visitor misattribution — bfcache is probably part of the answer.
What bfcache Actually Does
The back-forward cache (bfcache) is a browser optimization that keeps entire pages in memory after you navigate away. When you hit the back button, instead of re-fetching HTML, re-parsing CSS, and re-executing JavaScript, the browser just... unfreezes the page. Instant. Like the navigation never happened.
This is great for users. Back button feels instant instead of taking 1-3 seconds. But it's terrible for analytics that assume every page appearance involves a page load.
Here's the timeline:
- User visits
/product/shoes - Your analytics script runs on
DOMContentLoaded, fires a pageview - User navigates to
/checkout - Browser freezes
/product/shoesin bfcache - User hits back button
- Browser unfreezes
/product/shoes— noDOMContentLoaded, noloadevent - Your analytics script: silence
- User spends 30 seconds browsing the page you think they never saw
Safari has done this since 2009. Chrome added it in 2020 (version 86). Firefox shipped it in late 2021. So unless you've been handling this for years, you've been missing data. And honestly? I wasn't handling it correctly until embarrassingly recently. Spent way too long blaming Safari ITP for gaps that were actually bfcache. Embarrassing.
The kicker: bfcache is more aggressive on mobile. Browsers on phones are more memory-constrained but also more latency-sensitive, so they're aggressive about caching pages. If you've got significant mobile traffic — and almost everyone does — the impact is larger than you'd think from desktop testing. If you're testing across multiple devices or browser profiles, JustBrowser helps isolate sessions cleanly.
How to Detect bfcache Restores
The browser does give you a signal. The pageshow event fires both on initial page load AND on bfcache restore. The difference is the persisted property:
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
// Page was restored from bfcache
console.log('bfcache restore detected')
} else {
// Normal page load
console.log('regular page load')
}
})
When persisted is true, you know this is a page appearing from cache, not a fresh load. That's your signal to fire a pageview manually.
But here's the thing most tutorials miss: you also need to handle the exit. When a page goes into bfcache, you get a pagehide event with persisted: true. That's your last chance to flush any pending data — errors that haven't been sent, timing data you've collected, partial form analytics.
window.addEventListener('pagehide', (event) => {
if (event.persisted) {
// Page is being cached, not unloaded
// This is your last chance to send data
flushPendingData()
}
})
After pagehide, your JavaScript is frozen. No network requests. No timers. Nothing. If you didn't send your data before the freeze, it's stuck.
The Complete Fix for Analytics
Here's the pattern that actually works. It handles initial loads, bfcache restores, and proper data flushing:
// bfcache-aware-analytics.js
(function() {
// Track whether we've fired a pageview for this navigation
let pageviewFired = false
function trackPageview(source) {
// Dedupe — don't double-fire on same navigation
if (pageviewFired) return
pageviewFired = true
window.ja?.('pageview', {
url: location.pathname + location.search,
bfcache: source === 'bfcache'
})
}
function flushPendingData() {
// Send any buffered analytics via sendBeacon
// This is your last chance before the page freezes
const pendingEvents = window.__analyticsBuffer || []
if (pendingEvents.length > 0) {
navigator.sendBeacon('/api/analytics/batch', JSON.stringify(pendingEvents))
window.__analyticsBuffer = []
}
}
// Normal page load
if (document.readyState === 'complete') {
trackPageview('load')
} else {
window.addEventListener('load', () => trackPageview('load'))
}
// bfcache restore
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
pageviewFired = false // Reset for new navigation
trackPageview('bfcache')
}
})
// bfcache freeze — flush data before it's too late
window.addEventListener('pagehide', (event) => {
if (event.persisted) {
flushPendingData()
}
})
})()
The bfcache: true flag in the pageview is optional but useful for analysis. You can see what percentage of your pageviews come from cache restores. On content sites I've worked with, it's 15-25% of total pageviews. That's a lot of invisible navigation.
Fixing Error Tracking for bfcache
Error tracking has a different problem. You're not just missing pageviews — you're losing errors that happen right before navigation.
Picture this: user clicks a button, JavaScript throws an error, user immediately hits the back button. Your error handler queues the error for transmission, but before the network request completes, the page is frozen into bfcache. Error lost.
The fix: use sendBeacon in your pagehide handler to guarantee delivery:
window.addEventListener('pagehide', (event) => {
// Flush errors regardless of bfcache status
// Even on hard unload, sendBeacon is more reliable than fetch
const pendingErrors = window.__errorBuffer || []
if (pendingErrors.length > 0) {
navigator.sendBeacon('/api/errors/batch', JSON.stringify({
errors: pendingErrors,
meta: {
url: location.href,
timestamp: Date.now(),
persisted: event.persisted
}
}))
window.__errorBuffer = []
}
})
sendBeacon is designed for this exact scenario. It queues a small payload for delivery even if the page is unloading or freezing. The browser guarantees delivery (within reason) without blocking the navigation.
One thing I learned the hard way: don't put large payloads in sendBeacon. Browsers limit it — Chrome caps at 64KB per call. If you've buffered 50 errors with full stack traces, you might hit that limit. Lost about a week debugging "missing errors" before I realized they were just too fat for the beacon. Batch your errors more frequently, or truncate stack traces for beacon delivery. For outbound call tracking and attribution, VeloCalls has similar beacon-based reliability patterns.
The visibilitychange Alternative
Some developers use visibilitychange instead of pagehide. It works, but with caveats.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flushPendingData()
}
})
visibilitychange fires when you switch tabs, minimize the browser, or lock your phone. pagehide fires specifically when the page is navigating away. For bfcache purposes, they overlap — both fire when you navigate — but visibilitychange also fires in situations where the page isn't actually going anywhere.
If you're using both, be careful about double-flushing. I've seen implementations that send the same batch twice because both events fired within milliseconds.
My preference: use pagehide for navigation-specific flushing, visibilitychange for "user switched away but might come back" scenarios (like session activity timeouts).
What About the unload Event?
Don't use it. Seriously. I'm baffled this is still in tutorials.
The unload event is the old-school way to run cleanup code when a page is navigating away. But here's the problem: browsers use unload handler presence as a signal that the page might not be safe to cache. If you add an unload handler, many browsers will refuse to bfcache your page.
Chrome explicitly documents this. Safari has done it forever. Your "cleanup code" in unload is actively making navigation slower for your users.
// DON'T DO THIS — it breaks bfcache
window.addEventListener('unload', () => {
sendFinalAnalytics()
})
Use pagehide instead. Same capability, bfcache-friendly. Teams using DevOS for workflow automation can integrate these patterns directly into their deployment pipelines.
Testing bfcache Behavior
Chrome DevTools has bfcache testing built in. Open DevTools > Application > Back/forward cache. Click "Test back/forward cache" and it'll tell you whether your page is eligible and what's blocking it if not.
Common blockers:
unloadevent handlers (as discussed)Cache-Control: no-storeheadersbeforeunloadhandlers (sometimes)- WebSocket connections
- Service worker
fetchhandlers in some cases
You can also test manually:
- Load your page
- Navigate to a different page
- Hit the back button
- Check your analytics dashboard — did a pageview fire?
If you're running tests across different accounts or profiles — maybe comparing logged-in vs. anonymous bfcache behavior — JustBrowser keeps those sessions isolated so you're not cross-contaminating cookies.
Real Numbers: How Much Traffic Are You Missing?
The impact depends on your site type. Content sites with lots of browsing behavior see higher bfcache usage. E-commerce sites with linear checkout flows see less.
From sites I've instrumented with the bfcache: true flag:
| Site Type | bfcache Restores (% of pageviews) |
|---|---|
| Blog / Content | 18-25% |
| SaaS dashboard | 8-12% |
| E-commerce | 12-18% |
| Documentation | 22-30% |
Documentation sites are the highest because users constantly hit back to return to the TOC or previous section. If you're running developer docs and not handling bfcache, you're probably underreporting pageviews by 20%+. Ouch.
That's not a rounding error. That's a visibility problem for content teams trying to prove which docs are actually being used. Pair this with conversion funnel tracking without cookies and you start getting accurate numbers.
What This Won't Fix
bfcache handling solves navigation visibility. It doesn't solve:
-
Bot traffic inflation — bfcache is a real-browser feature, so bots don't trigger it. If you need click fraud protection for ad campaigns, that's what ClickzProtect handles.
-
Tab switching without navigation — if a user opens 10 tabs and switches between them, that's
visibilitychangeterritory, not bfcache. -
Server-side rendering timing — bfcache is client-side only. Your TTFB and server render times aren't affected.
-
Cross-origin navigations — bfcache works within same-origin navigations. If a user leaves for Google and comes back, that's a fresh page load (unless they navigate to a bfcached page from history).
Putting It Together
Here's my recommended setup for any production site:
// analytics-bfcache.js
(function() {
let currentPath = location.pathname + location.search
function trackPageview(trigger) {
// Add whatever metadata your analytics needs
window.ja?.('pageview', {
url: currentPath,
trigger: trigger,
timestamp: Date.now()
})
}
function flushErrors() {
const errors = window.__pendingErrors || []
if (errors.length === 0) return
navigator.sendBeacon('/api/errors', JSON.stringify(errors))
window.__pendingErrors = []
}
// Initial load
trackPageview('load')
// bfcache restore
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
currentPath = location.pathname + location.search
trackPageview('bfcache')
}
})
// Flush before freeze
window.addEventListener('pagehide', () => {
flushErrors()
})
// Also flush on visibility hidden (tab switch, minimize)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flushErrors()
}
})
})()
Drop this after your analytics script loads. It works with JustAnalytics, GA4, Plausible, or any custom implementation. The pattern is the same — and honestly, it's annoying that no analytics vendor ships this by default. We should all be doing better here.
For more complex setups — like correlating errors with specific funnel stages — check the error tracking and funnel correlation guide.
Frequently Asked Questions
Why don't my analytics fire when users hit the back button?
When a browser restores a page from bfcache instead of reloading it, none of your page load events fire — no DOMContentLoaded, no load event, nothing. The page just... reappears from memory. Your analytics script never runs again. You need to listen for the pageshow event with persisted=true to catch these navigations and manually fire pageviews.
Does bfcache affect error tracking too?
Yes. If an error occurs right before navigation and you're using synchronous logging, the error might not reach your server before the page freezes for bfcache. And when the page restores, any errors that happened in the frozen state are lost. Use the pagehide event with sendBeacon to flush pending errors before the page gets cached.
Can I just disable bfcache on my site?
Technically, yes — adding unload event handlers or Cache-Control: no-store headers will prevent bfcache. But this is a terrible idea. bfcache makes back/forward navigations instant instead of requiring full page loads. Disabling it tanks your Core Web Vitals and frustrates users. The right fix is instrumenting your analytics to handle bfcache correctly, not breaking a valuable browser optimization.
Which browsers use bfcache?
All major browsers now use bfcache. Safari has had it since 2009 and it's the most aggressive implementation. Chrome shipped it in 2020. Firefox has supported it since 2021. Mobile browsers use it heavily because instant back navigation matters more on slower connections. If you have any mobile traffic, bfcache is affecting your analytics.
Try JustAnalytics
All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).
Author at JustAnalytics.