Derived Svelte stores for WebSocket streams #
A trading dashboard subscribes to one WebSocket store of fills and derives a dozen views from it: fills for the selected symbol, total volume per symbol, a one-minute moving average, the count of unread alerts. Each view is a derived store, and each recomputes from the full history on every message. With a few thousand fills the dashboard drops frames on every tick, and when you navigate away and back, the upstream socket store keeps running because some derived store still holds a subscription. Derived stores are the idiomatic way to shape live data in Svelte, and they are lazy and composable by design — but only if each derivation does incremental work and the chain of subscriptions ends when the last consumer leaves.
Root cause #
A derived(source, fn) store runs fn with the source’s current value every time the source changes. If the source holds the whole history and fn filters or reduces it, every message costs O(history) per derived store, and with N derived stores O(N × history) per message. That is invisible in development with a hundred items and dominant in production after an hour.
Svelte stores are lazy: a derived store subscribes to its sources only while it has subscribers itself, and the socket store opens its connection only while it has subscribers (via the start function passed to readable). That laziness is what makes cleanup automatic — as long as every consumer eventually unsubscribes. A subscription taken manually in plain TypeScript with store.subscribe(...) and never released keeps the entire chain alive, socket included.
Resolution #
Make the source store emit events or batches, not ever-growing history, and make derived stores fold each new batch into their own state instead of recomputing from scratch. Svelte’s derived supports this with its (value, set) form plus a closure-held accumulator, or you can write small custom stores that keep incremental state. Window-based aggregates (moving averages, rates) evict old entries as time passes rather than scanning everything.
// lib/fills.ts
import { derived, readable, type Readable } from 'svelte/store';
export interface Fill { id: string; symbol: string; qty: number; price: number; at: number }
const WINDOW_MS = 60_000;
// Source: emits each incoming batch, not the accumulated history.
export function fillBatches(url: string): Readable<Fill[]> {
return readable<Fill[]>([], (set) => {
const ws = new WebSocket(url); // opened on first subscriber
ws.onmessage = (e) => set(JSON.parse(e.data) as Fill[]);
return () => ws.close(1000, 'no subscribers'); // closed after the last leaves
});
}
// Incremental aggregate: O(batch) per message, not O(history).
export function volumeBySymbol(batches: Readable<Fill[]>): Readable<Map<string, number>> {
let totals = new Map<string, number>(); // accumulator lives with the store
return derived(batches, (batch, set) => {
if (batch.length === 0) return;
totals = new Map(totals); // new identity so consumers update
for (const f of batch) totals.set(f.symbol, (totals.get(f.symbol) ?? 0) + f.qty);
set(totals);
}, new Map<string, number>());
}
// Sliding window: evict expired fills, keep a running sum.
export function movingAveragePrice(batches: Readable<Fill[]>, symbol: string): Readable<number | null> {
const window: Fill[] = [];
let sum = 0;
return derived(batches, (batch, set) => {
const now = Date.now();
for (const f of batch) if (f.symbol === symbol) { window.push(f); sum += f.price; }
while (window.length && now - window[0].at > WINDOW_MS) sum -= window.shift()!.price;
set(window.length ? sum / window.length : null);
}, null);
}
// Bounded recent list for display: newest first, fixed length.
export function recentFills(batches: Readable<Fill[]>, max = 200): Readable<Fill[]> {
let list: Fill[] = [];
return derived(batches, (batch, set) => {
if (batch.length === 0) return;
list = [...batch.slice().reverse(), ...list].slice(0, max);
set(list);
}, [] as Fill[]);
}
Consumers use them like any store; $-prefixed access in components subscribes and unsubscribes automatically:
<script lang="ts">
import { fillBatches, volumeBySymbol, recentFills } from '$lib/fills';
const batches = fillBatches('wss://md.example.com/fills');
const volume = volumeBySymbol(batches);
const recent = recentFills(batches);
</script>
{#each [...$volume] as [symbol, qty] (symbol)}
<p>{symbol}: {qty}</p>
{/each}
{#each $recent as f (f.id)}<li>{f.symbol} {f.qty} @ {f.price}</li>{/each}
Note that each derived store subscribes to batches separately, and a readable’s start runs once for the first subscriber and is shared — so one socket serves all three views. Accumulators live in the closure of each derived store, which means they reset when the store is recreated; if a view must survive remounts, create the stores in a module (or a context) rather than inside the component.
Edge cases #
Late subscribers. A derived store created after messages have flowed starts from its initial value, because the source emits batches rather than history. For views that need the past, have the source store keep a small bounded history and replay it to the derived store’s first computation, or request a snapshot from the server on subscribe.
Derived from several sources. derived([a, b], ([$a, $b], set) => …) runs when either changes, and you cannot tell which one did. For incremental folds over two streams, give each source its own derived accumulator and combine the results in a third derived store that only does cheap work.
Timers in windows. A sliding window only evicts when a new batch arrives. If the stream goes quiet, the window keeps stale entries. Add a readable clock (ticking every few seconds) as a second source so eviction runs on time, not only on data.
Verification #
Test derived stores without a component using get from svelte/store and a fake source:
import { get, writable } from 'svelte/store';
import { volumeBySymbol } from '$lib/fills';
it('folds batches incrementally', () => {
const src = writable<any[]>([]);
const vol = volumeBySymbol(src);
const unsub = vol.subscribe(() => {}); // keep it active
src.set([{ symbol: 'AAPL', qty: 10 }]);
src.set([{ symbol: 'AAPL', qty: 5 }, { symbol: 'MSFT', qty: 3 }]);
expect(get(vol)).toEqual(new Map([['AAPL', 15], ['MSFT', 3]]));
unsub();
});
In the browser, navigate away from the dashboard and confirm the socket closes (Network panel, WS view) — proof that no subscriber is left holding the chain. Then profile a long session: scripting time per message should not grow with session length.
Operational checklist #
FAQ #
Why is my derived store slow with WebSocket data? #
It most likely recomputes over the entire history on every message. Emit batches from the source and fold each batch into an accumulator, so each message costs work proportional to the batch.
When does a readable store open and close its WebSocket? #
Its start function runs when the first subscriber arrives, and the function it returns runs when the last subscriber leaves. Put new WebSocket() in start and close() in the returned function to tie the connection to actual use.
Do derived stores share one upstream subscription? #
Each derived store subscribes to its source independently, but a readable source runs its start function only once for all subscribers, so they share one socket. Expensive per-subscriber work in the source itself would be duplicated, so keep sources thin.
Should I use runes instead? #
In Svelte 5, $derived on rune state gives similar composition with fine-grained tracking; see Svelte 5 runes for WebSocket state. The incremental-fold principle applies equally to both.
Related #
- Svelte WebSocket Store with Auto-Reconnect — a reconnecting source store.
- Svelte 5 Runes for WebSocket State — the runes equivalent.
- Coalescing High-Frequency WebSocket Updates — batching at the server.
- Virtualizing Live-Updating Lists — rendering the long list cheaply.
Back to Svelte Stores for Real-Time.