Svelte 5 runes for WebSocket state #
You are migrating a real-time Svelte app from stores to Svelte 5 runes. The writable store that held the socket and its messages becomes a $state object, and suddenly the app slows down: every incoming message deep-proxies a large payload, a $effect that reconnects on status changes loops forever, and a component that used to unsubscribe automatically through the $store auto-subscription now leaks its socket on unmount. Runes change how reactivity is tracked — fine-grained signals instead of store subscriptions — and a WebSocket client written for stores needs a few deliberate adjustments to take advantage of them without inheriting new costs.
Root cause #
Svelte 5’s $state makes objects and arrays deeply reactive by wrapping them in proxies. That is ideal for UI state a user edits, and wasteful for data that arrives from the network, is replaced wholesale, and is only read: a 500-row snapshot assigned to $state creates proxies for every row and every nested object. With frequent updates the proxy creation, not the rendering, becomes the cost.
Effects behave differently from store subscriptions too. A $effect re-runs whenever any $state it read changes. An effect that reads client.status and calls connect() — which sets status — invalidates itself and runs again, forming a loop. And lifetimes move: a store’s subscribe/unsubscribe pair tied cleanup to the component’s auto-subscription, while runes tie cleanup to the $effect teardown function, which only exists if you return one.
Resolution #
Put the client in a .svelte.ts module as a class with $state fields, so runes work outside components. Use $state.raw for data received from the network: it is reactive on reassignment but not proxied, so replacing a snapshot costs one signal update instead of thousands of proxies. Expose derived views with $derived. Open and close the connection from a component $effect that reads only the inputs that should trigger reconnection, and returns a teardown.
// lib/feed.svelte.ts — runes are allowed in .svelte.ts modules
type Item = { id: string; text: string; seq: number };
type Status = 'idle' | 'connecting' | 'open' | 'closed';
const MAX_ITEMS = 500;
export class FeedClient {
status = $state<Status>('idle');
// Raw: reactive when reassigned, never deep-proxied. Network data is replaced, not mutated.
items = $state.raw<Item[]>([]);
lastSeq = 0; // plain field: not UI state, no reactivity needed
readUpTo = $state(0);
unread = $derived(this.items.filter((i) => i.seq > this.readUpTo).length);
private ws: WebSocket | null = null;
connect(url: string): () => void {
this.status = 'connecting';
const ws = new WebSocket(`${url}?afterSeq=${this.lastSeq}`);
this.ws = ws;
ws.onopen = () => { this.status = 'open'; };
ws.onclose = () => { if (this.ws === ws) this.status = 'closed'; };
ws.onmessage = (e) => {
const batch = JSON.parse(e.data) as Item[];
const fresh = batch.filter((i) => i.seq > this.lastSeq);
if (fresh.length === 0) return;
this.lastSeq = fresh[fresh.length - 1].seq;
// One reassignment per message: one signal, one render pass.
this.items = [...fresh.reverse(), ...this.items].slice(0, MAX_ITEMS);
};
// The teardown is returned so the caller's $effect can own the lifetime.
return () => {
this.ws = null;
ws.close(1000, 'effect teardown');
this.status = 'idle';
};
}
markAllRead() { this.readUpTo = this.lastSeq; }
}
export const feed = new FeedClient();
The component owns the lifetime. Its effect reads only url, so status changes made inside connect do not re-trigger it:
<script lang="ts">
import { feed } from '$lib/feed.svelte';
let { url }: { url: string } = $props();
$effect(() => {
// Reads `url` only. Returning the teardown closes the socket on unmount or url change.
return feed.connect(url);
});
</script>
<p class="status">{feed.status} — {feed.unread} unread</p>
<ul>
{#each feed.items as item (item.id)}
<li>{item.text}</li>
{/each}
</ul>
<button onclick={() => feed.markAllRead()}>Mark all read</button>
Why doesn’t the effect loop? Svelte tracks dependencies by what the effect reads synchronously. connect writes this.status but the effect body never reads it, so writing it does not schedule a re-run. If you do need to react to status — say, reconnecting on closed — do it in a separate effect with its own teardown, or inside the socket’s onclose handler, never by reading and writing the same state in one effect. The reconnection schedule itself is covered in a Svelte WebSocket store with auto-reconnect.
Edge cases #
Mutating raw state does nothing. feed.items.push(x) on a $state.raw array changes the array but triggers no update. Always reassign (feed.items = [...]). If a feature genuinely needs in-place edits of network data — an editable grid — use $state for that slice only.
Server-side rendering. In SvelteKit, $effect never runs on the server, so the socket is only opened in the browser, as required. A module-level feed instance, however, is shared across all server requests; keep only browser-side data in it, or create the client per request via context. The SSR side is covered in SvelteKit SSR-safe WebSocket initialization.
Interop with stores. Svelte 5 still supports stores, and existing store-based code keeps working. You can migrate incrementally: expose a store from the rune class with toStore, or consume a store in rune code with fromStore.
Verification #
Use Svelte DevTools or plain $inspect in development to watch what updates:
<script lang="ts">
import { feed } from '$lib/feed.svelte';
$inspect(feed.status); // logs every status change — should not loop
$inspect(feed.items.length); // one log per message batch, not per row
</script>
Then profile a burst: with the Performance panel recording, replay a few hundred messages through a fake socket and check that scripting time per message stays flat as the list grows. Unmount the component and confirm in the Network panel that the socket closed with code 1000, and that no further onmessage work appears in the profile.
Operational checklist #
FAQ #
Should I still use stores for WebSockets in Svelte 5? #
Stores still work, and they remain a good fit for library code that must support Svelte 4. For new Svelte 5 code, a rune-based class is simpler to type and gives fine-grained updates. Choose $state.raw for received data either way.
Why does my $effect keep reconnecting? #
It probably reads state that connect writes, such as status, so each connection attempt invalidates the effect. Make the effect read only its true inputs (the URL, the room id) and handle status-driven logic in socket callbacks.
What’s the difference between $state and $state.raw here? #
$state wraps objects in deep proxies so property mutations are tracked; $state.raw tracks only reassignment of the whole value. Network data is replaced rather than edited, so raw state gives the same UI behaviour at a fraction of the cost.
Can I use runes in a plain .ts file? #
No — runes are compiler features and only work in .svelte components and .svelte.ts/.svelte.js modules.
Related #
- Svelte WebSocket Store with Auto-Reconnect — the store-based predecessor.
- Derived Svelte Stores for WebSocket Streams — derivations with stores.
- SvelteKit SSR-Safe WebSocket Initialization — server rendering concerns.
- Batching WebSocket Updates with requestAnimationFrame — one assignment per frame.
Back to Svelte Stores for Real-Time.