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.

Socket lifecycle bound to component scope A route change unmounts the component; without teardown the socket survives and keeps mutating reactive state, while a scope-bound disconnect closes it cleanly. Mounted route opens 1 socket Route change component unmounts No teardown socket leaks, drift Scope disconnect socket closed clean onUnmounted / onScopeDispose detach handlers, then close(1000) guarantees one socket per mount

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 #

The reason a dashboard degrades is almost never the socket, and the arithmetic makes that unambiguous.

Reactive updates per second by message rate Writing to a ref on every incoming message drives Vue's reactivity at the message rate, while batching into one write per animation frame caps updates at the display refresh rate. Reactive updates per second by message rate one ref write per message versus one per animation frame Updates without batching With frame batching 0/s 100/s 200/s 300/s 20 msg/s 60/s 60 msg/s 150/s 45/s 150 msg/s 60/s 300 msg/s
Above about 60 messages a second, unbatched writes are spending most of your frame budget on work the screen cannot show.
Which reactive primitive for which data A table matching each kind of dashboard data to the appropriate Vue reactivity primitive and the reason that choice matters for performance. Which reactive primitive for which data Use Why Small object, deep reads ref / reactive fine-grained tracking Large array replaced wholesale shallowRef skip deep traversal Live socket instance plain variable never make it reactive Cursor / high-frequency direct DOM write bypass reactivity Derived aggregates computed cached until deps change Making a WebSocket instance reactive is the single most expensive mistake here — Vue will traverse the whole object graph
Five data shapes, five different answers. The default — make everything reactive — is wrong for three of them.

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.

Should the socket instance be a ref? #

No. Wrapping a WebSocket in ref or reactive makes Vue traverse an object graph full of internal buffers and event machinery, which is expensive and occasionally breaks the socket outright. Keep the instance in a plain variable inside the composable’s closure and expose only the reactive values consumers need — status, last message, and a send function.

How many widgets can share one composable call? #

As many as you like, provided the composable shares its connection rather than opening one per call. The straightforward pattern is a module-level connection keyed by URL with reference counting, so each useWebSocket() call subscribes to the same socket and the last consumer to unmount closes it. Nine widgets should produce one connection, not nine.

Back to Vue 3 Composables for Real-Time WebSocket Data