Svelte Stores for Real-Time WebSocket Data #
Open a Svelte real-time view’s onMount, call new WebSocket(url), and you have a working demo. Ship it across a dozen components and the cracks appear: every component that needs the same feed opens its own socket, navigating away leaves sockets half-closed, and Vite’s hot-module replacement spawns a fresh connection on every save until the dev tab holds twenty zombie connections to the same endpoint. The onMount/onDestroy pair couples the socket’s lifecycle to component instances rather than to demand, so duplication and leaks are the default outcome.
Svelte’s store contract solves this at the right layer. A store’s start/stop notifier — the function you pass as the second argument to readable or writable — runs when the subscriber count goes from zero to one, and its returned teardown runs when the count drops back to zero. Tie a WebSocket to that notifier and the socket opens exactly once when the first component subscribes, stays shared across every additional subscriber, and closes deterministically when the last one leaves. No component lifecycle hooks, no manual reference counting, no leaked sockets.
This guide builds a reconnecting WebSocket store as a readable factory, wires $-auto-subscriptions for zero-boilerplate cleanup, and adds the SvelteKit browser guard that keeps new WebSocket from ever running during server-side rendering.
Prerequisites #
This guide sits under Frontend WebSocket State Hooks & UI Patterns and assumes the same transport baseline as the other framework guides. Before building the store, confirm:
- A reachable
wss://endpoint with the upgrade headers and timeouts already configured server-side — the store does not paper over a misconfigured proxy. - Familiarity with the lifecycle problem framed in Memory Leak Prevention; the store contract is Svelte’s structural answer to the same teardown discipline that effect cleanup enforces in React.
- A reconnect policy you are comfortable with. The factory here reuses the exponential-backoff thinking covered in React WebSocket Custom Hooks, adapted to the store’s notifier rather than a hook’s effect.
- SvelteKit 2 / Svelte 4+ (the
readablecontract and$app/environmentbrowserflag are stable across these).
How the start/stop notifier drives the socket #
The hardest concept to internalize is that the socket’s open and close are consequences of the subscriber count crossing zero, not events you trigger directly. The diagram below traces that flow: subscriptions accumulate, the notifier opens one shared socket, and the teardown fires only when the count returns to zero.
Because the count is managed by Svelte’s runtime, two components rendering the same $feed get one socket; remove both and the teardown closes it. You never write the bookkeeping.
Core implementation #
The factory below returns a Readable<FeedState>. The start notifier opens the socket, wires handlers, and schedules reconnects; the returned teardown closes the socket and cancels any pending reconnect. Critically, the whole body short-circuits when browser is false, so SvelteKit’s server render produces a valid store that simply never touches the WebSocket API.
import { readable, type Readable } from 'svelte/store';
import { browser } from '$app/environment'; // SvelteKit: true only in the client bundle
// Tunables live in one object so the table below maps 1:1 to code.
const BASE_RECONNECT_MS = 1_000; // first retry delay
const MAX_RECONNECT_MS = 30_000; // cap so backoff never runs away
const JITTER_RATIO = 0.3; // +/- randomization to de-sync reconnect storms
export type FeedStatus = 'idle' | 'connecting' | 'open' | 'closed';
export interface FeedState<T = unknown> {
status: FeedStatus;
data: T | null;
error: string | null;
}
const INITIAL: FeedState = { status: 'idle', data: null, error: null };
export function websocketStore<T = unknown>(url: string): Readable<FeedState<T>> {
// The second arg is the start/stop notifier. It runs ONCE when the
// subscriber count goes 0 -> 1, and its return value runs at 1 -> 0.
return readable<FeedState<T>>(INITIAL as FeedState<T>, (set) => {
// SSR guard: on the server `browser` is false. Return an empty teardown
// so the store is valid but never constructs a WebSocket. Without this,
// `new WebSocket` throws "WebSocket is not defined" during SvelteKit SSR.
if (!browser) return () => {};
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let attempt = 0;
let stopped = false; // set by teardown to suppress reconnect after close
const connect = () => {
set({ status: 'connecting', data: null, error: null });
ws = new WebSocket(url);
ws.onopen = () => {
attempt = 0; // reset backoff on a clean connection
set({ status: 'open', data: null, error: null });
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as T;
set({ status: 'open', data, error: null });
} catch {
set({ status: 'open', data: null, error: 'invalid JSON frame' });
}
};
ws.onerror = () => {
// onerror is always followed by onclose; defer reconnect to onclose
// so we schedule exactly one retry per failed connection.
set({ status: 'connecting', data: null, error: 'transport error' });
};
ws.onclose = () => {
ws = null;
if (stopped) return; // intentional close from teardown -> do not retry
set({ status: 'closed', data: null, error: null });
scheduleReconnect();
};
};
const scheduleReconnect = () => {
const backoff = Math.min(BASE_RECONNECT_MS * 2 ** attempt, MAX_RECONNECT_MS);
const jitter = backoff * JITTER_RATIO * (Math.random() * 2 - 1);
attempt += 1;
reconnectTimer = setTimeout(connect, backoff + jitter);
};
connect();
// Teardown: fires when the last subscriber leaves (count 1 -> 0).
return () => {
stopped = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (ws) {
ws.onclose = null; // detach handler so close does not trigger reconnect
ws.close(1000, 'no subscribers');
ws = null;
}
};
});
}
Consuming it is a single line of markup — the leading $ subscribes on mount and, more importantly, unsubscribes automatically when the component is destroyed. That auto-unsubscribe is what decrements the count and ultimately runs the teardown; you never call .subscribe() or .close() by hand.
<script lang="ts">
import { websocketStore } from '$lib/websocketStore';
// Module-scope so every component importing it shares ONE store instance,
// and therefore one socket, regardless of how many views subscribe.
const feed = websocketStore<{ price: number }>('wss://example.com/ticks');
</script>
{#if $feed.status === 'open' && $feed.data}
<span>{$feed.data.price}</span>
{:else if $feed.status === 'connecting'}
<span>connecting…</span>
{:else}
<span>offline</span>
{/if}
Define the store once at module scope (or export it from a $lib module) so the import is shared. Calling websocketStore(url) inside each component instead creates a separate store per component — and a separate socket — defeating the whole pattern.
Configuration reference #
| Parameter | Type | Default | Production value | Notes |
|---|---|---|---|---|
url |
string |
— | wss://… |
Must be wss:// in production; ws:// is blocked on HTTPS pages. |
BASE_RECONNECT_MS |
number |
1000 |
1000 |
First reconnect delay; doubled each attempt. |
MAX_RECONNECT_MS |
number |
30000 |
15000–30000 |
Caps backoff so a long outage doesn’t push retries to minutes. |
JITTER_RATIO |
number |
0.3 |
0.2–0.5 |
Spreads reconnects across clients to avoid a thundering herd on recovery. |
| close code | number |
1000 |
1000 |
Normal closure; teardown sends it so the server logs a clean disconnect. |
browser guard |
boolean |
— | required | From $app/environment; skip the socket entirely during SSR. |
Edge cases & gotchas #
SSR: window/WebSocket undefined. SvelteKit runs your component module on the server first. Any top-level new WebSocket(url) — or even reading window — throws ReferenceError and crashes the render. The if (!browser) return () => {} line inside the notifier is the fix: the store still constructs (so $feed is valid in SSR markup as the initial state), but it never reaches the WebSocket call. Never put the guard around the import; guard inside the notifier body.
Subscribe/unsubscribe churn closes the socket mid-navigation. During client-side navigation Svelte may destroy the old page’s component before mounting the new one. If both reference the same module-scope store, the count never hits zero and the socket survives — good. But if each route creates its own store instance, the count drops to zero on the old page, the teardown closes the socket, and the new page reopens it: a needless reconnect on every navigation. Share one module-scope store to avoid the close/reopen flap. For frequently toggled views, you can also delay teardown with a short grace period before ws.close().
HMR duplicate sockets. Vite HMR re-evaluates the changed module without a full reload. If the store lives in a module that re-executes, the old store’s teardown may not run, leaving its socket open while a new one connects — the “twenty zombies” symptom. Keep the store in a stable $lib module that rarely changes, and accept that a hard refresh clears any HMR-orphaned sockets. In dev, watch the Network panel’s WS list: more than one row per endpoint means an orphan.
onerror then onclose double-reconnect. The browser fires onerror immediately followed by onclose on a failed connection. Scheduling a reconnect in both handlers queues two timers and doubles the connection rate. Reconnect only in onclose (as above) and treat onerror as a status/log signal.
Verification #
Confirm exactly one socket per endpoint and clean teardown:
- DevTools Network → WS: filter to your endpoint. Mount two components that use the feed — you should see a single WS row, not two. Navigate away from all of them and the row’s status should move to closed.
- Subscriber-count probe: wrap
setcalls with aconsole.count('ws-open')inconnect()andconsole.count('ws-close')in the teardown during dev. Across mounts/unmounts the counts should stay balanced and the open count should not climb on re-subscribe to a shared store. - SSR smoke test: run
npm run build && node build(adapter-node) orvite buildand load a page that renders$feed. The build must succeed with noWebSocket is not defined— proof thebrowserguard short-circuits server execution. - Backoff inspection: kill the server and watch reconnect attempts in the Network panel; the gaps should grow toward
MAX_RECONNECT_MSand vary by the jitter ratio rather than firing in lockstep.
Guides in this area #
- Svelte WebSocket store with auto-reconnect — the full reconnecting store factory with exponential backoff and jitter, hardened for production and SvelteKit SSR.
FAQ #
Why use a store instead of opening the socket in onMount? #
onMount ties the socket to a single component instance, so N components that need the same feed open N sockets and each must remember to close in onDestroy. A store’s start/stop notifier ties the socket to demand: one socket opens at the first subscriber, is shared, and closes when the last subscriber leaves — with no per-component teardown code.
Does the store close the socket automatically? #
Yes, when the subscriber count drops to zero. The $feed auto-subscription unsubscribes on component destroy; when the last subscriber unsubscribes, the notifier’s returned teardown runs and calls ws.close(). You only get leaks if you call .subscribe() manually and forget to call the returned unsubscriber.
How do I avoid “WebSocket is not defined” in SvelteKit? #
Guard inside the notifier with SvelteKit’s browser flag from $app/environment: if (!browser) return () => {}. The store still constructs during SSR (so $feed renders the initial state), but the WebSocket constructor only runs in the client bundle.
Why is one socket opening per component / per navigation? #
You are almost certainly creating the store inside each component rather than once at module scope. Define const feed = websocketStore(url) in a shared $lib module and import it; every component then subscribes to the same instance, keeping the count above zero across navigations so the socket is reused instead of closed and reopened.
Should I reconnect in onerror or onclose? #
Only in onclose. The browser fires onerror and then onclose for a failed connection, so scheduling a retry in both doubles your reconnect rate. Use onerror purely to set an error status, and let the single onclose handler schedule the backed-off reconnect.
Related #
- Svelte WebSocket store with auto-reconnect — production-grade reconnecting store factory with backoff, jitter, and SSR guards.
- React WebSocket Custom Hooks — the hook-based equivalent of this pattern, including effect cleanup and reconnect policy.
- Memory Leak Prevention — the teardown discipline the store contract enforces structurally.
- Vue 3 Composables for Real-Time — the same lifecycle problem solved with Vue’s effect scope and
onScopeDispose.