Vue 3 Composables for Real-Time #

A dashboard renders live metrics through a useWebSocket composable. It works in development, then a user navigates between routes forty times in a session and the tab grinds to a halt: every mounted-then-unmounted view left a socket open, each still firing onmessage into a ref that no live component reads. Forty zombie connections, forty reconnect timers, and a memory graph that only climbs. The bug is not the WebSocket — it is that the connection’s lifecycle was never bound to Vue’s reactive scope.

This page shows how to build a Vue 3 composable that ties a WebSocket’s open/close/reconnect lifecycle to the Composition API’s effect scope, so teardown is automatic and deterministic. You will get a typed, reconnecting useWebSocket composable, a configuration reference for tuning it in production, and the edge cases that bite when network conditions turn hostile. It sits within the broader Frontend WebSocket State Hooks & UI Patterns area and mirrors the same teardown discipline you would apply with React WebSocket custom hooks or Svelte stores for real-time.

Prerequisites #

Before the frontend composable matters, the transport underneath it must be sound:

  • A WebSocket endpoint that survives proxies. If you front the server with nginx, the upgrade headers must be set as described in configuring nginx for WebSocket upgrades, or every connection dies at the load balancer.
  • Server-side heartbeats. The browser cannot detect a half-open TCP connection on its own; the backend should run ping/pong as covered in Connection Lifecycle & Heartbeats.
  • Vue 3.x with <script setup> and TypeScript. The patterns here use onScopeDispose, shallowRef, and effectScope, all stable since Vue 3.2.
  • A reconnection contract you understand end to end. The frontend backoff here pairs with server expectations documented under Auto-Reconnection Strategies.

How the composable binds to Vue’s scope #

The hardest concept is not the socket API — it is when cleanup runs. A composable called inside a component lives in that component’s effect scope. When the scope is disposed (unmount, hot-module replacement, or an explicit effectScope().stop()), onScopeDispose fires. Binding the socket’s teardown to that callback is what guarantees no connection outlives the component that owns it.

Vue effect scope owning a WebSocket lifecycle A component effect scope creates the socket and registers an onScopeDispose callback; on unmount the callback closes the socket and clears reconnect timers. Component effect scope useWebSocket() creates socket reactive refs status, data onScopeDispose registers cleanup Scope active socket OPEN, refs update reconnect on drop backoff + jitter Scope disposed close(1000), clear timers null the socket ref no zombie connection unmount

Core implementation #

The composable returns reactive state plus imperative controls. The lifecycle is owned by onScopeDispose, which is preferred over onUnmounted because it also fires for composables nested inside a manually created effectScope that has no direct component parent.

import { shallowRef, ref, readonly, onScopeDispose } from 'vue';

const NORMAL_CLOSURE = 1000; // clean close, no reconnect
const BASE_RECONNECT_MS = 1_000; // first backoff delay
const MAX_RECONNECT_MS = 30_000; // backoff ceiling
const MAX_RETRIES = 8; // give up after this many attempts
const JITTER_RATIO = 0.3; // randomize up to ±30% to avoid thundering herd

type ConnectionState = 'IDLE' | 'CONNECTING' | 'OPEN' | 'CLOSED' | 'FAILED';

export interface UseWebSocketOptions {
protocols?: string[];
autoConnect?: boolean; // connect immediately on creation (default true)
}

export function useWebSocket<T = unknown>(url: string, options: UseWebSocketOptions = {}) {
const { protocols, autoConnect = true } = options;

// shallowRef: the payload is replaced wholesale, so we skip deep proxying for throughput
const data = shallowRef<T | null>(null);
const status = ref<ConnectionState>('IDLE');
const error = ref<Error | null>(null);

let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let retries = 0;
let manualClose = false; // distinguishes intentional disconnect from a drop

function connect(): void {
// Guard against double-connects: only act from a settled, non-open state
if (status.value === 'CONNECTING' || status.value === 'OPEN') return;
manualClose = false;
status.value = 'CONNECTING';

ws = new WebSocket(url, protocols);

ws.onopen = () => {
status.value = 'OPEN';
error.value = null;
retries = 0; // reset backoff on a successful open
};

ws.onmessage = (event: MessageEvent) => {
try {
// Assigning to shallowRef.value triggers reactivity without traversing the object
data.value = JSON.parse(event.data) as T;
} catch {
error.value = new Error('Malformed WebSocket payload');
}
};

ws.onerror = () => {
// The browser fires error then close; we record but let onclose drive reconnect
error.value = new Error('WebSocket transport error');
};

ws.onclose = (event: CloseEvent) => {
ws = null;
if (manualClose || event.code === NORMAL_CLOSURE) {
status.value = 'CLOSED';
return;
}
status.value = 'CLOSED';
scheduleReconnect(); // unexpected drop: back off and retry
};
}

function scheduleReconnect(): void {
if (retries >= MAX_RETRIES) {
status.value = 'FAILED'; // surface a terminal state the UI can render
return;
}
// Exponential backoff capped at the ceiling, then jittered to desynchronize clients
const backoff = Math.min(BASE_RECONNECT_MS * 2 ** retries, MAX_RECONNECT_MS);
const jitter = backoff * JITTER_RATIO * (Math.random() * 2 - 1);
retries += 1;
reconnectTimer = setTimeout(connect, Math.round(backoff + jitter));
}

function disconnect(code = NORMAL_CLOSURE, reason = 'Client disconnect'): void {
manualClose = true; // suppress the reconnect branch in onclose
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
ws.close(code, reason);
}
ws = null;
status.value = 'CLOSED';
}

function send(payload: unknown): boolean {
if (ws?.readyState !== WebSocket.OPEN) return false; // never throw on a closed socket
ws.send(typeof payload === 'string' ? payload : JSON.stringify(payload));
return true;
}

// The single source of truth for teardown — fires on unmount, HMR, or effectScope.stop()
onScopeDispose(() => disconnect());

if (autoConnect) connect();

return {
data: readonly(data), // expose read-only refs; mutate only via send/connect
status: readonly(status),
error: readonly(error),
connect,
disconnect,
send,
};
}

Used inside a component, the entire lifecycle disappears into the scope:

import { computed } from 'vue';
import { useWebSocket } from './useWebSocket';

interface Metrics { activeUsers: number; latencyMs: number; }

const { data, status } = useWebSocket<Metrics>('wss://example.com/ws/metrics');

const isLive = computed(() => status.value === 'OPEN');
const activeUsers = computed(() => data.value?.activeUsers ?? 0);
// No onUnmounted, no manual close — onScopeDispose handles it.

Configuration reference #

Parameter Type Default Production value Notes
BASE_RECONNECT_MS number 1000 1000 First retry delay. Lower feels snappier but stampedes the server on mass outage.
MAX_RECONNECT_MS number 30000 30000 Backoff ceiling. Keep above your load balancer idle timeout so retries spread out.
MAX_RETRIES number 8 610 After this, status becomes FAILED. Render a manual “Reconnect” button at the cap.
JITTER_RATIO number 0.3 0.20.5 Randomizes delay ±ratio to break synchronized reconnect waves. Never 0 in production.
autoConnect boolean true true Set false when the socket needs an auth token resolved before opening.
protocols string[] undefined subprotocol list Use for protocol versioning, e.g. ['v2.metrics'].
close code number 1000 1000 Code 1000 signals a clean close and suppresses reconnect.

Edge cases & gotchas #

Backgrounded tabs silently drop the socket. Mobile browsers and desktop power-saving throttle background tabs, so heartbeats stall and the connection half-closes without an onclose. Add a visibilitychange listener that calls connect() when the tab returns to visible, and rely on server heartbeats to evict the dead peer.

Double connections during navigation. If a route component remounts before the previous scope disposes (keep-alive, fast back/forward), you can briefly hold two sockets. The status guard at the top of connect() prevents a second open within one scope, but across scopes you need the disposal to complete — never share a raw socket between scopes without a reference count.

Reconnect storms after a server restart. When a backend instance restarts, thousands of clients reconnect at once. The exponential backoff with jitter here is the frontend half of the fix; the synchronized-retry problem is covered in depth in exponential backoff with jitter for WebSocket reconnects.

Reactivity overhead on high-frequency streams. A deeply reactive ref proxies every nested property on each assignment. For payloads arriving dozens of times per second, shallowRef (used above) replaces the value wholesale and skips traversal. If you only need to trigger renders occasionally, batch updates with requestAnimationFrame.

Verification #

Confirm the composable tears down cleanly and reconnects under failure:

  • No leaked connections. In Chrome DevTools, open the Network tab, filter to WS, then mount and unmount the component repeatedly. The connection count must return to baseline — a climbing list of open sockets means a scope is not disposing.
  • Reconnect with backoff. Kill the server and watch the WS frames: the client should retry at roughly 1s, 2s, 4s, 8s (± jitter), capping at 30s, then enter FAILED after the retry limit.
  • Server-side connection count. On the host, ss -tnp 'sport = :8080' | wc -l should drop as clients unmount. A flat count under churn confirms zombie sockets.
  • Memory profile. Take a heap snapshot, churn the component 50 times, take another. WebSocket and setTimeout retainers should not accumulate. The teardown discipline here is the same one explained in preventing memory leaks in React useEffect WebSockets.

Guides in this area #

Scope disposal and the reactivity trap #

Two Vue-specific mistakes account for most real-time bugs in composables, and neither is obvious from the documentation.

The first is teardown. A composable called from <script setup> runs inside the component’s effect scope, so onUnmounted fires and cleanup happens. But the same composable invoked inside a standalone effectScope() — which is what a Pinia store or a useAsyncState wrapper creates — has no component to unmount, so onUnmounted never runs and the socket leaks silently. onScopeDispose is the primitive that runs whenever the current scope is torn down, component or not, and it is the only correct teardown hook for a reusable composable.

The second is reactivity applied where it should not be. Wrapping a WebSocket instance in ref or reactive makes Vue traverse an object graph full of internal buffers, event machinery and socket state — expensive at best, and occasionally enough to break the socket outright. Keep the live socket, the reconnect timer and any pending-message queue in plain variables inside the composable’s closure, and expose only the reactive values consumers actually render.

The same judgement applies to the data. A small object read deeply is fine as a ref. A large array that is replaced wholesale on every update should be a shallowRef, because deep tracking on a collection you always replace is pure overhead. Aggregates belong in computed, which caches until its dependencies change. And anything updating faster than the eye can follow — a cursor position, a live sparkline — is better written directly to the DOM or a canvas than pushed through reactivity at all.

Together those choices are usually the difference between a dashboard that stays at sixty frames per second and one that degrades as more widgets mount. Neither is about the socket; both are about how much work each incoming message is allowed to schedule.

Which primitive for which data A table matching each kind of real-time data to the appropriate Vue reactivity primitive and the reason for that choice. Which primitive for which data Use Why Small object, deep reads ref / reactive fine-grained tracking Large array, replaced whole shallowRef skip deep traversal Live socket instance plain variable never make it reactive High-frequency values direct DOM write bypass reactivity Derived aggregates computed cached until deps change The default — make everything reactive — is wrong for three of these five rows
Five data shapes, five answers. Making the socket reactive is the single most expensive mistake.

Batching is the other half. Writing to a ref on every incoming message drives Vue’s reactivity at the message rate; accumulating into a plain array and flushing once per animation frame caps it at the display rate, with no visible difference because the screen could not have shown the intermediate states.

Reactive updates per second by message rate Unbatched writes drive reactivity at the incoming message rate while frame batching caps updates at the display refresh rate regardless of how fast messages arrive. Reactive updates per second by message rate one ref write per message versus one per animation frame Reactive writes unbatched 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 spend most of your frame budget on work the screen cannot show.

FAQ #

Why use onScopeDispose instead of onUnmounted? #

onUnmounted only fires when the composable is called directly inside a component’s setup. onScopeDispose fires for any code running inside the active effect scope, including composables nested several layers deep or run inside a manually created effectScope(). For a transport that must always close, the broader guarantee is what you want.

Should I share one socket across components with provide/inject? #

Yes, when several components need the same stream. Create the useWebSocket instance in a parent (or a dedicated provider component), provide its return object, and inject it in children. The socket then lives in the provider’s scope, so it closes when the provider unmounts — not when individual children do. Avoid reference-counting hacks unless children outlive the provider.

Does this work behind AWS ALB or nginx? #

It does, provided the proxy is configured for the upgrade and a generous idle timeout. The frontend backoff assumes the server may drop you on deploys or timeouts. Set the proxy idle timeout above your heartbeat interval, per configuring nginx for WebSocket upgrades.

What changes for Socket.IO versus raw ws? #

Socket.IO ships its own reconnection, acknowledgements, and multiplexing, so you would not hand-roll the backoff loop — you would wrap the Socket.IO client and bridge its events into reactive refs instead. The scope-binding pattern (onScopeDispose calling socket.disconnect()) stays identical; only the transport’s reconnect ownership moves.

How do I delay the connection until I have an auth token? #

Pass autoConnect: false, resolve the token (for example in an awaited setup), then call the returned connect(). Because connect() reads the closure’s url, append the token as a query param or send it in the first message after OPEN.

Why onScopeDispose rather than onUnmounted? #

Because a composable is not only called from components. Inside a standalone effectScope() — a Pinia store, a composition helper — there is no unmount event, so onUnmounted never fires and the socket outlives its owner. onScopeDispose runs on any scope teardown, which is the only guarantee that covers every legitimate call site.

Should a real-time composable pause when the tab is hidden? #

For high-frequency feeds, yes. A background tab is throttled anyway, and the queued messages arrive as a burst on refocus that can jank the first frame back. Listen for visibilitychange, stop applying updates while hidden, and request a fresh snapshot on return rather than replaying a backlog nobody saw. For low-rate feeds the complexity is not worth it.

How do several components share one connection? #

Hold the connection at module scope keyed by URL with a subscriber count, so each useWebSocket() call subscribes to the same socket and the last consumer to unmount closes it. Nine widgets should produce one connection. Opening one per composable call is the same per-component mistake that hooks make in React, and it has the same cost.

Does watchEffect cleanup replace onScopeDispose? #

For effects created inside the watcher, yes — the cleanup callback runs before each re-run and on scope disposal. For a connection created once when the composable is called, no: there is no watcher to attach it to, and wrapping the socket in a watcher purely to get cleanup is more machinery than the direct hook. Use onScopeDispose for the connection and watcher cleanup for anything derived from reactive inputs.

Back to Frontend WebSocket State Hooks & UI Patterns