HTTP ist eine Frage und danach eine Antwort. Ein WebSocket ist eine Leitung, die offen bleibt, und der Server darf zuerst sprechen: eine neue Chatnachricht, ein geänderter Preis, ein fertiger Build. Die Leitung zu öffnen ist eine Codezeile. Sie im Zug, auf einem Laptop im Ruhezustand oder während eines Server-Deploys am Leben zu halten, ist die eigentliche Arbeit.
Diese Seite baut einen typisierten Socket, der sich selbst neu verbindet und als Signals bereitsteht: Verbindungsstatus, Nachrichten, ein Postausgang für alles, was du offline sendest, und ein Reconnect, der den Server nicht gleich wieder umwirft, sobald er zurück ist.
Das Minimum: webSocket() aus RxJS
RxJS bringt ein Subject für WebSockets mit: subscribe() öffnet die Verbindung, next() sendet, unsubscribe() schließt sie. Nachrichten sind standardmäßig in beide Richtungen JSON (serializer und deserializer ändern das).
import { webSocket } from 'rxjs/webSocket';
interface ChatMessage {
type: 'chat';
user: string;
text: string;
}
const socket = webSocket<ChatMessage>('wss://api.example.com/live');
// subscribe() öffnet die Verbindung; jede Nachricht kommt schon geparst an (JSON.parse)
const sub = socket.subscribe({
next: (msg) => console.log(msg.user, msg.text),
error: (err) => console.error('Verbindung verloren', err), // der Stream ENDET hier
complete: () => console.log('vom Server sauber geschlossen'), // ...oder hier
});
socket.next({ type: 'chat', user: 'ana', text: 'hi' }); // gesendet als JSON.stringify(...)
sub.unsubscribe(); // schließt die VerbindungZwei Details zählen später. Der Stream endet, wenn die Verbindung abbricht: mit error, wenn es abnormal war, mit complete, wenn der Server sauber geschlossen hat. Und was du sendest, bevor die Verbindung offen ist, wird gepuffert und beim Verbinden gesendet.
Eine Verbindung, die zurückkommt: Backoff mit Jitter
Wenn die Leitung abbricht, ist sofortiges Wiederholen in einer Schleife die schlechteste Wahl. Wurde der Server gerade neu gestartet, treffen ihn alle Clients in derselben Sekunde — die Thundering Herd — und er fällt erneut um. Das übliche Rezept: nach jedem Fehlschlag länger warten (exponentieller Backoff), die Wartezeit deckeln und Zufall hinzufügen (Jitter), damit sich die Clients verteilen.
/**
* „Full Jitter“: eine zufällige Wartezeit zwischen 0 und min(cap, base · 2^attempt).
* Versuch 1 → 0–1 s, 2 → 0–2 s, 3 → 0–4 s ... nie mehr als 10 s.
*/
export function fullJitter(attempt: number, base = 500, cap = 10_000): number {
return Math.random() * Math.min(cap, base * 2 ** attempt);
}fehlgeschlagener Versuchwieder verbunden
- Spitze nach Neustart
- 200
- Versuche insgesamt
- 2200
- wieder online
- 200 / 200
- der Letzte zurück bei
- 10.2 s
Fest 1 s hämmert auf den Server ein, solange er unten ist. Exponentiell ohne Jitter sendet weniger Anfragen, aber in synchronen Wellen: Wer zur selben Zeit gescheitert ist, versucht es zur selben Zeit erneut. Full Jitter verteilt die Rückkehr über mehrere Sekunden, der Server bekommt ein Rinnsal statt einer Wand; der Preis ist, dass die unglücklichsten Clients ein paar Sekunden später zurückkommen. Die Obergrenze hält diese Wartezeit vernünftig. Für einen Socket ist das fast immer der richtige Tausch.
retry() reicht nicht: saubere Schließungen
retry() reagiert nur auf Fehler. Ein Server, der höflich neu startet, schließt Verbindungen mit Code 1001 oder 1012 („service restart“) und wasClean: true — und dann endet der Stream regulär, ohne Fehler. Ohne repeat() bleibt deine App genau während der Deploys getrennt. Setze den Versuchszähler in openObserver zurück, wenn die Leitung wirklich offen ist, nicht bei der ersten Nachricht.
import { repeat, retry, timer } from 'rxjs';
import { webSocket } from 'rxjs/webSocket';
import { fullJitter } from './backoff';
import type { ServerMessage } from './live-socket';
let attempt = 0;
const wait = () => timer(fullJitter(++attempt));
const socket = webSocket<ServerMessage>({
url: 'wss://api.example.com/live',
openObserver: { next: () => (attempt = 0) }, // zurücksetzen, wenn die Leitung wirklich offen ist
});
socket
.pipe(
retry({ delay: wait }), // Netzwerkfehler, abnormales Schließen (1006)
repeat({ delay: wait }), // SAUBERES Schließen (1001, 1012 „service restart“): der Stream endet regulär!
)
.subscribe((msg) => console.log(msg));WebSocketSubject behält, was du sendest, bevor der Socket offen ist, ersetzt diesen Puffer aber bei jedem fehlgeschlagenen Versuch (_resetState() im RxJS-Quellcode). Nachrichten, die bei ausgefallenem Netz gesendet werden, sind verloren, sobald ein einziger Reconnect-Versuch scheitert. Wenn sie wichtig sind, führe einen eigenen Postausgang und leere ihn in openObserver.Der Socket als Service mit Signals
Komponenten sollten nichts über Backoff oder JSON wissen. Ein Service hält eine Verbindung für die ganze Anwendung, stellt Status und Nachrichten als Signals bereit und bietet ein einziges send(). Solange die Leitung weg ist, warten Nachrichten in einem begrenzten Postausgang und gehen nach dem Reconnect der Reihe nach raus.
import { DestroyRef, Service, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval, repeat, retry, timeout, timer } from 'rxjs';
import { webSocket } from 'rxjs/webSocket';
import { fullJitter } from './backoff';
export type ServerMessage =
| { type: 'chat'; user: string; text: string }
| { type: 'presence'; online: number }
| { type: 'pong' };
export type ClientMessage = { type: 'chat'; text: string } | { type: 'ping' };
export type SocketStatus = 'idle' | 'connecting' | 'open' | 'reconnecting';
const PING_EVERY = 15_000;
const DEAD_AFTER = 20_000; // 20 s lang gar keine Nachricht = tote Verbindung
/** Eine einzige Verbindung für die ganze App, als Signals bereitgestellt. */
@Service()
export class LiveSocket {
private readonly destroyRef = inject(DestroyRef);
readonly status = signal<SocketStatus>('idle');
readonly online = signal(0);
readonly messages = signal<readonly ServerMessage[]>([]);
/** Was gesendet wurde, während die Leitung weg war; es geht beim nächsten „open“ der Reihe nach raus. */
readonly outbox = signal<readonly ClientMessage[]>([]);
private attempt = 0;
// RxJS nutzt einen einzigen Typ für beide Richtungen
private readonly socket = webSocket<ServerMessage | ClientMessage>({
url: 'wss://api.example.com/live',
openObserver: { next: () => this.opened() },
closeObserver: { next: () => this.status.set('reconnecting') },
});
connect(): void {
if (this.status() !== 'idle') return;
this.status.set('connecting');
const wait = () => timer(fullJitter(++this.attempt));
this.socket
.pipe(
timeout({ each: DEAD_AFTER }), // Heartbeat: Stille → Fehler → retry
retry({ delay: wait }),
repeat({ delay: wait }),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((msg) => this.receive(msg as ServerMessage));
interval(PING_EVERY)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
if (this.status() === 'open') this.socket.next({ type: 'ping' });
});
}
send(msg: ClientMessage): void {
if (this.status() === 'open') this.socket.next(msg);
else this.outbox.update((list) => [...list, msg].slice(-100)); // begrenzt!
}
private opened(): void {
this.status.set('open');
this.attempt = 0;
const pending = this.outbox();
this.outbox.set([]);
pending.forEach((msg) => this.socket.next(msg));
}
private receive(msg: ServerMessage): void {
if (msg.type === 'presence') this.online.set(msg.online);
else if (msg.type === 'chat') this.messages.update((list) => [...list, msg].slice(-200));
}
}- Hier erscheinen die Ereignisse der Verbindung.
Die Demo führt den Code oben mit kürzeren Intervallen aus. Nur WebSocketCtor ist ersetzt: Statt eines echten WebSocket bekommt sie einen Server im Speicher, der neu starten, das Netz verlieren oder eine Verbindung einfrieren kann.
Heartbeat: eine tote Verbindung, die lebendig aussieht
Eine Verbindung kann ohne jedes Close-Ereignis sterben: Der Laptopdeckel geht zu, ein NAT vergisst die Zuordnung, das Mobilnetz wechselt die Zelle. TCP merkt das unter Umständen minutenlang nicht, und readyState sagt weiterhin OPEN. Die Abhilfe ist ein Heartbeat: Der Client sendet in festem Abstand ein ping, und timeout({ each }) behandelt jede Stille über dem Limit als tote Leitung. Der Fehler geht an retry(), das eine frische Verbindung öffnet.
Was du auf einer toten, aber offenen Leitung sendest, geht ohne Fehler verloren: send() klappt, die Daten kommen nie an. Für Nachrichten, die ankommen müssen (eine Bestellung, eine Zahlung), gib jeder eine ID, warte auf ein ack vom Server und sende nach dem Reconnect erneut, was nicht bestätigt wurde. Der Server ignoriert Duplikate anhand der ID.
SSR, Authentifizierung, Tabs und Resync
- SSR und Prerendering. Der Code läuft auch auf dem Server, wo es keinen Socket gibt, den man öffnen sollte. Verbinde dich in
afterNextRender(oder hinterisPlatformBrowser) und lass das Server-Rendering einen leeren Zustand zeigen. Siehe serverkompatibler Code. - Authentifizierung. Der Browser lässt dich keinen
Authorization-Header auf einen WebSocket setzen. Optionen: ein Session-Cookie (gleiche Site), ein kurzes Einmal-Ticket per HTTP in der URL oder eine ersteauth-Nachricht. Kein langlebiges Token in die URL — URLs landen in Serverlogs. Tokens und Refresh behandelt Authentifizierung: Tokens & Refresh. - Mehrere Tabs. Jeder Tab öffnet seine eigene Verbindung. Wenn das ein Problem ist (Verbindungslimits, doppelte Benachrichtigungen), kann ein Tab den Socket besitzen und Nachrichten über
BroadcastChanneloder einenSharedWorkeran die anderen weitergeben. - Resync nach dem Reconnect. Während du weg warst, hat der Server Dinge gesendet, die du nie bekommen hast. Fordere nach jedem
opendas Verpasste an (since=lastEventId) oder lade den Zustand per HTTP neu. Änderungen, die du zwischendurch gemacht hast, sind Thema von optimistischen Updates.
import { HttpClient } from '@angular/common/http';
import { defer, retry, switchMap, timer } from 'rxjs';
import { webSocket } from 'rxjs/webSocket';
import { fullJitter } from './backoff';
import type { ServerMessage } from './live-socket';
// Der Browser kann bei einem WebSocket keinen Authorization-Header setzen.
// 1) ein kurzes Ticket per HTTP (dein Auth-Interceptor fügt das Token wie gewohnt hinzu)
// 2) das Ticket ist einmalig und lebt ~30 s, darf also in die URL
// 3) defer() → jeder Reconnect holt ein NEUES Ticket
export function authenticatedSocket(http: HttpClient) {
return defer(() => http.post<{ ticket: string }>('/api/ws-ticket', {})).pipe(
switchMap(({ ticket }) => webSocket<ServerMessage>(`wss://api.example.com/live?ticket=${ticket}`)),
retry({ delay: (_, n) => timer(fullJitter(n)) }),
);
}WebSocket oder Server-Sent Events?
Wenn nur der Server spricht — Benachrichtigungen, ein Feed, der Fortschritt eines Jobs, Text Token für Token —, sind Server-Sent Events einfacher: schlichtes HTTP, im Browser eingebauter Reconnect und die ID des letzten Ereignisses wird automatisch zurückgeschickt.
| WebSocket | Server-Sent Events | |
|---|---|---|
| Richtung | in beide Richtungen | Server → Browser |
| Protokoll | eigenes (ws:// / wss://, nach einem HTTP-Upgrade) | schlichtes HTTP (text/event-stream) |
| Reconnect | musst du selbst schreiben | eingebaut, mit Last-Event-ID |
| Daten | Text und Binär | nur Text |
| Typischer Einsatz | Chat, gemeinsames Bearbeiten, Spiele, Handel in beide Richtungen | Benachrichtigungen, Live-Feeds, Job-Fortschritt, KI-Textstreaming |
import { Observable } from 'rxjs';
/** Server-Sent Events: nur Server → Browser, schlichtes HTTP, Reconnect eingebaut. */
export function serverEvents<T>(url: string): Observable<T> {
return new Observable<T>((subscriber) => {
const source = new EventSource(url, { withCredentials: true });
source.onmessage = (e) => subscriber.next(JSON.parse(e.data) as T);
source.onerror = () => {
// der Browser verbindet sich selbst neu (und sendet Last-Event-ID); nur CLOSED ist endgültig
if (source.readyState === EventSource.CLOSED) subscriber.error(new Error('SSE closed'));
};
return () => source.close();
});
}Wann ja, wann nein
- Beide Seiten sprechen oft: Chat, Präsenz, gemeinsames Bearbeiten.
- Latenz zählt, und die Nachrichten sind klein und häufig.
- Du brauchst Binärdaten oder ein eigenes Protokoll über die Verbindung.
- Nur der Server spricht: Server-Sent Events sind einfacher. Ab und zu eine Anfrage: schlicht HttpClient.
- Daten, die sich alle paar Minuten ändern: Polling alle 30–60 s ist billiger und viel einfacher zu betreiben.
- Änderungen, die offline funktionieren und später synchronisiert werden müssen: Das ist ein Offline-first-Problem, kein Socket-Problem.
Häufige Stolperfallen
- Ein Socket pro Komponente. Fünf Widgets, fünf Verbindungen zum selben Server. Halte die Verbindung in einem Root-Service.
- Retry ohne Jitter oder ohne Obergrenze. Tausende Clients kommen in synchronen Wellen zurück, oder ein Client wartet nach einem langen Ausfall 17 Minuten.
- Nur
retry(), keinrepeat(). Saubere Schließungen (jeder Deploy) beenden den Stream für immer. - Sich auf den eingebauten Puffer verlassen. Offline gesendete Nachrichten verschwinden nach dem ersten fehlgeschlagenen Versuch. Nutze einen eigenen, begrenzten Postausgang.
- Kein Heartbeat. Der Status sagt „verbunden“, aber seit zehn Minuten kam nichts mehr.
- Verbinden während SSR oder ein langes JWT in der URL. Das Erste öffnet Sockets vom Server, das Zweite hinterlässt Tokens in Logs.
Spickzettel
webSocket<T>(url | config) subscribe = verbinden · next() = senden · unsubscribe = schließen
vor dem ersten „open“ next() wird gepuffert und beim Verbinden gesendet
...aber nach einem Fehlversuch wird der Puffer verworfen → eigenen Postausgang führen
Netzwerkfehler / Code 1006 retry({ delay })
sauberes Schließen (1000/1001/1012) repeat({ delay }) der Stream ENDET regulär, ohne Fehler
Backoff random(0, min(cap, base · 2^n)) „Full Jitter“
Backoff zurücksetzen in openObserver, nicht bei der ersten Nachricht
tot, aber nicht geschlossen Ping alle 15 s + timeout({ each: 20_000 })
SSR / Prerender in afterNextRender verbinden, nie im Konstruktor
Auth Cookie · kurzes Einmal-Ticket · erste „auth“-Nachricht
nach dem Reconnect Resync: das Verpasste anfordern (since=lastEventId)
Nachrichten, die ankommen müssen ID + Ack vom Server, Unbestätigtes erneut senden
nur eine Richtung? Server-Sent Events (EventSource): Reconnect ist eingebaut