Svelte Stores Backed by SSE Permalink to this section
Part of Angular & Svelte SSE Integration, under Frontend Consumption & Client Patterns.
Svelte’s store contract — a subscribe function that returns an unsubscribe function, with a start function that runs for the first subscriber and a stop function for the last — is a near-perfect fit for a Server-Sent Events connection. A readable store that opens an EventSource on start and closes it on stop gives every component live data with no lifecycle code at all. This guide builds that store properly: reducing events into state, starting from a server-rendered snapshot in SvelteKit, per-entity reactivity for large collections, and the Svelte 5 runes version.
Symptom & Developer Intent Permalink to this section
- SvelteKit pages crash during server rendering with
EventSource is not defined. - The page renders empty lists on first load and fills them a moment later, shifting the layout.
- A list of 2,000 items re-renders entirely when one item changes.
- A store subscribed in a module keeps its connection open forever.
- After navigating away and back, events published in between are missing.
The intent is a store that is safe during SSR, starts from server-rendered data, updates minimally, and opens its connection only while components use it.
Root Cause Analysis Permalink to this section
A readable store’s start function runs wherever the store is first subscribed. In SvelteKit, components render on the server first, so a store subscribed in a component’s markup starts on the server — where EventSource does not exist. Guarding with browser stops the crash; seeding the store with the server-fetched snapshot stops the empty-then-full flash.
Whole-list re-rendering comes from a store whose value is a single large object replaced on every event: every {#each} over it re-evaluates. Keying the each block and updating immutably by entity helps; per-entity stores or runes help more.
Step-by-Step Resolution Permalink to this section
Step 1 — Load a snapshot on the server Permalink to this section
// src/routes/orders/+page.server.js
export async function load({ fetch }) {
const res = await fetch('/api/orders/snapshot');
const { orders, cursor } = await res.json(); // cursor = last event id included
return { orders, cursor };
}
Step 2 — Create the store from the snapshot, streaming only in the browser Permalink to this section
// src/lib/orders-store.js
import { readable } from 'svelte/store';
import { browser } from '$app/environment';
export function ordersStore(initial, cursor) {
const byId = new Map(initial.map((o) => [o.id, o]));
return readable([...byId.values()], (set) => {
if (!browser) return; // SSR: static snapshot only
const es = new EventSource(`/api/orders/stream?after=${encodeURIComponent(cursor)}`, { withCredentials: true });
let dirty = false;
const flush = () => { if (dirty) { dirty = false; set([...byId.values()]); } requestAnimationFrame(flush); };
requestAnimationFrame(flush); // at most one store update per frame
es.addEventListener('order.updated', (e) => { const o = JSON.parse(e.data); byId.set(o.id, o); dirty = true; });
es.addEventListener('order.deleted', (e) => { byId.delete(JSON.parse(e.data).id); dirty = true; });
return () => es.close();
});
}
<!-- src/routes/orders/+page.svelte -->
<script>
import { ordersStore } from '$lib/orders-store.js';
let { data } = $props();
const orders = ordersStore(data.orders, data.cursor);
</script>
{#each $orders as order (order.id)}
<OrderRow {order} />
{/each}
Because the store is created inside the component, it lives and dies with the page: leaving the route unsubscribes, which closes the connection. The keyed each block reuses DOM for unchanged orders.
Step 3 — Share one connection across components with a module-level store Permalink to this section
When several components on different routes need the same stream (a notification badge in the layout and a notification list on a page), create the store once in a module, but only subscribe from components:
// src/lib/notifications.js — module-level store, lazily connected.
export const notifications = readable({ unread: 0 }, (set, update) => {
if (!browser) return;
const es = new EventSource('/api/notifications/stream', { withCredentials: true });
es.addEventListener('unread', (e) => update((s) => ({ ...s, unread: JSON.parse(e.data).count })));
return () => es.close();
});
Creating it at module level does not open anything; the connection opens when the first component subscribes and closes when the last unsubscribes. Never call notifications.subscribe(...) at module level, or it will stay open for the life of the page.
Step 4 — Use runes for per-entity reactivity (Svelte 5) Permalink to this section
// src/lib/orders.svelte.js
import { SvelteMap } from 'svelte/reactivity';
class Order {
id;
status = $state(''); // each field is its own reactive signal
total = $state(0);
constructor(o) { this.id = o.id; this.update(o); }
update(o) {
if ('status' in o) this.status = o.status;
if ('total' in o) this.total = o.total;
}
}
export class OrdersModel {
byId = new SvelteMap(); // reactive to set/delete, not to field changes
constructor(initial) { for (const o of initial) this.byId.set(o.id, new Order(o)); }
apply(type, data) {
if (type === 'order.updated') {
const existing = this.byId.get(data.id);
if (existing) existing.update(data); // fine-grained: only changed fields notify
else this.byId.set(data.id, new Order(data));
}
if (type === 'order.deleted') this.byId.delete(data.id);
}
}
The store from step 2 becomes a thin adapter: its event listeners call model.apply(e.type, JSON.parse(e.data)), and components read model.byId directly. With each order held as its own reactive object, an update to one order’s status re-renders only the elements that read that order’s status. For collections with frequent single-item changes, this is significantly cheaper than replacing an array.
Step 5 — Keep position across navigations Permalink to this section
If the store closes when the user leaves and reopens when they return, pass the last seen id when reopening — kept in a module variable or sessionStorage — so the server replays what was missed. Alternatively reload the snapshot in load on return, which SvelteKit does naturally when the route is revisited.
Validation & Monitoring Permalink to this section
Check SSR by loading the page with JavaScript disabled: the snapshot must render. Then enable JavaScript and confirm in DevTools that exactly one stream opens after hydration and closes when you navigate to a route that does not use it. Unit-test stores with get(store) and a fake EventSource to assert that start and stop run as expected.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why does SvelteKit crash with EventSource is not defined?
Components render on the server, and a store subscribed during that render runs its start function there. Guard the start function with the browser flag so the connection is only created in the browser.
Readable store or runes?
Readable stores remain the simplest way to tie a connection's lifetime to subscribers, and they work in Svelte 5. Runes are better for the model the events update, especially large collections that benefit from per-entity reactivity.
Can a store expose connection status too?
Yes. Return an object with data and status fields, or pair the data store with a small status store updated from the same EventSource's open and error handlers.
How do I use a bearer token instead of cookies?
Swap EventSource for a fetch-based client inside the store's start function. Components and the store's contract stay the same.