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 useonScopeDispose,shallowRef, andeffectScope, 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.
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 |
6–10 |
After this, status becomes FAILED. Render a manual “Reconnect” button at the cap. |
JITTER_RATIO |
number | 0.3 |
0.2–0.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
FAILEDafter the retry limit. - Server-side connection count. On the host,
ss -tnp 'sport = :8080' | wc -lshould 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.
WebSocketandsetTimeoutretainers should not accumulate. The teardown discipline here is the same one explained in preventing memory leaks in React useEffect WebSockets.
Guides in this area #
- Vue 3 useWebSocket composable with auto-reconnect — a focused walkthrough of the reconnect state machine, close-code handling, and how to expose retry status to the template.
- Vue 3 real-time dashboard best practices — rendering many live widgets without re-render storms, using
shallowRef,provide/inject, and computed throttling.
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.
Related #
- Vue 3 useWebSocket composable with auto-reconnect — the reconnect state machine in detail.
- Vue 3 real-time dashboard best practices — rendering live data at scale without jank.
- React WebSocket Custom Hooks — the same lifecycle discipline in React’s
useEffect. - Svelte Stores for Real-Time — a store-based take on the same reconnecting transport.
- State Sync & Optimistic Updates — converging local UI state with the server stream.