A Vue 3 useWebSocket Composable That Survives Reconnects, HMR, and Tab Switches #
You wired a new WebSocket() into a Vue component, exposed a ref for the messages, and shipped it. Then you noticed the dashboard accumulates duplicate sockets after every hot reload, keeps reconnecting in a tight loop after the laptop sleeps, and leaves a dangling connection when the component using it unmounts. The fix is not more watch calls — it is a single composable that owns the socket’s entire lifecycle and ties every side effect to the active reactive scope. This page builds that composable for the Composition API, returning reactive status, data, and send, with backoff reconnect and deterministic teardown.
Root cause #
The trouble comes from three places where the WebSocket lifecycle and Vue’s reactivity lifecycle disagree.
First, scope and cleanup. A composable called from <script setup> runs inside the component’s effectScope. Anything you start — a socket, a timer, an event listener — outlives that scope unless you explicitly stop it. onUnmounted covers the component case, but a composable can also be invoked inside a detached effectScope() (a Pinia store, a useAsyncState wrapper) where no component-unmount event ever fires. onScopeDispose is the primitive that runs whenever the current scope is torn down, component or not, which is why it is the correct teardown hook for a reusable composable.
Second, hot module replacement. When Vite swaps a module, it re-runs setup against a component instance whose previous effects may not have disposed yet in the order you expect. If the composable opens the socket as a module-level side effect, or keeps the WebSocket object in a module-scoped variable, the old socket stays OPEN while the new one connects. You get N sockets for N saves, each with its own onmessage handler still pushing into a now-orphaned ref.
Third, reconnect storms when the tab is hidden. Browsers throttle background timers and frequently drop idle WebSocket connections after the tab is hidden for a while. An unconditional onclose → setTimeout(connect, delay) loop will keep firing in the background, burning the backoff ceiling so that when the user returns, the socket is stuck at its maximum delay. Reconnect should pause while document.hidden is true and resume immediately on visibilitychange. This is the same disconnect-handling discipline described in auto-reconnection strategies, applied on the client side.
Resolution #
The composable below keeps the live socket and the pending timer in plain (non-reactive) variables, exposes only status, data, and send as reactive surface, and registers a single onScopeDispose that both Vue’s component unmount and any standalone effectScope will trigger. The disposed flag is the guard that prevents a late onclose from scheduling a reconnect after teardown.
import { ref, shallowRef, readonly, onScopeDispose, type Ref } from 'vue';
export type WSStatus = 'CONNECTING' | 'OPEN' | 'CLOSED';
interface UseWebSocketOptions {
immediate?: boolean; // connect on setup (default true)
maxBackoffMs?: number; // ceiling for reconnect delay
}
const BASE_BACKOFF_MS = 500; // first retry delay
const DEFAULT_MAX_BACKOFF_MS = 15_000;
const BACKOFF_FACTOR = 2; // exponential growth per attempt
export function useWebSocket<T = unknown>(url: string, opts: UseWebSocketOptions = {}) {
const { immediate = true, maxBackoffMs = DEFAULT_MAX_BACKOFF_MS } = opts;
const status = ref<WSStatus>('CLOSED');
// shallowRef: we store raw message payloads, not deeply-reactive trees,
// so Vue does not walk every incoming object on each frame.
const data = shallowRef<T | null>(null);
// Plain variables — intentionally NOT refs. A live WebSocket and a timer id
// are imperative handles; making them reactive only invites re-render churn.
let socket: WebSocket | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let attempt = 0; // reconnect attempt counter, drives backoff
let disposed = false; // set once the scope is torn down
function clearRetry() {
if (retryTimer !== null) { clearTimeout(retryTimer); retryTimer = null; }
}
function scheduleReconnect() {
// Never reconnect after disposal or while the tab is in the background.
if (disposed || document.hidden) return;
clearRetry();
// Exponential backoff with a hard ceiling; ++attempt grows the delay.
const delay = Math.min(BASE_BACKOFF_MS * BACKOFF_FACTOR ** attempt++, maxBackoffMs);
retryTimer = setTimeout(connect, delay);
}
function connect() {
if (disposed) return;
// HMR / double-call guard: tear down any socket we still own before opening
// a new one, so a module reload cannot leave two live connections.
if (socket) { socket.onclose = null; socket.close(); socket = null; }
status.value = 'CONNECTING';
const ws = new WebSocket(url);
socket = ws;
ws.onopen = () => {
if (disposed) { ws.close(); return; } // scope vanished mid-handshake
attempt = 0; // reset backoff on a clean open
status.value = 'OPEN';
};
ws.onmessage = (ev: MessageEvent) => {
try { data.value = JSON.parse(ev.data) as T; }
catch { data.value = ev.data as T; } // fall back to raw frame
};
ws.onclose = () => {
if (socket === ws) socket = null; // ignore closes from a superseded socket
status.value = 'CLOSED';
scheduleReconnect(); // pauses itself if hidden/disposed
};
ws.onerror = () => ws.close(); // normalise errors into the close path
}
function send(payload: unknown): boolean {
if (socket?.readyState !== WebSocket.OPEN) return false; // caller can buffer
socket.send(typeof payload === 'string' ? payload : JSON.stringify(payload));
return true;
}
function onVisibility() {
// Resume immediately when the user returns to a CLOSED socket.
if (!document.hidden && status.value === 'CLOSED') { attempt = 0; connect(); }
}
document.addEventListener('visibilitychange', onVisibility);
// Runs on component unmount AND on standalone effectScope().stop().
onScopeDispose(() => {
disposed = true;
clearRetry();
document.removeEventListener('visibilitychange', onVisibility);
if (socket) { socket.onclose = null; socket.close(); socket = null; }
});
if (immediate) connect();
return {
status: readonly(status) as Readonly<Ref<WSStatus>>,
data: readonly(data) as Readonly<Ref<T | null>>,
send,
};
}
Used from a component, the reactive surface is small and the teardown is automatic:
// <script setup lang="ts">
const { status, data, send } = useWebSocket<{ price: number }>('wss://api.example.com/ticks');
// status.value / data.value update reactively; send() returns false if not OPEN.
// No onUnmounted needed — onScopeDispose inside the composable handles it.
Note that nulling socket.onclose before calling close() in both connect() and the disposal hook is deliberate: an intentional close must not re-enter scheduleReconnect(). Resetting attempt = 0 on onopen and on visibility-resume keeps the backoff curve honest — a successful connection should not inherit the previous failure’s delay. For wiring this into a full screen, see Vue 3 real-time dashboard best practices.
Operational checklist #
Scope disposal is what makes the composable safe outside a component, and it is worth seeing where each teardown hook actually fires.
FAQ #
Why use onScopeDispose instead of onUnmounted? #
onUnmounted only fires for component instances. A composable is reusable — it may run inside a Pinia store or a manually created effectScope() that never unmounts a component. onScopeDispose runs whenever the current reactive scope is disposed, covering both cases, so the socket is always cleaned up regardless of where the composable was invoked.
Why are the socket and timer plain variables instead of refs? #
A WebSocket instance and a setTimeout id are imperative handles, not view state. Wrapping them in reactive refs would trigger unnecessary dependency tracking and re-renders without any benefit. Only status and data drive the template, so only those are reactive — and data uses shallowRef to avoid deep-walking every incoming payload.
How does this prevent duplicate sockets during HMR? #
Two guards. connect() closes any socket the composable still owns before opening a new one, and onScopeDispose closes the socket when the old module instance is disposed. Because the live socket lives in a closure variable rather than at module scope, a hot reload cannot strand a connection behind a stale module reference.
Does pausing reconnect when hidden lose messages? #
It pauses retries, not an open connection. If the socket is already OPEN, hiding the tab does nothing here. It only prevents new reconnect attempts from burning the backoff ceiling while no one is watching; on visibilitychange back to visible, a CLOSED socket reconnects immediately with a reset attempt counter.
Can I delay connecting until the user acts? #
Yes. Pass { immediate: false } and the composable skips the initial connect(). Expose connect from the return object if you want the caller to trigger it on demand — the disposal hook still tears down whatever socket exists when the scope ends.
Why onScopeDispose rather than onUnmounted? #
Because a composable is not only called from components. Invoked inside a standalone effectScope() — which is what a Pinia store or a useAsyncState wrapper creates — there is no component to unmount, so onUnmounted never fires and the socket leaks silently. onScopeDispose runs whenever the current scope is torn down, component or not, which is the only correct guarantee for a reusable composable.
Should the composable pause when the tab is hidden? #
For high-frequency feeds, yes — a background tab is throttled anyway, and the queued messages arrive in 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.
Related #
- Vue 3 Composables for Real-Time WebSocket Data — the parent area covering composable lifecycle and reactivity patterns.
- Vue 3 Real-Time Dashboard Best Practices — applying this composable across a full reactive dashboard.
- Auto-Reconnection Strategies — server-side disconnect handling that pairs with client backoff.
- Building a useWebSocket React Hook with TypeScript — the same lifecycle problem solved with React refs and effects.