1. SSE vs. WebSockets vs. Polling
Three ways to keep a screen up to date with the server, and they aren't interchangeable — each fits a different shape of problem.
- Polling (an
HttpClientcall on an interval, Week 14) — simplest, works everywhere, but wastes requests when nothing changed and adds latency up to the poll interval. Fine for low-urgency data. - Server-Sent Events — one persistent HTTP connection, server pushes text events whenever it wants, browser reconnects automatically. One-directional only: the client can't send messages back over the same connection.
- WebSockets (or a GraphQL subscription, Week 26) — full duplex, either side can send at any time, but needs dedicated server infrastructure and you own reconnection logic yourself.
The deciding question is almost always direction: if the client only ever receives updates — a live activity feed, a build/deploy status, a notification stream — SSE gets you there over plain HTTP, through the same load balancers and proxies as the rest of your API, with reconnection built into the browser instead of hand-rolled.
2. EventSource & an Angular Wrapper
The browser's native EventSource API is callback-based, not
signal-shaped — wrapping it once in an injectable service is worth doing so every
consuming component gets clean, typed signals instead of repeating the same
addEventListener boilerplate.
import { Injectable, NgZone, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class SseService {
private zone = inject(NgZone);
connect<T>(url: string) {
const data = signal<T[]>([]);
const status = signal<'connecting' | 'open' | 'closed'>('connecting');
const source = new EventSource(url, { withCredentials: true });
source.onopen = () => this.zone.run(() => status.set('open'));
source.onmessage = (event: MessageEvent) => {
const parsed = JSON.parse(event.data) as T;
this.zone.run(() => data.update((list) => [...list, parsed]));
};
source.onerror = () => this.zone.run(() => status.set('closed'));
return { data: data.asReadonly(), status: status.asReadonly(), close: () => source.close() };
}
}
NgZone.run() matters here specifically because EventSource's
callbacks fire outside Angular's zone by default in a zoneless or partially-zoneless
setup (Week 20) — without it, a signal update from onmessage might not
trigger change detection reliably. If your app has already gone fully zoneless, signals
update the DOM correctly without this wrapping regardless, but it's a safe default either
way.
3. Reconnection & Last-Event-ID
EventSource's biggest advantage over a hand-rolled WebSocket client is that
reconnection is built in — the browser automatically retries after a dropped connection,
and the server can tell it exactly where to resume.
id: 1042
event: activity
data: {"user":"jordan","action":"commented","itemId":"abc123"}
id: 1043
event: activity
data: {"user":"amara","action":"assigned","itemId":"def456"}
retry: 3000
Every event with an id: field is remembered by the browser as the
Last-Event-ID. On reconnect, the browser automatically sends it back as a
Last-Event-ID request header — your server reads that header and resumes the
stream from the next event after it, rather than replaying everything or silently
dropping whatever happened during the gap. retry: 3000 sets the
reconnection delay in milliseconds; without it, browsers default to a few seconds.
id to be resumable server-side
A monotonically increasing id backed by a real log or sequence (not a random UUID) is what lets the server actually answer "give me everything after id 1042" efficiently. This is the same durability concern as an at-least-once message queue — SSE's reconnect model only helps if the server can act on the id it receives back.
4. Feeding a Stream Into Signals
Once wrapped (Section 2), consuming a live stream in a component looks like consuming any other signal — the streaming detail is fully contained in the service.
import { Component, inject, OnDestroy } from '@angular/core';
import { SseService } from './sse.service';
@Component({
selector: 'app-activity-feed',
templateUrl: './activity-feed.html',
})
export class ActivityFeed implements OnDestroy {
private sse = inject(SseService);
private stream = this.sse.connect<ActivityEvent>('/api/activity/stream');
events = this.stream.data;
status = this.stream.status;
ngOnDestroy() {
this.stream.close();
}
}
Closing the connection in ngOnDestroy matters more here than it might for a
one-shot HTTP request — an open EventSource left running after a component
is gone keeps the underlying connection (and the browser's per-origin connection budget)
alive for nothing.
5. The Auth-Header Problem
EventSource cannot set custom request headers — no Authorization:
Bearer ..., which is exactly how Week 23's token-based auth normally travels.
withCredentials: true (Section 2) sends cookies, which works if your API
already supports cookie-based session auth, but doesn't help a pure bearer-token setup.
Two real-world workarounds, in order of preference:
- Cookie-based auth for the stream endpoint specifically — if your app already issues an httpOnly session cookie alongside its bearer token, point
EventSourceat an endpoint that accepts that cookie instead. - A short-lived, single-purpose token in the URL — the client fetches a signed, narrowly-scoped token via a normal authenticated
HttpClientcall (interceptor attaches the real bearer token, Week 14), then opens theEventSourcewith that token as a query param. Keep its lifetime short and its scope limited to "read this one stream" — it appears in server logs and browser history, so it should never be your general-purpose access token.
There's a third option — a fetch()-based SSE polyfill that can set arbitrary
headers — but it gives up the browser's native automatic reconnection, which is most of
what makes SSE simpler than a WebSocket in the first place. Reach for it only if neither
workaround above is viable.
6. Combining With the HTTP Layer
A live feed is rarely the whole feature — typically it augments data you already loaded
with HttpClient (Week 14). The clean pattern is to keep them as two signals
and combine with computed() (Week 5), not to force the stream into the same
request/response shape as the initial load.
private http = inject(HttpClient);
initialActivity = toSignal(
this.http.get<ActivityEvent[]>('/api/activity/recent'),
{ initialValue: [] as ActivityEvent[] },
);
liveActivity = this.sse.connect<ActivityEvent>('/api/activity/stream').data;
allActivity = computed(() => [...this.liveActivity(), ...this.initialActivity()]);
This mirrors Week 16's signals/observables bridging guidance directly — each data source
stays a simple, independently-testable signal, and the combining logic lives in one
computed() rather than being tangled into either source.
7. Hands-on Exercise
Build a live activity feed with reconnect handling
A minimal SSE server (a handful of lines in Node, or any backend you're comfortable with) plus an Angular feed component.
Requirements — server:
- An endpoint that sets
Content-Type: text/event-stream, emits a new JSON event roughly every 2-4 seconds, and includes an incrementingid:field per Section 3's format. - Support
Last-Event-ID: when present on a request, resume from an in-memory buffer of recent events rather than starting fresh.
Requirements — Angular:
- Build
SseServicefrom Section 2, including astatussignal reflecting connecting/open/closed. - Build an
ActivityFeedcomponent (Section 4) that renders incoming events newest-first and shows a small connection-status indicator driven bystatus. - Deduplicate by event id — if the browser reconnects and the server resends an event already received (an edge case worth handling explicitly, not assuming away), it must not appear twice in the list.
- Properly close the connection in
ngOnDestroy(Section 4) — verify with dev tools that the network connection actually drops when you navigate away.
To test reconnection realistically, kill and restart your local server mid-stream rather than trusting it works from reading the code — watch the network tab for the automatic retry and confirm Last-Event-ID is actually present on the reconnect request.
8. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What's the single question that should decide SSE vs. WebSockets for a given feature?
What's the single question that should decide SSE vs. WebSockets for a given feature?
Direction. If the client only ever receives updates and never needs to send data back over the same connection, SSE covers it with plain HTTP and built-in reconnection. Full duplex communication — the client also needs to send messages on the same live connection — needs WebSockets (or a GraphQL subscription).
Q2
What does the browser do automatically with an event's id: field, and why does that matter after a dropped connection?
What does the browser do automatically with an event's id: field, and why does that matter after a dropped connection?
It remembers the most recent id and automatically resends it as a Last-Event-ID request header on reconnect. That lets a server that reads the header resume the stream from where the client actually left off, instead of the client either replaying everything or silently missing events that occurred during the gap.
Q3
Why can't you just attach a normal Authorization: Bearer ... header to an EventSource request?
Why can't you just attach a normal Authorization: Bearer ... header to an EventSource request?
The native EventSource API doesn't support setting custom request headers at all — it only supports withCredentials for cookies. That forces either cookie-based auth for the stream endpoint or a short-lived, narrowly-scoped token passed as a query parameter instead of the app's normal bearer token.
Q4
Why call NgZone.run() inside the EventSource event handlers in the wrapper service?
Why call NgZone.run() inside the EventSource event handlers in the wrapper service?
EventSource's callbacks are native browser APIs that fire outside Angular's zone by default in a zone-based app, so a signal update inside them isn't guaranteed to trigger change detection reliably without explicitly re-entering the zone. In a fully zoneless app (Week 20) signals handle this correctly on their own, but wrapping it is a safe default that works either way.