Week 27: Real-Time Streams with Server-Sent Events

Not every "live" feature needs a WebSocket or a GraphQL subscription (Week 26). When the server only needs to push, never receive, Server-Sent Events do the job with plain HTTP, a browser-native reconnect story, and none of the extra infrastructure.

Phase 7 of 8 Week 27 of 33 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Choose correctly between SSE, WebSockets and polling for a given real-time feature
  • Wrap EventSource in a clean, zone-safe Angular service that feeds a signal
  • Handle reconnection, Last-Event-ID, and the auth-header limitation SSE doesn't solve for you

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 HttpClient call 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.

sse.service.ts
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.

activity-stream endpoint — server-side event format
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.

Design your event 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.

activity-feed.ts
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 EventSource at 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 HttpClient call (interceptor attaches the real bearer token, Week 14), then opens the EventSource with 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.

activity-feed.ts — merging initial load + live stream
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

Hands-on

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:

  1. An endpoint that sets Content-Type: text/event-stream, emits a new JSON event roughly every 2-4 seconds, and includes an incrementing id: field per Section 3's format.
  2. Support Last-Event-ID: when present on a request, resume from an in-memory buffer of recent events rather than starting fresh.

Requirements — Angular:

  1. Build SseService from Section 2, including a status signal reflecting connecting/open/closed.
  2. Build an ActivityFeed component (Section 4) that renders incoming events newest-first and shows a small connection-status indicator driven by status.
  3. 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.
  4. Properly close the connection in ngOnDestroy (Section 4) — verify with dev tools that the network connection actually drops when you navigate away.
Hint

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?

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?

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?

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?

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.