Angular & Svelte SSE Integration Permalink to this section
Part of Frontend Consumption & Client Patterns.
React and Vue have their own guides on this site; Angular and Svelte deserve theirs because each has a reactive primitive that fits Server-Sent Events unusually well. Angular’s RxJS Observable is, conceptually, exactly what an EventSource is — a push-based sequence of values over time that can error and complete — and Angular’s newer signals turn the latest value of that sequence into template state. Svelte’s stores were designed around subscription with automatic cleanup, and Svelte 5’s runes make a stream’s latest state a plain reactive variable. Used well, these primitives give lifecycle-correct streams in very little code. Used carelessly, they produce the same bugs as anywhere else: a new connection per subscriber, streams left open after navigation, change detection that runs on every event, and server-side rendering that crashes because EventSource does not exist on the server. This guide covers both frameworks: the core wrapper, sharing one connection between many consumers, keeping rendering efficient, and making server-side rendering safe.
How It Works Permalink to this section
Both frameworks integrate a stream the same way at the conceptual level: wrap EventSource in the framework’s subscribable primitive, open the connection when the first consumer subscribes, share it among consumers, and close it when the last consumer goes away.
The mapping of concepts is close to one to one:
| Concept | Angular | Svelte |
|---|---|---|
| Push sequence | Observable<T> |
Readable<T> store |
| Lazy start, auto stop | new Observable(sub => { … return teardown }) |
readable(initial, set => { … return stop }) |
| Share one connection | share() / shareReplay({ refCount: true }) |
stores share by default |
| Latest value in template | toSignal(obs$) or async pipe |
$store or $state with runes |
| Derived state | computed() / RxJS operators |
derived() / $derived |
| Teardown | unsubscribe (component destroy) | last unsubscriber (component destroy) |
The key property both frameworks share is reference-counted laziness: the connection exists only while something is subscribed. That is what makes navigation, conditional rendering and component destruction clean up streams automatically.
Server-Side Implementation Permalink to this section
The server needs nothing framework-specific. Two conventions help both clients:
- Named events per data type, so a single stream can feed several independent observables or stores, each filtering by name. See using named event types in SSE.
- A snapshot first, for state-shaped data, so a newly subscribed store or signal has a correct value immediately rather than waiting for the next change.
event: orders.snapshot
id: 5120
data: {"open":42,"late":3}
event: orders.delta
id: 5121
data: {"open":43}
For server-side rendered apps (Angular SSR, SvelteKit), the page’s initial HTML should already contain the snapshot, fetched during rendering with an ordinary HTTP request; the stream then only brings changes once the page is live in the browser.
Client-Side Consumption Permalink to this section
Angular: an Observable service with signals Permalink to this section
// sse.service.ts
import { Injectable, NgZone, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { Observable, share, EMPTY } from 'rxjs';
export interface SseMessage<T = unknown> { type: string; data: T; id: string; }
@Injectable({ providedIn: 'root' })
export class SseService {
private zone = inject(NgZone);
private isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private streams = new Map<string, Observable<SseMessage>>();
stream<T>(url: string, types: string[]): Observable<SseMessage<T>> {
if (!this.isBrowser) return EMPTY; // SSR: no EventSource on the server
const key = `${url}|${types.join(',')}`;
if (!this.streams.has(key)) {
const obs$ = new Observable<SseMessage>((subscriber) => {
const es = this.zone.runOutsideAngular(() => new EventSource(url, { withCredentials: true }));
const handler = (e: MessageEvent) =>
this.zone.run(() => subscriber.next({ type: e.type, data: JSON.parse(e.data), id: e.lastEventId }));
types.forEach((t) => es.addEventListener(t, handler));
es.onerror = () => { if (es.readyState === EventSource.CLOSED) subscriber.error(new Error('stream closed')); };
return () => es.close(); // runs when the last subscriber leaves
}).pipe(share()); // one EventSource for all subscribers
this.streams.set(key, obs$);
}
return this.streams.get(key)! as Observable<SseMessage<T>>;
}
}
// orders-badge.component.ts
@Component({
selector: 'app-orders-badge',
template: `<span class="badge">{{ open() }}</span>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrdersBadgeComponent {
private sse = inject(SseService);
private messages = toSignal(
this.sse.stream<{ open: number }>('/api/stream', ['orders.snapshot', 'orders.delta']).pipe(
scan((acc, m) => ({ ...acc, ...m.data }), { open: 0 }),
),
{ initialValue: { open: 0 } },
);
open = computed(() => this.messages().open);
}
toSignal subscribes when the component is created and unsubscribes when it is destroyed, so the shared stream closes automatically when no component needs it. Creating the EventSource outside Angular’s zone and re-entering only to emit keeps zone-based change detection from running on the connection’s internal activity; in zoneless applications, signals trigger updates directly and the zone calls can be dropped. Wrapping EventSource in an RxJS Observable covers retry, shareReplay and error handling.
Svelte: a readable store (and runes) Permalink to this section
// stream.js
import { readable, derived } from 'svelte/store';
import { browser } from '$app/environment'; // SvelteKit; use typeof window in plain Svelte
export function sseStore(url, types, initial) {
return readable(initial, (set, update) => {
if (!browser) return; // no connection during SSR
const es = new EventSource(url, { withCredentials: true });
const onEvent = (e) => update((state) => reduce(state, e.type, JSON.parse(e.data)));
types.forEach((t) => es.addEventListener(t, onEvent));
return () => es.close(); // last subscriber gone: close
});
}
export const orders = sseStore('/api/stream', ['orders.snapshot', 'orders.delta'], { open: 0, late: 0 });
export const lateShare = derived(orders, ($o) => ($o.open ? $o.late / $o.open : 0));
<!-- OrdersBadge.svelte -->
<script>
import { orders } from './stream.js';
</script>
<span class="badge">{$orders.open}</span>
A Svelte store’s start function runs when it gains its first subscriber and its stop function when it loses the last, so every component using $orders shares one EventSource, and navigating away from all of them closes it. In Svelte 5, the same store works unchanged, and runes can hold the state inside a class for more complex models. Svelte stores backed by SSE builds both versions.
Exposing connection status Permalink to this section
Users need to know when live data is not live. Both wrappers can expose a status alongside the data, so any component can show an indicator without touching the connection:
// Angular: a status signal maintained by the service.
readonly status = signal<'connecting' | 'live' | 'reconnecting' | 'closed'>('connecting');
// inside the Observable factory:
es.onopen = () => this.zone.run(() => this.status.set('live'));
es.onerror = () => this.zone.run(() =>
this.status.set(es.readyState === EventSource.CLOSED ? 'closed' : 'reconnecting'));
// Svelte: a companion writable store updated from the same EventSource.
export const status = writable('connecting');
// inside the readable's start function:
es.onopen = () => status.set('live');
es.onerror = () => status.set(es.readyState === EventSource.CLOSED ? 'closed' : 'reconnecting');
Debounce the visible indicator by a second or two so that brief, planned reconnects do not flash a warning, as discussed in showing connection status in the UI.
Keeping position across route changes Permalink to this section
Reference counting closes the stream when the user navigates to a route that does not need it. When they come back, a brand-new EventSource starts without a Last-Event-ID, so events published in between are only visible if the component reloads state. Two remedies work in both frameworks: keep the last event id in the shared service or module and pass it as a query parameter when reopening, or keep the stream open for a grace period after the last unsubscribe so quick back-and-forth navigation does not reconnect at all. In RxJS, share({ resetOnRefCountZero: () => timer(10_000) }) does the latter in one line; in Svelte, delay the close() in the stop function with a timer that a new subscriber cancels.
Edge Cases & Network Interference Permalink to this section
- Server-side rendering.
EventSourcedoes not exist in Node.js-based SSR environments. Guard creation with a platform check (isPlatformBrowser, SvelteKit’sbrowser) and render from a snapshot fetched on the server. - Hydration mismatch. If the stream’s first event changes state during hydration, the client DOM can briefly differ from the server HTML. Start the store or signal from the server-rendered snapshot so the first render matches.
- Change detection storms. In zone-based Angular, every event handler inside the zone schedules change detection for the application. At high rates, emit outside the zone and batch, or use
OnPushcomponents and signals. - Duplicate connections from cold observables. An RxJS
Observablewithoutshare()opens a newEventSourceper subscriber — including eachasyncpipe in a template. Always share. - Navigation that keeps streams alive. A store subscribed from a module-level script (outside a component) never unsubscribes. Subscribe inside components, or unsubscribe explicitly.
- Browser limits. On HTTP/1.1, several streams per page plus several tabs exhaust six connections per origin. Use one multiplexed stream per page, and share it across tabs where needed, as in sharing one SSE connection across tabs.
Mitigation checklist:
Performance & Scale Considerations Permalink to this section
Both frameworks can render streams at high rates if updates are batched and change detection is scoped to what changed.
In Angular, the most effective steps are signals with OnPush (or zoneless change detection), emitting outside the zone, and batching with RxJS: bufferTime(16) or an animation-frame scheduler collapses bursts into one update per frame. In Svelte, updates are already fine-grained — only the DOM nodes that depend on changed values update — but a store updated hundreds of times per second still recomputes derived values each time; batch into one update per frame for high-rate feeds, as described in throttling dashboard updates to the frame rate.
// Angular: coalesce a burst into one emission per animation frame.
import { animationFrameScheduler, bufferTime, filter } from 'rxjs';
const batched$ = sse.stream('/api/stream', ['tick']).pipe(
bufferTime(0, animationFrameScheduler),
filter((batch) => batch.length > 0),
);
Svelte 5 adds a further option for high-rate data: hold the model in a class whose fields are $state, and update only the fields that changed. Because runes track dependencies at the field level, a delta that changes open re-renders only the elements that read open, even when the model has dozens of fields. The same effect in Angular comes from splitting the model into several signals, or from a signal per entity in a keyed collection, rather than one signal holding a large object that is replaced on every event.
Both frameworks also benefit from moving heavy parsing off the main thread when events are large. The wrapper’s contract — an Observable or a store — does not change if the EventSource lives in a Web Worker and posts parsed batches to the page; only the wrapper’s internals do. That keeps the performance decision local to one file instead of spreading it across components.
Memory matters for long-lived pages. Keep only what the view needs: fold events into state with scan or a store reducer, and never accumulate every event in an array without bound.
Validation & Debugging Permalink to this section
For Angular, unit-test the service with a fake EventSource class injected through a factory token, and verify that the connection opens on first subscription, is shared, and closes on last unsubscription:
it('shares one EventSource and closes it after the last unsubscribe', () => {
const a = service.stream('/s', ['x']).subscribe();
const b = service.stream('/s', ['x']).subscribe();
expect(FakeEventSource.instances.length).toBe(1);
a.unsubscribe(); expect(FakeEventSource.instances[0].closed).toBe(false);
b.unsubscribe(); expect(FakeEventSource.instances[0].closed).toBe(true);
});
Test the reducer separately from the connection. Whether it is an RxJS scan accumulator, an NgRx reducer or a Svelte store’s update function, it is a pure function from state and event to state, and a recorded fixture of real events replayed through it catches most rendering bugs without any network or framework test harness.
For Svelte, get(store) subscribes and immediately unsubscribes, which is a quick way to test start and stop behaviour with the same fake. End to end, navigate between routes that use and do not use the stream and confirm in DevTools that exactly one stream request is open while it is needed and none afterwards; Angular DevTools’ profiler and Svelte’s dev tools show whether updates are confined to the components that depend on the stream.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Should Angular apps use the async pipe or toSignal?
Both unsubscribe automatically. toSignal fits signal-based components and zoneless change detection; the async pipe remains fine in templates of OnPush components that already use observables.
Why does each async pipe open a new connection?
Because the Observable is cold. Add share or shareReplay with refCount so all subscribers use one EventSource and the connection closes when the last one leaves.
Do Svelte stores share the connection automatically?
Yes. A readable store's start function runs once for the first subscriber, and all later subscribers share it until the last one unsubscribes.
How do I authenticate with a bearer token in these frameworks?
EventSource cannot send headers. Use cookies, or replace EventSource inside the wrapper with a fetch-based client that adds the Authorization header; the Observable or store interface stays the same.
Can NgRx or other state libraries consume the stream?
Yes. Dispatch actions from an effect that subscribes to the shared Observable, and let reducers fold them into state. The same rules apply: one shared connection, idempotent reducers keyed by entity id, and teardown when the feature is unloaded.
What about Solid, Qwik or other signal-based frameworks?
The shape is identical: wrap EventSource in the framework's reactive primitive with lazy start and cleanup, share it by reference count, and guard for server rendering. Only the names of the primitives change.
Does SvelteKit's load function help?
Yes: fetch the snapshot in a load function so the server-rendered page is correct, then let a store backed by the stream take over in the browser.