Svelte WebSocket Store with Auto-Reconnect #
You wired a WebSocket into a Svelte component, navigated away, and the socket kept emitting into a destroyed component — or worse, every hot reload during development stacked another live connection until your terminal filled with 101 Switching Protocols and your server’s file-descriptor count crept upward. The fix is not a component-level onMount/onDestroy pair. It is a readable store whose lifecycle is driven by subscriber count: the socket opens when the first subscriber arrives and closes when the last one leaves. This page builds that store factory, with backoff reconnection, SvelteKit SSR guards, and HMR-safe teardown.
Root cause #
Svelte’s readable(initialValue, start) contract is the lever. The start notifier runs lazily — Svelte calls it only when the store goes from zero to one subscriber. The function you return from start (the stop notifier) runs only when the store goes from one to zero subscribers. That is exactly the WebSocket lifecycle you want: a connection should exist precisely while something is listening to it.
The mistakes that produce zombie sockets all come from bypassing this contract:
- Opening in module scope or
onMount. A socket created at import time opens during SSR (whereWebSocketis undefined and the import throws) or opens once per component instance, so two subscribers mean two sockets. - Reconnecting unconditionally in
onclose. When the stop notifier closes the socket deliberately, thecloseevent still fires. If theonclosehandler schedules a reconnect without checking an intentional-close flag, tearing down the store starts a reconnect loop instead of ending one. - Ignoring HMR. Vite’s hot module replacement re-evaluates the module but does not run your stop notifier. The previous socket stays open while the new module opens its own, duplicating every message. This is a development-only leak, but it corrupts state and exhausts dev-server connections.
The state machine the store moves through is small and worth fixing in your head before reading the code. The same backoff thinking applies on the server side too — see Auto-Reconnection Strategies for the reconnect-storm failure modes a careless client can trigger.
Resolution #
The factory below returns a readable store whose value carries both the latest message and a connection status. It guards SSR with browser, increments a generation counter to neutralize stale timers, and disposes the previous socket on HMR via import.meta.hot.
import { readable, type Readable } from 'svelte/store';
import { browser } from '$app/environment';
const BASE_DELAY_MS = 500; // first reconnect wait
const MAX_DELAY_MS = 15_000; // backoff ceiling
const noop = () => {}; // SSR stop notifier: nothing to tear down
export type SocketStatus = 'connecting' | 'open' | 'closed';
export interface SocketState<T> {
status: SocketStatus;
lastMessage: T | null; // most recent decoded payload
}
export function websocketStore<T = unknown>(url: string): Readable<SocketState<T>> {
const initial: SocketState<T> = { status: 'connecting', lastMessage: null };
return readable<SocketState<T>>(initial, (set) => {
// SSR guard: on the server `WebSocket` is undefined. Return a no-op stop
// notifier so the store still satisfies its contract during prerender/SSR.
if (!browser) return noop;
let socket: WebSocket | null = null;
let attempt = 0; // backoff exponent, reset on clean open
let timer: ReturnType<typeof setTimeout> | undefined;
let closedByStore = false; // true only when the stop notifier runs
const backoffDelay = () =>
Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
const connect = () => {
set({ status: 'connecting', lastMessage: null });
socket = new WebSocket(url);
socket.onopen = () => {
attempt = 0; // success resets the backoff window
set({ status: 'open', lastMessage: null });
};
socket.onmessage = (event) => {
// Decode once here; subscribers receive parsed data, not raw frames.
const lastMessage = JSON.parse(event.data) as T;
set({ status: 'open', lastMessage });
};
socket.onclose = () => {
if (closedByStore) return; // deliberate teardown: do NOT reconnect
set({ status: 'closed', lastMessage: null });
timer = setTimeout(connect, backoffDelay());
attempt += 1; // grow the next wait
};
// `onerror` precedes `onclose`; let `onclose` own the reconnect so we
// never schedule two timers for one failure.
socket.onerror = () => socket?.close();
};
connect();
// Stop notifier: runs when the LAST subscriber leaves. This is the only
// place that sets `closedByStore`, so the reconnect loop ends cleanly.
return () => {
closedByStore = true;
clearTimeout(timer);
socket?.close(1000, 'store unsubscribed'); // 1000 = normal closure
};
});
}
Wire it into a module that also handles HMR. Keeping the store at module scope lets every component share one socket, and the import.meta.hot.dispose hook unsubscribes the old instance before Vite swaps the module:
// src/lib/stores/ticker.ts
import { websocketStore } from './websocketStore';
export const ticker = websocketStore<{ symbol: string; price: number }>(
'wss://stream.example.com/ticker',
);
// During dev, dispose any live subscription on this module before replacement
// so HMR does not leave a second socket open.
if (import.meta.hot) {
let dispose = ticker.subscribe(() => {}); // keep-alive + handle to tear down
import.meta.hot.dispose(() => dispose());
}
In a component, $ticker reads the latest state and the socket exists only while that component (or any other subscriber) is mounted:
<script lang="ts">
import { ticker } from '$lib/stores/ticker';
</script>
{#if $ticker.status === 'open'}
<p>{$ticker.lastMessage?.symbol}: {$ticker.lastMessage?.price}</p>
{:else}
<p>Status: {$ticker.status}</p>
{/if}
The same subscriber-count discipline underpins hook-based clients in other frameworks; if you are porting from React, compare the effect-cleanup approach in React WebSocket Custom Hooks — the closedByStore flag plays the role React’s intentional-close ref plays.
Operational checklist #
- Confirm the store opens one socket for two simultaneous component subscribers (watch the Network tab for a single
101 Switching Protocols - Verify the stop notifier fires: subscribe, then unsubscribe all and assert the close frame has code
1000 - Trigger a server-side
prerender/SSR pass and confirm noWebSocket is not definederror — the!browser - Save a file to force HMR and assert the live socket count stays at one (check
ss -tnp | grep ESTAB - Kill the server mid-stream and watch
statuscycleopen → closed → connecting, with the reconnect delay growing towardMAX_DELAY_MS - After a successful reconnect, confirm
attemptresets so the next failure starts atBASE_DELAY_MS - Add jitter to
backoffDelay()
FAQ #
Why a readable store instead of onMount/onDestroy in a component? #
Because the socket should be tied to demand, not to a single component’s lifecycle. A readable start notifier runs on the zero-to-one subscriber transition and the stop notifier on the one-to-zero transition, so one socket is shared across every subscriber and is closed exactly when nothing is listening. An onMount approach opens a socket per component instance and cannot dedupe.
How do I avoid duplicate sockets during HMR? #
Register import.meta.hot.dispose(...) in the module that creates the store and call the unsubscribe function it returns. Vite re-evaluates the module on hot reload but never runs your stop notifier automatically, so without dispose the old socket stays open alongside the new one. The dispose hook drops the keep-alive subscription, which fires the stop notifier and closes the old socket.
Does the !browser guard break server-side rendering? #
No — that is the point. On the server WebSocket is undefined, so the guard returns the noop stop notifier and the store keeps its initial connecting value through prerender and SSR. The real connection opens only after hydration in the browser, when a component first subscribes. See Frontend WebSocket State Hooks & UI Patterns for the broader hydration-safety patterns.
How do I expose connection status separately from messages? #
The store value is a single SocketState<T> object carrying both status and lastMessage. In a component you read $ticker.status for UI gating and $ticker.lastMessage for data. If you prefer two stores, use derived(ticker, ($t) => $t.status) — but keep one socket and one source of truth.
Why not reconnect inside onerror? #
onerror always precedes onclose, and the browser fires close after every failed or dropped connection. Reconnecting in both handlers schedules two timers per failure, doubling your effective request rate. Let onerror only call socket.close() and let onclose own the single reconnect path, gated by the closedByStore flag so deliberate teardown never restarts the loop.
Related #
- Svelte Stores for Real-Time — the parent guide covering store factories, derived stores, and real-time UI binding.
- React WebSocket Custom Hooks — the equivalent effect-based lifecycle pattern for React.
- Auto-Reconnection Strategies — server-side view of backoff, jitter, and reconnect storms.
- Frontend WebSocket State Hooks & UI Patterns — the area overview tying state stores to UI rendering concerns.
Back to Svelte Stores for Real-Time.