Cleaning up WebSockets on SPA route changes #
In a single-page app, the page never unloads. A user moves from the dashboard to a report to a settings screen and back twenty times in a working day, and every visit to a live view opens something: a socket, a subscription, a message listener, an interval that polls presence. When the route changes, the component unmounts but some of those resources do not go with it. By the afternoon the tab holds eight open sockets to the same server, message handlers fire into components that no longer exist, and the heap has grown by hundreds of megabytes. A full page load would have cleaned all of this up for free; in an SPA, every route must clean up after itself deliberately.
Root cause #
Traditional multi-page sites get cleanup from the browser: navigating to a new document tears down every socket, timer and listener of the old one. SPAs replace that with a router that swaps components, and components only release what their unmount logic explicitly releases. Four things commonly escape.
Sockets opened by a route component without a matching close in its cleanup keep their TCP connection and, through their onmessage closure, the component’s state. Listeners added to a shared socket (socket.addEventListener('message', …)) without removal accumulate, one per visit. Server-side subscriptions survive client-side unmounts unless the client tells the server to stop, so the server keeps sending data for views nobody is looking at. And timers created per route — heartbeat checks, reconnect delays — keep running and keep their closures alive.
Resolution #
Give each route a lifetime object — an AbortController — and register every resource against it. Unmounting the route aborts the controller, and one abort tears down every listener, timer, socket and subscription registered with it. The signal option on addEventListener makes listener removal automatic, and a small helper handles the rest.
// A route-scoped lifetime: everything registered here dies with the route.
export function createRouteScope() {
const controller = new AbortController();
const { signal } = controller;
return {
signal,
// Listener removal is automatic when the signal aborts.
listen<K extends keyof WebSocketEventMap>(ws: WebSocket, type: K, fn: (e: WebSocketEventMap[K]) => void) {
ws.addEventListener(type, fn as EventListener, { signal });
},
interval(fn: () => void, ms: number) {
const id = setInterval(fn, ms);
signal.addEventListener('abort', () => clearInterval(id), { once: true });
},
// A socket owned by this route: closed on abort.
socket(url: string) {
const ws = new WebSocket(url);
signal.addEventListener('abort', () => {
if (ws.readyState <= WebSocket.OPEN) ws.close(1000, 'route left');
}, { once: true });
return ws;
},
// A server-side subscription on a shared socket: unsubscribed on abort.
subscribe(shared: WebSocket, channel: string) {
const sendSub = () => shared.send(JSON.stringify({ op: 'sub', channel }));
if (shared.readyState === WebSocket.OPEN) sendSub();
else shared.addEventListener('open', sendSub, { once: true, signal });
signal.addEventListener('abort', () => {
if (shared.readyState === WebSocket.OPEN) shared.send(JSON.stringify({ op: 'unsub', channel }));
}, { once: true });
},
dispose: () => controller.abort(),
};
}
// React: one scope per mounted route component.
import { useEffect } from 'react';
export function LiveReport({ reportId, shared }: { reportId: string; shared: WebSocket }) {
useEffect(() => {
const scope = createRouteScope();
scope.subscribe(shared, `report:${reportId}`);
scope.listen(shared, 'message', (e) => { /* update this view's state */ });
scope.interval(() => shared.send(JSON.stringify({ op: 'presence', reportId })), 15_000);
return scope.dispose; // route change = one abort = full cleanup
}, [reportId, shared]);
return null;
}
The same scope works unchanged in Vue (onUnmounted(scope.dispose)) and Svelte (onDestroy(scope.dispose)), and in router-level hooks such as a loader or a navigation guard. Because cleanup is a single call registered once, adding a new resource to the route cannot forget its teardown — the registration is the teardown.
Decide deliberately which sockets should be route-scoped at all. An app-wide connection that every view shares should live above the router — in a provider, store or plugin, as in sharing one WebSocket across React components — and routes should own only their subscriptions on it. Opening a dedicated socket per route is appropriate only when the route talks to a different endpoint.
Edge cases #
Unmount during connect. If the user leaves before a route-owned socket finishes opening, close() on a CONNECTING socket is allowed and aborts the handshake; the browser logs a warning in some versions. The helper above closes in both states, which is correct.
Back-forward cache. Browsers may freeze a whole page in bfcache instead of unloading it, which is a page-level event rather than a route change. Handle it with pagehide/pageshow, as discussed in WebSocket reconnect with the Page Visibility API.
Keep-alive routes. Vue’s <KeepAlive> and similar caches deactivate components without unmounting them. Pause subscriptions on deactivate (onDeactivated) and resume on activate, or the cached view keeps receiving data offscreen.
Verification #
Navigate between two live routes twenty times, then check three things. In DevTools’ Network panel, the WS list should show no more sockets than you expect to be alive. In the console, getEventListeners(sharedSocket) (Chrome) should list one message listener per currently mounted live view. And a heap snapshot comparison should show no retained instances of the unmounted route components — the technique in finding WebSocket listener leaks in DevTools. On the server, count subscriptions per connection; it should drop when a user leaves a view.
Operational checklist #
FAQ #
Why do WebSocket connections pile up in my single-page app? #
A component opens a socket on mount and never closes it on unmount, so each visit to the route adds a connection. Close route-owned sockets in cleanup, or better, share one app-level socket and scope only subscriptions to routes.
Is removing listeners enough if the socket is shared? #
It stops client-side work, but the server keeps sending data for the channels the route subscribed to. Send an unsubscribe as part of cleanup so bandwidth and server fan-out follow what is on screen.
Does AbortController work with WebSocket listeners? #
Yes. addEventListener accepts a signal option in all modern browsers; aborting the signal removes the listener. It is the simplest way to remove many listeners at once without keeping references to each handler.
How does this relate to React StrictMode double effects? #
StrictMode runs effect cleanup and setup twice in development. A route scope disposed in cleanup handles that correctly: the first scope is fully torn down before the second is created. See preventing memory leaks in React useEffect WebSockets.
Related #
- Preventing Memory Leaks in React useEffect WebSockets — effect-level cleanup.
- Finding WebSocket Listener Leaks in DevTools — proving the fix.
- Detecting Detached DOM Nodes from Real-Time Lists — leaks inside a single route.
- Vue 3 WebSocket Plugin with provide/inject — scope-based cleanup in Vue.
Back to Memory Leak Prevention.