Angular Server-Sent Events and Streaming HTTP: Live Feeds Without WebSockets [2026]

Link copied
Angular Server-Sent Events and Streaming HTTP: Live Feeds Without WebSockets [2026]

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:

  1. EventSource auto-reconnects on connection loss (you do nothing)
  2. onmessage fires for every event the server pushes
  3. destroyRef.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:

  1. Cookie-based auth — set an auth cookie on the domain; EventSource sends it automatically (with withCredentials: true)
  2. Query parameternew EventSource(\/api/stream?token=${token}`)` — works but logs the token in server access logs (mitigate with short-lived tokens)
  3. 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:

  1. Bidirectional or low-latency — chat, multiplayer, collaborative editing. WebSocket.
  2. Binary protocols — voice, video, custom binary formats. WebSocket or WebRTC.
  3. 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.

YouI’m building an LLM chat UI. SSE or WebSocket?
YouFor LLM streaming, use chunked HTTP (Fetch with response.body streaming) — that’s what every major LLM API does (OpenAI, Anthropic). It’s simpler than SSE for one-shot responses (no event-stream framing needed) and natively HTTP. WebSocket only wins if you need true bidirectional streaming (rare for chat — request is small, response is the only thing streaming). SSE is the right call for server-push (notifications, presence) where you’re NOT initiating from the client per-stream.

Module 6 is complete. Module 7 picks up RxJS in modern Angular.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *