Angular Server-Sent Events and Streaming HTTP: Live Feeds Without WebSockets [2026]
For real-time data, WebSockets are the default reach. But for many use cases — server-pushed notifications, LLM token streaming, live dashboards, progress updates — Server-Sent Events (SSE) and HTTP streaming are simpler, server-friendlier, and work over standard HTTP/2. This lesson covers SSE with EventSource, chunked HTTP responses with the Fetch API, and how to consume both as Angular signals.
This is lesson 6.6, closing Module 6 of the Angular Tutorial. After this, you can build any one-way real-time UI without reaching for socket.io.
SSE vs WebSockets — quick comparison #
| Aspect | SSE | WebSocket |
|---|---|---|
| Direction | Server → client only | Bidirectional |
| Protocol | HTTP (text/event-stream) | Custom (ws://) |
| Auto-reconnect | Built into EventSource | Manual |
| Through proxies/firewalls | Just HTTP | Sometimes blocked |
| Auth | Cookies + headers (via Fetch API workaround) | Custom |
| Binary support | No (text only) | Yes |
| Browser support | Universal | Universal |
| Server complexity | Trivial — just keep the response open | Requires WS upgrade handling |
For server-push notifications, dashboards, progress updates, LLM streaming — SSE wins on simplicity. For chat, multiplayer games, collaborative editing — WebSocket is the right call.
Basic EventSource setup #
The browser's EventSource API connects to an SSE endpoint:
import { Component, signal, inject, DestroyRef } from '@angular/core';
@Component({
selector: 'app-notifications',
template: `
<p>Status: {{ status() }}</p>
<ul>@for (n of notifications(); track n.id) { <li>{{ n.text }}</li> }</ul>
`,
})
export class NotificationsComponent {
private destroyRef = inject(DestroyRef);
status = signal<'connecting' | 'open' | 'closed'>('connecting');
notifications = signal<{ id: string; text: string }[]>([]);
constructor() {
const es = new EventSource('/api/notifications/stream');
es.onopen = () => this.status.set('open');
es.onerror = () => this.status.set('closed');
es.onmessage = (e) => {
const data = JSON.parse(e.data);
this.notifications.update(arr => [...arr, data]);
};
this.destroyRef.onDestroy(() => es.close());
}
}
Three key behaviors:
EventSourceauto-reconnects on connection loss (you do nothing)onmessagefires for every event the server pushesdestroyRef.onDestroy(() => es.close())stops reconnect attempts when the component dies
In zoneless apps (lesson 3.5), updating signals from onmessage correctly triggers CD. Don't forget to use .update() or .set(), not field mutation.
Named events #
SSE supports typed event names:
// Server sends:
event: notification
data: {"id":"1","text":"New message"}
event: presence
data: {"user":"pradeep","online":true}
Client listens per-event:
es.addEventListener('notification', (e) => {
const data = JSON.parse((e as MessageEvent).data);
this.notifications.update(arr => [...arr, data]);
});
es.addEventListener('presence', (e) => {
const data = JSON.parse((e as MessageEvent).data);
this.presence.set(data);
});
Useful for multiplexing a single connection across multiple event types.
Auth with SSE #
EventSource does NOT support custom headers. To send a bearer token:
- Cookie-based auth — set an auth cookie on the domain; EventSource sends it automatically (with
withCredentials: true) - Query parameter —
new EventSource(\/api/stream?token=${token}`)` — works but logs the token in server access logs (mitigate with short-lived tokens) - Bridge via Fetch streaming (see below) — supports custom headers
For most apps, cookie auth is the simplest. For SPA + JWT setups, use Fetch streaming.
HTTP streaming with the Fetch API #
For more control (custom headers, bidirectional bytes, binary responses), use streamed Fetch:
async function streamFromServer(token: string, signal?: AbortSignal) {
const res = await fetch('/api/stream', {
headers: { Authorization: `Bearer ${token}` },
signal,
});
if (!res.body) throw new Error('No response body');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const payload = JSON.parse(line.slice(6));
yield payload;
}
}
}
}
Usage as an async iterator in a component:
@Component({...})
export class StreamComponent {
messages = signal<any[]>([]);
private abort = new AbortController();
constructor() {
this.start();
inject(DestroyRef).onDestroy(() => this.abort.abort());
}
private async start() {
for await (const msg of streamFromServer(this.token, this.abort.signal)) {
this.messages.update(arr => [...arr, msg]);
}
}
}
The for await consumes the async iterator. abort.signal lets you cancel cleanly on destroy.
LLM streaming (the modern use case) #
Most AI chat UIs in 2026 use streamed Fetch — the server emits tokens as they're generated, the UI updates in real-time:
export class ChatComponent {
reply = signal('');
async sendMessage(prompt: string) {
this.reply.set('');
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
if (!res.body) return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
this.reply.update(s => s + chunk); // append tokens as they arrive
}
}
}
The template shows reply() building character-by-character — the typing-cursor effect users expect from chat UIs.
SSE bridge to a signal #
Wrap EventSource as a reusable signal source:
export function sseSignal<T>(url: string): Signal<T | null> {
const sig = signal<T | null>(null);
const es = new EventSource(url);
es.onmessage = (e) => sig.set(JSON.parse(e.data));
inject(DestroyRef).onDestroy(() => es.close());
return sig.asReadonly();
}
// Usage
export class TickerComponent {
price = sseSignal<number>('/api/ticker/BTC');
// ... template reads price()
}
The helper hides the lifecycle. Reusable across components.
When SSE doesn't fit #
Three cases:
- Bidirectional or low-latency — chat, multiplayer, collaborative editing. WebSocket.
- Binary protocols — voice, video, custom binary formats. WebSocket or WebRTC.
- Resource-constrained server — SSE keeps a connection open per client; for 10,000+ concurrent users on a single server, the connection-count math gets hard. WebSocket is no better (same problem), but a polling endpoint scales to more users on the same hardware.
For everything else — notifications, dashboards, progress, LLM tokens — SSE is the simplest tool.
Common gotchas #
| Symptom | Cause | Fix |
|---|---|---|
EventSource reconnects forever even when component dies |
Forgot es.close() in onDestroy |
inject(DestroyRef).onDestroy(() => es.close()) |
| Auth header doesn't reach SSE endpoint | EventSource doesn't support custom headers |
Use cookie auth, or bridge via Fetch streaming |
| Stream stops after exactly 30 seconds | Reverse proxy timeout (nginx default) | Set proxy_read_timeout 3600; in nginx |
| Updates don't render | Signal mutation instead of .update() |
Always use .set() / .update() for tracked signals |
| Memory grows unbounded | Accumulating every message forever | Cap the array length — keep last 100 |
| LLM stream stops mid-response | Browser idle/CPU throttling | Mostly out of your control; the server should checkpoint resumable state |
What's next #
Module 6 is complete. Module 7 picks up RxJS in modern Angular — where it still wins over signals, where to bridge with toSignal/toObservable, and the operators worth knowing.
Try it yourself #
A simulated SSE stream connected to a signal:
import { Component, signal, inject, DestroyRef } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
@Component({
selector: 'app-ticker',
template: `<p>Price: \${{ price() ?? '...' }}</p>`,
})
class TickerComponent {
price = signal<string | null>(null);
constructor() {
// Simulate with setInterval since we don't have a real SSE server here
const id = setInterval(() => {
this.price.set((Math.random() * 100000).toFixed(2));
}, 500);
inject(DestroyRef).onDestroy(() => clearInterval(id));
}
}
bootstrapApplication(TickerComponent);
Price updates every 500ms — same shape as SSE just with setInterval instead. Replace with new EventSource('/api/ticker') and es.onmessage to wire to a real endpoint.
Module 6 is complete. Module 7 picks up RxJS in modern Angular.
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


