The browser’s Back/Forward Cache (bfcache) freezes entire pages in memory so back-and-forward navigation feels instantaneous. However, an open WebSocket connection prevents browsers like Safari and Chrome from caching the page, forcing sluggish full-page reloads.
Even worse, if a page is cached with a severed socket, navigating back leaves the connection dead because React useEffect does not re-run. To fix this, listen to the browser’s pagehide event to gracefully close the socket before freeze, and use the pageshow event with event.persisted to reconnect the socket the moment the page thaws.
Table of Contents
The Problem in Simple Words
Have you ever clicked the “Back” button on a website, and the previous page appeared instantly with zero delay, exactly where you left off?
That speed is powered by a browser feature called the bfcache (Back/Forward Cache).
Unlike regular HTTP caching, which only stores downloaded files like images and CSS, the bfcache saves the entire living page including the DOM tree, scroll position, and JavaScript memory. When you click Back, the browser does not rebuild the page from scratch. It pauses the page when you leave, and unfreezes it when you return.
This creates an app-like experience. But there is a major catch: live network connections.
If your app uses a WebSocket for live chat, stock tickers, or notification counters the browser cannot keep an open TCP socket connected while the page is asleep in memory.
When a user navigates away from your page:
- The browser sees an active WebSocket.
- Browsers like Safari and Chrome must make a choice: either discard the bfcache and force a slow reload, or freeze the page and kill the socket.
- If the browser freezes the page and kills the socket, returning leaves your user with a dead connection. The screen looks normal, but no new messages ever arrive.
Common Workarounds
When developers notice that live features break after using the Back button, they usually try one of two common fixes. Both create serious problems.
Workaround 1: Disabling bfcache Entirely
Some developers give up and deliberately prevent caching by sending no-store headers or adding dummy unload listeners.
Why it hurts:
This “fixes” the stale socket bug by destroying navigation speed. Every time users tap Back, their browser shows a white flash, re-downloads assets, and re-executes JavaScript. On mobile connections, this wastes data and ruins the user experience.
Workaround 2: Relying on the unload Event
Other developers try to clean up their WebSocket inside an unload event listener.
Why it hurts:
Adding an unload event listener permanently disqualifies your page from bfcache. In Firefox and Safari, attaching an unload handler stops the browser from caching that page entirely. Modern web standards explicitly recommend avoiding unload.
What Developers Commonly Do Wrong in React
In standard React code, developers initialize WebSockets inside useEffect:
useEffect(() => {
const socket = new WebSocket('wss://example.com/feed');
socket.onmessage = (event) => {
setMessages((prev) => [...prev, event.data]);
};
return () => {
socket.close();
};
}, []);
Here is why this fails in production:
useEffectcleanup only runs when components unmount.When a user clicks an external link or navigates away, the browser often freezes the page directly into the bfcache before React unmounts your components.useEffectsetup does NOT re-run on back navigation.When the user clicks “Back”, the browser restores the existing component instance straight out of memory. Because the component was never unmounted or re-mounted,useEffectnever runs again.- The result:Your
socketobject remains in memory with a closed status (readyState = 3). The user sees their chat window or notification bell, but the app is completely silent until a manual page refresh.
The Solution For Bfcache Issues
To keep both instant back navigation and live WebSockets, we must coordinate with the browser’s Page Lifecycle.
Modern browsers provide two events specifically built for this: pagehide and pageshow.
Here is the strategy:
- Before the page freezes (
pagehide):When the user navigates away,pagehidefires. We immediately close the WebSocket with a clean status code (1000 Normal Closure). Because the page now has zero active sockets, the browser happily stores it in the bfcache. - When the page returns (
pageshow):When the user clicks Back,pageshowfires with a special property calledevent.persisted. - The Check (
event.persisted === true):Ifpersistedistrue, the browser tells us the page just woke up from the bfcache. We immediately create a fresh WebSocket connection and fetch any updates missed while asleep.
Here is a custom React hook called useBfCacheWebSocket:
import { useEffect, useRef, useState, useCallback } from 'react';
interface Options {
url: string;
onMessage?: (data: string) => void;
onRestore?: () => void;
}
export function useBfCacheWebSocket({ url, onMessage, onRestore }: Options) {
const [isConnected, setIsConnected] = useState(false);
const socketRef = useRef<WebSocket | null>(null);
const connect = useCallback(() => {
if (socketRef.current?.readyState === WebSocket.OPEN) return;
const socket = new WebSocket(url);
socketRef.current = socket;
socket.onopen = () => setIsConnected(true);
socket.onmessage = (event) => onMessage?.(event.data);
socket.onclose = () => setIsConnected(false);
}, [url, onMessage]);
const disconnect = useCallback(() => {
if (socketRef.current) {
socketRef.current.close(1000, 'Page entering bfcache');
socketRef.current = null;
setIsConnected(false);
}
}, []);
useEffect(() => {
connect();
const handlePageHide = () => disconnect();
const handlePageShow = (event: PageTransitionEvent) => {
if (event.persisted) {
connect();
onRestore?.();
}
};
window.addEventListener('pagehide', handlePageHide);
window.addEventListener('pageshow', handlePageShow);
return () => {
window.removeEventListener('pagehide', handlePageHide);
window.removeEventListener('pageshow', handlePageShow);
disconnect();
};
}, [connect, disconnect, onRestore]);
return { isConnected };
}
How this hook works:
connect()anddisconnect(): These helpers open and close the WebSocket cleanly without leaving dangling references.handlePageHide: Fires right before the page enters the bfcache. It callsdisconnect(), freeing the socket so the browser caches the page.handlePageShow: Checksevent.persisted. Iftrue, the page woke up from memory. It callsconnect()to rebuild the socket and triggersonRestore()to refresh stale data.- Standard Cleanup: If the component unmounts during regular SPA navigation, listeners are removed and the socket is closed.
Using the Hook in a Real Component
Here is a practical live notification badge that stays connected across back and forward navigation:
import React, { useState, useCallback } from 'react';
import { useBfCacheWebSocket } from './useBfCacheWebSocket';
export function LiveNotificationBell() {
const [unreadCount, setUnreadCount] = useState(0);
const handleMessage = useCallback((data: string) => {
try {
const payload = JSON.parse(data);
if (payload.type === 'NEW_NOTIFICATION') {
setUnreadCount((count) => count + 1);
}
} catch {
// Ignore invalid payloads
}
}, []);
const handleBfCacheRestore = useCallback(async () => {
const res = await fetch('/api/notifications/unread-count');
if (res.ok) {
const data = await res.json();
setUnreadCount(data.count);
}
}, []);
const { isConnected } = useBfCacheWebSocket({
url: 'wss://example.com/notifications',
onMessage: handleMessage,
onRestore: handleBfCacheRestore,
});
return (
<div className="notification-bell">
<span className={isConnected ? 'status online' : 'status offline'} />
<span className="count">{unreadCount}</span>
</div>
);
}
When a user navigates away and clicks Back:
- The page opens with zero loading time.
- The WebSocket reconnects instantly in the background.
onRestorefetches any unread notifications missed while the tab was asleep.
Conclusion & Key Takeaways
WebSockets and bfcache do not have to fight each other. With the right lifecycle events, you get both real-time updates and instant back navigation.
Key takeaways:
- Never use
window.addEventListener('unload'); it permanently breaks bfcache. - Use
pagehideto close open WebSockets before the browser takes a page snapshot. - Use
pageshowwithevent.persistedto reconnect the socket when the user returns. - Always provide an
onRestorecallback to sync missed messages or updates.
How can I test if bfcache is working on my site?
Open Chrome DevTools, navigate to Application, and select Back/forward cache. Click Test back/forward cache. DevTools tests navigation and lists any reasons preventing caching.
Does document.visibilityState replace pagehide?
No. visibilitychange fires when switching tabs or minimizing the browser window. The pagehide event specifically signals that the document is being unloaded or frozen into bfcache.
Does this also apply to Server-Sent Events (SSE)?
Yes. Open EventSource (SSE) connections cause the same bfcache eviction behavior as WebSockets. Close your EventSource on pagehide and recreate it on pageshow.
