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.

Where each piece of code actually runs Module top level, load functions, store start functions and component script bodies all execute during server rendering; only onMount is guaranteed to run exclusively in the browser after hydration. Where each piece of code actually runs Module top level server render AND browser — never construct a socket here load() in +page.ts server and browser — fetch initial state, not connections Store start function runs on first subscriber, including during SSR Component script body runs during SSR for the initial render onMount browser only, after hydration — connect here The rule is simple: if it can run on the server, it must not touch a browser API
Only the bottom band is browser-only. Everything above it must be safe to evaluate with no DOM and no WebSocket.

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.

What happens on a server-rendered first paint During server rendering the store's start function returns immediately because the browser flag is false, so the HTML contains the empty state; after hydration the same start function runs in the browser and opens the connection. What happens on a server-rendered first paint Server Browser Socket GET /dashboard store start: browser === false, no-op HTML with empty state hydrate — store start runs again new WebSocket(...) first message — UI updates The same store code runs twice — once inertly on the server, once for real in the browser
One store, two environments. The browser guard is what makes the server pass count for nothing.

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.

Where to put connection code in SvelteKit A table showing which SvelteKit code locations execute during server rendering, which are safe places to open a WebSocket, and what cleanup each offers. Where to put connection code in SvelteKit Runs on server Safe to connect Cleans up Module top level yes no never load() in +page.ts yes no no readable() start, unguarded yes no on last unsub readable() start + browser guard no-op yes on last unsub onMount no yes on destroy The guarded store start is preferred over onMount because it also handles the last-subscriber-leaves case
Two rows are safe. The guarded store start is the one that also gives you shared, reference-counted teardown.

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.

Back to Svelte Stores for Real-Time