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.

A SvelteKit page with an SSE-backed store Flow from a server load function fetching a snapshot, through server-side rendering with the snapshot, hydration in the browser, and the store opening its EventSource to apply live deltas. A SvelteKit page with an SSE-backed store load() on server fetch snapshot data SSR render snapshot HTML Hydrate same data subscribe Store start open EventSource events Live deltas update store
The server renders real data; the stream only adds changes after hydration. The first paint and the hydrated page match exactly.

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.

One module-level store shared by the layout and a page Sequence diagram of the layout badge subscribing first and opening the stream, a page component subscribing to the same store, the page being left, and the stream staying open for the layout. One module-level store shared by the layout and a page Layout badge Page list notifications store Server subscribe (1) open EventSource subscribe (2) unsubscribe on leave (1) unread 4 $notifications updates
The store counts subscribers across the whole app. Navigating away from the page leaves the layout's subscription, so the connection stays.

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

Render work per update for a 2,000-row list Bar chart comparing the time to apply one row update in a 2,000-row list with an unkeyed each block over a replaced array, a keyed each block, and per-entity runes. Render work per update for a 2,000-row list Unkeyed, array replaced 38 ms Keyed each, array replaced 9 ms Per-entity runes 0.6 ms milliseconds per single-row update (illustrative)
Keyed blocks avoid rebuilding DOM; per-entity reactivity also avoids re-evaluating every row.

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.