What Is the Back/Forward Cache (bfcache)?
The back/forward cache — commonly called bfcache — is a browser optimization that stores a complete snapshot of a web page in memory when the user navigates away. But unlike the HTTP cache, which stores individual resources like images and scripts, bfcache preserves the entire page state: the DOM tree, JavaScript heap, scroll position, form input values, even in-flight timers. When someone hits the back or forward button, the browser restores this snapshot instantly instead of fetching and rendering everything from scratch.
The result? Navigation that feels literally instant — zero network requests, zero parsing, zero layout, zero paint. The page appears exactly as the user left it, typically in under 10 milliseconds.
Every major browser now supports bfcache — Chrome, Firefox, Safari, and Edge. And as of early 2025, Chrome expanded bfcache eligibility dramatically by allowing it even on pages served with Cache-Control: no-store. That single change removed the largest blocker that had previously prevented about 17% of mobile history navigations from being cached.
Why bfcache Matters for Web Performance
If you think bfcache is some niche optimization that doesn't apply to your site, the numbers tell a different story:
- 10% of desktop navigations and 20% of mobile navigations are back or forward actions (per Chrome usage data)
- bfcache-restored pages have near-zero LCP, minimal CLS, and immediate interactivity — essentially perfect Core Web Vitals scores for those page loads
- Sites optimized for bfcache report measurably lower bounce rates and higher engagement, especially on e-commerce product pages where users frequently compare items by navigating back and forth
- Unlike most performance work that requires weeks of engineering effort, fixing bfcache blockers is often a matter of removing a single event listener or changing one HTTP header
Honestly, bfcache is one of those rare cases where a small code change can produce a massive, measurable performance improvement for a significant chunk of your traffic. I've seen sites go from "decent" to "wow" Core Web Vitals just by cleaning up their unload handlers.
How bfcache Works Under the Hood
Understanding the bfcache lifecycle helps you debug problems and write compatible code. Here's what happens step by step:
- User navigates away: Instead of destroying the page, the browser fires
pagehideandvisibilitychangeevents, then freezes JavaScript execution and stores the entire page state in memory - Page sits in bfcache: All pending timers, promises, and task queue items are paused. The page consumes memory but zero CPU
- User navigates back: The browser restores the page from memory, resumes JavaScript execution, fires
pageshowwithevent.persisted = true, and the page appears immediately - Eviction: If the browser needs memory, or the page has been cached too long (typically 10 minutes, or 3 minutes for
no-storepages in Chrome), the snapshot gets discarded and the next back navigation triggers a full reload
Here's the crucial part: the unload event is never fired for pages entering bfcache. This is the root cause of most bfcache incompatibility — code that relies on unload forces the browser to skip bfcache entirely.
Chrome's 2025 Game-Changer: bfcache for no-store Pages
One of the most significant bfcache developments happened in early 2025, when Chrome completed its rollout of bfcache support for pages served with Cache-Control: no-store. This deserves special attention because no-store was previously the single largest bfcache blocker.
What changed
Chrome now allows bfcache for no-store pages, but with two safety mechanisms:
- Cookie-change eviction: If any cookies change after the user navigates away (say, during logout), the cached page is automatically evicted from bfcache
- Shortened timeout: Pages with
no-storeare kept in bfcache for a maximum of 3 minutes instead of the standard 10
What this means for you
If your site uses Cache-Control: no-store broadly (and many sites do, even for non-sensitive pages), you may already be benefiting from bfcache without any code changes. That's the good news.
The caveat: you should verify that your logout flows properly modify cookies so Chrome correctly evicts authenticated pages when a user signs out. For pages that genuinely contain sensitive information and should never be restored from bfcache, just make sure your logout flow updates or deletes session cookies. Chrome handles the rest.
How to Test Your Pages for bfcache Eligibility
Before fixing anything, you need to know where you stand. There are three solid ways to test bfcache compatibility.
Method 1: Chrome DevTools (quickest)
- Open DevTools on your page
- Navigate to Application → Background services → Back/forward cache
- Click "Test back/forward cache"
- Chrome navigates to
chrome://terms/and back. If the test passes, you'll see "Restored from back-forward cache." If not, you get a list of specific failure reasons
Failure reasons are categorized as:
- Actionable: Issues you can fix (e.g.,
unloadevent listeners) - Pending Support: Browser limitations that'll be lifted in future versions
- Not Actionable: Issues outside your control (e.g., browser extensions)
Quick tip: Test in an Incognito window or Guest profile to eliminate interference from browser extensions. I can't tell you how many times I've seen a "failure" that turned out to be a password manager extension.
Method 2: Lighthouse audit
Lighthouse includes a dedicated bfcache audit. Run a Lighthouse performance report and look for the "Page prevented back/forward cache restoration" diagnostic. It reports the same failure reasons as DevTools but is easier to automate in CI/CD pipelines.
Method 3: The notRestoredReasons API (field data)
For production monitoring, the notRestoredReasons API (available since Chrome 123) lets you collect bfcache failure data from real users:
// Monitor bfcache failures in the field
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.type === 'back_forward' && entry.notRestoredReasons) {
const reasons = entry.notRestoredReasons;
// Send to your analytics endpoint
navigator.sendBeacon('/analytics/bfcache', JSON.stringify({
url: entry.name,
reasons: reasons.reasons,
children: reasons.children // iframe-level reasons
}));
}
}
});
observer.observe({ type: 'navigation', buffered: true });
This gives you real-world data on exactly which pages are being blocked and why — far more actionable than lab tests alone.
Common bfcache Blockers and How to Fix Them
So, let's get into the fixes. Here are the most frequent reasons pages fail bfcache, ordered by how often they show up in the wild, with exact code fixes for each.
1. The unload event listener
This is the number one bfcache killer. Any unload listener — whether in your own code or buried in a third-party script — makes the page ineligible for bfcache in Chrome and Firefox on desktop.
Before (broken):
// DON'T: This prevents bfcache
window.addEventListener('unload', () => {
sendAnalyticsBeacon();
cleanupResources();
});
After (fixed):
// DO: Use pagehide instead — fires in all cases unload does,
// PLUS fires when the page enters bfcache
window.addEventListener('pagehide', (event) => {
sendAnalyticsBeacon();
if (!event.persisted) {
// Page is truly being destroyed, not entering bfcache
cleanupResources();
}
});
The pagehide event fires in every case where unload fires, plus it fires when the page enters bfcache. Check event.persisted to tell the two scenarios apart.
2. Open WebSocket, WebTransport, or WebRTC connections
Pages with active real-time connections can't be frozen in bfcache because those connections have server-side state.
Fix: Close connections in pagehide and reconnect in pageshow:
let socket = null;
function connectWebSocket() {
socket = new WebSocket('wss://api.example.com/ws');
socket.addEventListener('message', handleMessage);
}
window.addEventListener('pagehide', () => {
if (socket) {
socket.close();
socket = null;
}
});
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
// Reconnect after bfcache restore
connectWebSocket();
}
});
// Initial connection
connectWebSocket();
3. Cache-Control: no-store (legacy concern)
As of Chrome's 2025 changes, no-store alone no longer blocks bfcache in Chrome. However, other browsers (Firefox, Safari) may still treat it as a blocker. If you're using no-store on non-sensitive pages, consider switching to no-cache or a short max-age for broader compatibility:
# Instead of this (blocks bfcache in some browsers):
Cache-Control: no-store
# Use this for non-sensitive dynamic pages:
Cache-Control: no-cache, max-age=0, must-revalidate
# Or this for pages that can be cached briefly:
Cache-Control: private, max-age=60
4. Active IndexedDB transactions
Pages with open IndexedDB transactions get blocked from bfcache because uncommitted transactions could leave the database in an inconsistent state. Makes sense when you think about it.
Fix: Make sure all IndexedDB transactions complete before navigation. If you're using IndexedDB for caching, close the database connection in pagehide:
let db = null;
async function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('myApp', 1);
request.onsuccess = () => {
db = request.result;
resolve(db);
};
request.onerror = () => reject(request.error);
});
}
window.addEventListener('pagehide', () => {
if (db) {
db.close();
db = null;
}
});
window.addEventListener('pageshow', async (event) => {
if (event.persisted && !db) {
await openDatabase();
}
});
5. The beforeunload listener (partial blocker)
In modern browsers, beforeunload no longer fully blocks bfcache, but it can still cause issues. The fix is straightforward: only add it when genuinely needed (like unsaved form changes) and remove it immediately after:
const form = document.querySelector('#editor-form');
let hasUnsavedChanges = false;
function onBeforeUnload(event) {
event.preventDefault();
// Modern browsers ignore custom messages
return '';
}
form.addEventListener('input', () => {
if (!hasUnsavedChanges) {
hasUnsavedChanges = true;
window.addEventListener('beforeunload', onBeforeUnload);
}
});
form.addEventListener('submit', () => {
hasUnsavedChanges = false;
window.removeEventListener('beforeunload', onBeforeUnload);
});
6. Third-party scripts using unload
Even if your own code is squeaky clean, third-party scripts are a common source of unload listeners. Known offenders include older versions of analytics libraries, hCaptcha iframes, marketing pixels, and some live chat widgets.
How to identify them:
// Run this in the console to find all unload listeners
getEventListeners(window).unload?.forEach(listener => {
console.log('unload listener:', listener.listener.toString().substring(0, 200));
});
Fix options:
- Update the library to a version that uses
pagehideinstead - Lazy-load the script so it only runs on pages where it's actually needed
- Load the script in a sandboxed iframe with
rel="noopener"so itsunloadlistener doesn't block the parent page - Use a Partytown-style approach to run the script off the main thread
Framework-Specific Fixes
Next.js
Next.js has two common bfcache issues worth calling out:
Issue 1: no-store header on all HTML responses. By default, Next.js sends Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate for HTML pages. While Chrome now handles no-store pages, you can improve cross-browser compatibility with middleware:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Only override HTML page responses, not API routes
if (!request.nextUrl.pathname.startsWith('/api')) {
response.headers.set(
'Cache-Control',
'public, no-cache, max-age=0, must-revalidate'
);
}
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Issue 2: Router state not updating on bfcache restore. When a user navigates back from an external site, the Next.js router may hold stale state. You can handle this with a pageshow listener:
// app/layout.tsx (App Router) or pages/_app.tsx (Pages Router)
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function RootLayout({ children }) {
const router = useRouter();
useEffect(() => {
const handlePageShow = (event: PageTransitionEvent) => {
if (event.persisted) {
// Force router to re-sync after bfcache restore
router.refresh();
}
};
window.addEventListener('pageshow', handlePageShow);
return () => window.removeEventListener('pageshow', handlePageShow);
}, [router]);
return <html><body>{children}</body></html>;
}
React SPAs with client-side routing
Single-page apps using React Router or similar libraries typically work fine with bfcache since the browser caches the shell page. Still, you should refresh stale data on restore:
import { useEffect } from 'react';
function useBfcacheRestore(onRestore: () => void) {
useEffect(() => {
const handler = (event: PageTransitionEvent) => {
if (event.persisted) {
onRestore();
}
};
window.addEventListener('pageshow', handler);
return () => window.removeEventListener('pageshow', handler);
}, [onRestore]);
}
// Usage in a component:
function ProductPage({ productId }) {
const [product, setProduct] = useState(null);
const fetchProduct = useCallback(async () => {
const data = await fetch(`/api/products/${productId}`).then(r => r.json());
setProduct(data);
}, [productId]);
useEffect(() => { fetchProduct(); }, [fetchProduct]);
// Re-fetch when restored from bfcache
useBfcacheRestore(fetchProduct);
return product ? <ProductDetails product={product} /> : <Loading />;
}
Fix Your Analytics for bfcache
One of the most common (and most overlooked) side effects of bfcache is underreported page views. When a user navigates back and the page restores from bfcache, the standard load event doesn't fire. If your analytics only track page views on load, you're missing 10-20% of actual page views. That's a big blind spot.
Google Analytics (gtag.js)
// Standard page view on initial load
gtag('event', 'page_view');
// Track bfcache-restored page views
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
gtag('event', 'page_view', {
page_title: document.title,
page_location: window.location.href,
send_to: 'G-XXXXXXXXXX'
});
}
});
Google Tag Manager (dataLayer)
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'page_view',
pv_origin: 'bfcache_restore'
});
}
});
Custom analytics or RUM tools
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
// Use sendBeacon for reliable delivery during navigation
navigator.sendBeacon('/analytics/pageview', JSON.stringify({
url: window.location.href,
referrer: document.referrer,
type: 'bfcache_restore',
timestamp: Date.now()
}));
}
});
bfcache and Core Web Vitals: What Gets Reported
Understanding how bfcache affects Core Web Vitals measurement is essential for accurate performance monitoring. Here's the breakdown:
- LCP: For bfcache-restored pages, the page appears instantly. Chrome reports a near-zero LCP for these navigations. The
web-vitalsJavaScript library creates new metric instances for bfcache restores and labels the navigation type as'back-forward-cache' - CLS: Since the page is restored in its final state with no new layout, there's typically zero layout shift on restore. Any CLS that accumulates after restore (e.g., from JavaScript that updates the DOM in a
pageshowhandler) is counted separately - INP: The page is immediately interactive after bfcache restore, so the initial interaction response time is minimal. INP tracks all interactions during the restored session normally
The key takeaway: CrUX includes bfcache navigations in its data, which means improving bfcache eligibility directly improves your site-level Core Web Vitals as reported in Google Search Console and PageSpeed Insights. It's basically free performance points.
A Complete bfcache Optimization Checklist
Use this checklist to systematically audit and fix your site for bfcache eligibility:
- Audit unload listeners: Search your codebase and third-party scripts for
addEventListener('unload'and replace every instance withpagehide - Review Cache-Control headers: Switch non-sensitive pages from
no-storetono-cachefor cross-browser bfcache compatibility - Close open connections: Tear down WebSockets, WebTransport, and WebRTC connections in
pagehideand reconnect inpageshow - Complete IndexedDB transactions: Close database connections in
pagehideto prevent transaction-related blocks - Audit iframes: Third-party iframes with
unloadlisteners (hCaptcha, older YouTube embeds) block the parent page — lazy-load or conditionally render them - Add analytics tracking: Listen for
pageshowwithevent.persistedto track bfcache-restored page views - Handle stale data: Refresh time-sensitive content (prices, stock levels, auth status) in your
pageshowhandler - Test in DevTools: Use the Application → Back/forward cache panel in Chrome DevTools to verify each page passes
- Monitor in production: Deploy the
notRestoredReasonsAPI to collect field data on bfcache failures - Verify logout flows: Make sure signing out modifies cookies so Chrome evicts authenticated pages from bfcache
Frequently Asked Questions
Does bfcache work on all browsers?
Yes — all major browsers support it, including Chrome (since v96), Firefox, Safari, and Edge. As of 2025, roughly 98% of browsers in use support bfcache. That said, eligibility varies: a page might be bfcache-eligible in one browser but blocked in another depending on the specific features and APIs it uses.
How is bfcache different from the HTTP cache?
The HTTP cache stores individual resources (HTML files, images, JavaScript bundles) and serves them from disk or memory on subsequent requests. bfcache is a whole different beast — it stores a complete in-memory snapshot of the entire page, including the JavaScript heap and DOM state. A bfcache restore is always faster than even a fully HTTP-cached page load because it skips parsing, compilation, layout, and paint entirely.
Does bfcache affect my SEO or Core Web Vitals scores?
Yes — and in a good way. CrUX (the data source for Core Web Vitals in Google Search Console) includes bfcache navigations. Since bfcache restorations have near-zero LCP, minimal CLS, and excellent INP, every bfcache-eligible navigation improves your aggregate Core Web Vitals scores. That can give you a real competitive edge in search rankings.
Will bfcache show outdated content to users?
bfcache restores the page exactly as the user left it. For static or semi-static content, that's perfect. For dynamic content (live prices, stock status, chat messages), listen for the pageshow event with event.persisted === true and refresh only the data that may have changed. You get to keep the instant navigation while ensuring content accuracy.
Can I opt out of bfcache for specific pages?
There's no official HTTP header to opt out of bfcache. However, if a page uses features that block bfcache (like unload listeners or active WebSocket connections), it'll be excluded automatically. For sensitive pages, the most reliable approach is to ensure your logout flow modifies cookies — Chrome will then evict the page from bfcache on its own.