Debugging WebSocket Connection Leaks and State Drift in Vue 3 Dashboards #
You shipped a Vue 3 real-time dashboard, and after a few minutes of users clicking between routes the tab’s memory climbs, panels flicker with stale numbers, and the Network tab shows three or four live ws:// connections where there should be one. You came here searching for why a single dashboard route spawns duplicate sockets and why widgets keep rendering values that no longer match the server. The cause is almost always a lifecycle mismatch: a native WebSocket opened inside a component or composable that is never deterministically closed when that component unmounts. This page isolates that failure, explains the runtime mechanics, and gives you one composable that ties socket teardown to Vue’s reactivity scope.
To reproduce the leak, open Chrome DevTools, filter the Network tab by ws, and navigate between two dashboard routes five or six times. Each surviving row is a socket whose owning component is gone but whose onmessage handler is still mutating reactive state. Take a heap snapshot and search for WebSocket — retained instances and MessageEvent listeners that survive a route change confirm the leak rather than a transient.
Root cause #
Vue 3’s onMounted and onUnmounted hooks run your callbacks at the right moments, but they do not touch anything you forget to register. A native WebSocket is an external resource: the browser keeps the underlying TCP connection and its event handlers alive as long as something references the socket object. When a component unmounts without an explicit ws.close(), that reference typically survives inside a closure captured by onmessage, setInterval, or a reconnect timer. The socket keeps receiving frames, and its handler keeps writing into the ref or reactive proxy it closed over — even though the DOM those values fed is gone. The next time a component reads that proxy, it sees state mutated by a ghost connection. That is the “phantom update” and “state drift” you’re seeing.
There is a second, subtler trap that turns one leak into a storm. If your composable schedules a reconnect from inside onclose (the standard auto-reconnect pattern), then calling ws.close() during navigation fires onclose, which schedules a reconnect, which opens a new socket on a route you already left. Every intentional teardown becomes an unintentional reconnect. The fix is to detach ws.onclose = null before you close, so the handler can’t distinguish-and-reconnect on a deliberate disconnect. This same auto-reconnect lifecycle is covered in depth in Vue 3 useWebSocket composable with auto-reconnect; the dashboard case adds the constraint that teardown must be bulletproof across rapid route churn.
Reactive proxies make the symptom worse than a plain memory leak. Because Vue tracks every dependency, a retained onmessage handler that mutates state.value schedules real re-renders in whatever components still read overlapping keys, so a leaked socket actively corrupts visible UI rather than just consuming RAM. These reactive boundaries are the same ones discussed across Frontend WebSocket State Hooks & UI Patterns.
Resolution #
Wrap the socket in a composable whose teardown is bound to the calling component’s effect scope. The pattern below enforces a single live socket, detaches handlers before an intentional close, and applies incoming frames as reactive patches instead of wholesale replacement so unrelated widgets don’t thrash.
import { ref, onScopeDispose } from 'vue';
const MAX_BACKOFF_MS = 30_000; // cap the reconnect delay
const BASE_BACKOFF_MS = 1_000; // first retry waits ~1s
const NORMAL_CLOSURE = 1000; // RFC 6455 intentional-close code
export function useDashboardSocket(url: string) {
const state = ref<Record<string, unknown>>({});
const status = ref<'IDLE' | 'CONNECTING' | 'OPEN' | 'CLOSED'>('IDLE');
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let retryCount = 0;
let intentionalClose = false; // distinguishes navigation from a dropped link
function connect() {
// Guard against duplicate sockets when connect() is called twice on a fast route.
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
status.value = 'CONNECTING';
ws = new WebSocket(url);
ws.onopen = () => {
status.value = 'OPEN';
retryCount = 0; // reset backoff once a connection succeeds
};
ws.onmessage = (e) => {
try {
const frame = JSON.parse(e.data);
// Merge a patch rather than replacing state — only changed keys trigger re-renders.
state.value = { ...state.value, ...frame.payload };
} catch (err) {
console.error('Dashboard frame parse error', err); // drop malformed frames, keep socket
}
};
ws.onerror = () => ws?.close(); // let onclose drive the single reconnect path
ws.onclose = () => {
status.value = 'CLOSED';
ws = null;
if (!intentionalClose) scheduleReconnect(); // never reconnect on navigation
};
}
function scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer);
// Exponential backoff with a ceiling so a dead backend doesn't hammer the network.
const delay = Math.min(BASE_BACKOFF_MS * 2 ** retryCount, MAX_BACKOFF_MS);
retryCount++;
reconnectTimer = setTimeout(connect, delay);
}
function disconnect() {
intentionalClose = true;
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (ws) {
ws.onclose = null; // CRITICAL: detach before close() so navigation can't trigger reconnect
ws.onerror = null;
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close(NORMAL_CLOSURE, 'route change');
}
ws = null;
}
status.value = 'CLOSED';
}
// onScopeDispose fires for the active effect scope — works in setup() AND nested composables,
// unlike onUnmounted which only binds to a component instance.
onScopeDispose(disconnect);
return { state, status, connect, disconnect };
}
The two lines that actually stop the leak are onScopeDispose(disconnect) and ws.onclose = null inside disconnect(). The first guarantees teardown runs even when the socket lives in a nested composable rather than a component’s own setup(). The second neutralizes the reconnect path so an intentional close during navigation can never resurrect the socket on a route the user already left.
If your dashboard keeps shared state in Pinia, do not open the socket inside the store’s setup() — that runs once globally and has no component lifecycle to clean up. Instead call useDashboardSocket from a component and write state.value into the store reactively, so teardown still rides the component’s effect scope.
Operational checklist #
- Every
new WebSocket(...)is owned by a composable that registersonScopeDispose(oronUnmounted -
ws.oncloseis set tonullbefore any intentionalclose() - After five rapid route transitions, the Network tab shows exactly one live
ws - A heap snapshot taken after navigation away retains zero detached
WebSocket - Backoff is capped (
MAX_BACKOFF_MS) andretryCountresets to zero ononopen - Pinia-backed dashboards init the socket from a component scope, never from store
setup() - CI greps for
new WebSocket
FAQ #
Why does onUnmounted not close my WebSocket? #
onUnmounted runs your callback, but it has no built-in knowledge of native resources. If you never call ws.close() inside it, the socket and its handlers stay alive. Register an explicit disconnect() in onUnmounted or, better, onScopeDispose so it also covers nested composables.
What is the difference between onUnmounted and onScopeDispose here? #
onUnmounted only fires when a component instance unmounts. onScopeDispose fires when the surrounding reactive effect scope is disposed, which includes component unmount and manual effectScope() teardown. For a composable that may be called from nested composables, onScopeDispose is the safer binding.
How do I stop reconnect storms during navigation? #
Set a flag (or detach ws.onclose = null) before calling close() so the close handler can tell an intentional disconnect from a dropped connection. Without that, every navigation triggers onclose, which schedules a reconnect on a route you already left.
Should I store the WebSocket in a Pinia store? #
Store the data in Pinia, but not the socket lifecycle. A store’s setup() runs once globally with no component lifecycle, so there is no onUnmounted to clean up. Open the socket in a component-scoped composable and write results into the store.
Why merge frames instead of replacing state? #
Replacing state.value with a fresh object invalidates every reactive dependency, re-rendering widgets whose data never changed. Spreading a patch ({ ...state.value, ...payload }) only triggers components that read the keys that actually moved.
Related #
- Vue 3 useWebSocket composable with auto-reconnect — the reconnect/backoff lifecycle this page builds on.
- Vue 3 Composables for Real-Time — the broader set of Vue composable patterns for live data.
- Memory Leak Prevention — teardown techniques shared across frameworks.
- Frontend WebSocket State Hooks & UI Patterns — reactive state and UI patterns for real-time apps.