SvelteKit SSR-Safe WebSocket Initialization #
ReferenceError: WebSocket is not defined, thrown from a store module during server rendering, and the whole route returns a 500. Or the subtler variant: the page renders, hydrates, and then every navigation opens another socket, so after ten minutes of clicking around the user holds fourteen connections and the server sees one user as fourteen sessions.
Both come from the same misunderstanding. In SvelteKit a module is evaluated on the server and in the browser, and anything at module top level runs in both.
Root cause #
WebSocket is a browser global. Node 22 and later do expose a global WebSocket, so the error may not appear on newer runtimes — which is worse, not better, because now the server opens a connection during rendering. That connection is never closed, is not associated with any user, and multiplies by every request the server handles.
Three specific patterns cause it:
Module-level construction. export const socket = new WebSocket(URL) in a .ts store module runs the moment the module is imported, and SvelteKit imports it on the server to render the component that uses it.
writable with a start function that connects. Svelte’s writable(initial, start) only calls start when the store gains its first subscriber — which sounds safe, but server rendering does subscribe to stores in order to read their values, so the start function runs on the server too.
Connecting in load. A load function in +page.ts runs on both server and client by design. It is the right place to fetch initial state over HTTP and the wrong place to open a socket.
The browser constant from $app/environment is the guard, and onMount is the hook that only ever runs client-side — because SvelteKit does not call onMount during SSR at all.
Resolution #
Build the store so that it is inert on the server and connects only when a browser subscriber appears. The browser guard makes the start function a no-op during SSR, and the returned stop function handles teardown for both navigation and hot module replacement.
// src/lib/stores/realtime.ts
import { readable, writable, derived, get } from 'svelte/store';
import { browser } from '$app/environment';
const BASE_BACKOFF_MS = 1_000;
const MAX_BACKOFF_MS = 30_000;
export type Status = 'idle' | 'connecting' | 'open' | 'reconnecting';
export const status = writable<Status>('idle');
function createStream(url: string) {
return readable<unknown | null>(null, (set) => {
// The start function runs on the first subscriber — including SSR's read.
// Bail out on the server: the store simply stays at its initial value, which
// is exactly what should be rendered before hydration.
if (!browser) return;
let socket: WebSocket | null = null;
let retry: ReturnType<typeof setTimeout> | undefined;
let attempt = 0;
let disposed = false;
const open = () => {
if (disposed) return;
status.set(attempt === 0 ? 'connecting' : 'reconnecting');
socket = new WebSocket(url);
socket.onopen = () => { attempt = 0; status.set('open'); };
socket.onmessage = (ev) => {
try { set(JSON.parse(ev.data)); } catch { /* ignore malformed frames */ }
};
socket.onclose = (ev) => {
socket = null;
if (disposed || ev.code === 1000) { status.set('idle'); return; }
const ceiling = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** attempt++);
retry = setTimeout(open, Math.random() * ceiling); // full jitter
status.set('reconnecting');
};
};
open();
// Runs when the last subscriber leaves — navigation, unmount, or an HMR
// module swap. Without `disposed`, a pending retry would resurrect a socket
// for a store nobody is listening to any more.
return () => {
disposed = true;
clearTimeout(retry);
socket?.close(1000, 'store stopped');
socket = null;
status.set('idle');
};
});
}
export const stream = createStream(
browser ? `wss://${location.host}/ws` : 'wss://placeholder/ws',
);
In the component, render from the store and never touch the socket directly. Because the store yields null on the server, the initial HTML is the empty state and hydration replaces it as soon as data arrives — no mismatch, no flash of wrong content.
<script lang="ts">
import { onMount } from 'svelte';
import { stream, status } from '$lib/stores/realtime';
// Anything genuinely imperative goes here: onMount never runs during SSR.
onMount(() => {
const t = setInterval(() => console.debug('status', $status), 30_000);
return () => clearInterval(t);
});
</script>
<p class="status" data-testid="ws-status">{$status}</p>
{#if $stream}
<pre>{JSON.stringify($stream, null, 2)}</pre>
{:else}
<p>Waiting for the first update…</p>
{/if}
The URL deserves one note. Building it from location.host inside a browser ternary keeps it correct across environments without a build-time constant, but the placeholder branch must never be reachable at runtime — it exists only to satisfy the type on the server, where the start function returns before using it.
Verification #
The failure is environment-specific, so check both environments explicitly.
# Does the route render at all with no DOM? A 500 here is the SSR bug.
npm run build && node build/index.js &
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/dashboard
# Does the SERVER hold connections? It must not.
ss -tanp | grep -c ESTAB
# Does navigating repeatedly leak sockets in the browser?
# In DevTools console after clicking through routes ten times:
# performance.getEntriesByType('resource').filter(e => e.name.startsWith('ws')).length
The strongest single check is disabling JavaScript and loading the page: the server-rendered HTML should show the empty state cleanly, with no error and no missing markup. If the page 500s with JS disabled, something in the render path is touching a browser API — and the stack trace will name it.
Operational checklist #
FAQ #
Why does this happen on Node 22 when WebSocket exists there? #
Because the error was never the point — the connection was. With a global WebSocket available, the unguarded code stops throwing and starts succeeding on the server, opening one connection per server render. That is far harder to notice and far more damaging: connections accumulate on the server, are attributed to no user, and never close. Keep the browser guard regardless of runtime.
Can I connect in +page.ts load if I check browser? #
You can, but you should not. load re-runs on navigation and on invalidation, so a socket opened there is opened again on every rerun, and there is no corresponding teardown hook. The store start function gives you both a single connection and a stop callback tied to subscriber lifetime, which is exactly the shape this problem needs.
How do I share one connection across several components? #
The readable store already does it: Svelte calls start on the first subscriber and the stop function after the last one leaves, so any number of components subscribing to the same store share one socket. That is reference counting for free — the same design described in sharing one WebSocket across React components, which React needs to build by hand.
Does hot module replacement leak sockets in development? #
It will, unless the store’s stop function actually closes the socket. When Vite swaps the module, the old store is discarded and its subscribers are torn down, which triggers stop — but only if you returned one. A dev session that accumulates connections on every save is the clearest possible sign the teardown path is missing.
What about +page.server.ts — can it use WebSockets? #
No, and it should not want to. Server load functions run per request and have no client to stream to; anything they open is a server-to-server connection with a request-scoped lifetime, which is the wrong shape for a subscription. If the server needs to consume a real-time feed, that belongs in a long-lived server process, not in a load function.
Related #
- Svelte Stores for Real-Time — the store patterns this makes safe under server rendering.
- Svelte WebSocket Store with Auto-Reconnect — the reconnect logic inside the guarded start function.
- Sharing One WebSocket Across React Components — the same reference-counting idea in React.
- Preventing Memory Leaks in React useEffect WebSockets — the teardown rules that apply to any framework.
- Testing Real-Time Frontends — asserting that server rendering stays connection-free.
Back to Svelte Stores for Real-Time