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.

Composable lifecycle and socket states setup connects the socket; onScopeDispose tears it down; close triggers backoff reconnect unless the scope is gone or the tab is hidden. setup() runs in effectScope connect() status = OPEN onmessage data.value set onclose fires status = CLOSED backoff reconnect if scope + visible onScopeDispose close + clear timer Disposal halts reconnect; no orphaned sockets

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 #

  • Confirm the composable is called synchronously inside setup/<script setup> so onScopeDispose