Unterschiede, Einsatzgebiete und konkrete Implementierungspatterns für Echtzeit-Kommunikation im Web
Echtzeit-Features klingen in der Planung einfach. Ein Dashboard, das sich automatisch aktualisiert. Benachrichtigungen ohne Reload. Statusanzeigen, die live mitlaufen. In der Praxis zeigt sich schnell: Die Wahl zwischen WebSockets, Server-Sent Events und anderen Mechanismen hat weitreichende Konsequenzen – für Infrastruktur, Skalierung und Betrieb.
Dieser Artikel zeigt, wann welche Technologie passt, was in der Implementierung schief geht, und wie man persistente Verbindungen in produktionstauglicher Qualität umsetzt.
WebSockets und SSE sind keine Alternativen für dieselbe Aufgabe
Das ist der häufigste Denkfehler am Anfang: WebSockets und Server-Sent Events lösen unterschiedliche Probleme. Sie sind nicht austauschbar, sondern adressieren verschiedene Kommunikationsmodelle.
Server-Sent Events sind ein unidirektionaler HTTP-Stream. Der Client öffnet eine Verbindung, der Server schickt Events darüber — fertig. Der Rückkanal ist normales HTTP: Der Client sendet eigene Requests, wenn er etwas zurückschicken muss. Das ist kein Nachteil, sondern in den meisten Fällen die richtige Architektur.
WebSockets sind bidirektional. Eine einzige persistente TCP-Verbindung, über die beide Seiten jederzeit senden können. Das macht sie für bestimmte Anwendungsfälle unverzichtbar — aber auch teurer in Betrieb und Infrastruktur.
| Merkmal | SSE | WebSocket | Long Polling | HTTP/2 Push |
|---|---|---|---|---|
| Verbindungstyp | HTTP (unidirektional) | TCP (bidirektional) | HTTP (polling) | HTTP/2 (veraltet) |
| Rückkanal | Separater HTTP-Request | Gleiche Verbindung | Neuer Request | Nicht vorgesehen |
| Browser-Support | Alle modernen Browser | Alle modernen Browser | Überall | Deprecated (Chrome) |
| Proxy/Firewall-Kompatibilität | Sehr hoch | Mittel (CONNECT-Tunnel) | Sehr hoch | Gering |
| Reconnect | Automatisch (EventSource) | Manuell | Manuell | Entfällt |
| Skalierung (horizontal) | Einfach | Komplex (Sticky Sessions) | Einfach | Entfällt |
| Typische Latenz | ~50–200ms | ~10–50ms | ~200–2000ms | Entfällt |
HTTP/2 Server Push ist seit 2023 in Chrome deprecated und in der Praxis nicht mehr relevant. Long Polling ist ein Notbehelf für Umgebungen mit restriktiven Firewalls — in Neuimplementierungen gibt es keinen Grund mehr, darauf zurückzugreifen.
Entscheidung nach Produktfall, nicht nach Technikgeschmack
Ein paar typische Fälle:
| Produktfall | Empfehlung | Grund |
|---|---|---|
| Import läuft und Fortschritt soll sichtbar sein | SSE | Server sendet Status, Client muss nichts live zurückschicken |
| KI-Antwort streamen | SSE | Text fließt nur vom Server zum Client |
| Chat zwischen Nutzern | WebSocket | beide Seiten senden jederzeit |
| kollaborativer Editor | WebSocket | niedrige Latenz und bidirektionale Konfliktauflösung |
| Admin-Dashboard mit Metriken | SSE oder Polling | meist reicht periodischer Server-Push |
| Börsenkurse plus Order-Eingabe | WebSocket | Marktdaten und Aktionen müssen sehr schnell laufen |
Die falsche Entscheidung ist fast immer WebSocket aus Reflex. Wenn der Client nur gelegentlich etwas sendet, ist ein normaler POST-Request für den Rückkanal oft sauberer und leichter zu betreiben.
Wann SSE ausreicht — und warum es oft die bessere Wahl ist
SSE wird unterschätzt. Viele Teams greifen reflexartig zu WebSockets, weil sie “Echtzeit” mit “bidirektional” gleichsetzen. Dabei sind die meisten Echtzeit-Features in der Praxis unidirektional vom Server zum Client:
- Live-Statusupdates (Bestellstatus, Build-Pipeline, Import-Fortschritt)
- Push-Notifications im Browser
- Live-Dashboards und Metriken
- Activity Feeds (neue Kommentare, neue Einträge)
- KI-generierte Antworten (Streaming-Output)
Für all das reicht SSE. Der Client öffnet eine EventSource-Verbindung, der Server streamt Events, der Browser reconnectet automatisch bei Verbindungsunterbrechung.
SSE-Server in Node.js
import { createServer, IncomingMessage, ServerResponse } from 'http';
function sseHandler(req: IncomingMessage, res: ServerResponse) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Nginx-Buffering deaktivieren
});
// Initialer Comment hält die Verbindung für ältere Proxies offen
res.write(': connected\n\n');
const sendEvent = (event: string, data: unknown, id?: string) => {
if (id) res.write(`id: ${id}\n`);
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
// Heartbeat alle 30 Sekunden — verhindert Proxy-Timeouts
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 30_000);
// Beispiel: Status-Updates schicken
const updates = setInterval(() => {
sendEvent('status', { state: 'running', progress: Math.random() }, Date.now().toString());
}, 2_000);
req.on('close', () => {
clearInterval(heartbeat);
clearInterval(updates);
});
}
createServer(sseHandler).listen(3000);
EventSource-Client
const source = new EventSource('/api/events', {
withCredentials: true, // Cookies mitschicken
});
source.addEventListener('status', (event: MessageEvent) => {
const data = JSON.parse(event.data);
console.log('Status:', data);
});
source.addEventListener('error', () => {
// EventSource reconnectet automatisch nach 3 Sekunden (Default)
// Eigene Logik nur nötig wenn man den Delay anpassen will
console.warn('Verbindung unterbrochen, reconnecte...');
});
// Last-Event-ID wird automatisch beim Reconnect mitgeschickt
// Der Server kann damit verpasste Events nachliefern
Wann WebSockets wirklich nötig sind
WebSockets braucht man dort, wo der Kommunikationsfluss echte Bidirektionalität erfordert und die Latenz des separaten HTTP-Requests ein Problem wäre:
- Chat und Messaging: Nachrichten fließen in beide Richtungen, mit geringer Latenz
- Kollaborative Editoren (wie Google Docs): Änderungen müssen in Millisekunden synchronisiert werden
- Multiplayer-Games: Spielzustand muss hochfrequent und bidirektional ausgetauscht werden
- Trading-Interfaces: Order-Submission und Market-Data über dieselbe Verbindung
Sobald der Rückkanal zeitkritisch ist — also wenn ein HTTP-Request zu viel Overhead erzeugt —, sind WebSockets die richtige Wahl.
WebSocket-Server mit Reconnect und Heartbeat
import { WebSocketServer, WebSocket } from 'ws';
import { IncomingMessage } from 'http';
interface AuthenticatedWebSocket extends WebSocket {
userId?: string;
isAlive: boolean;
}
const wss = new WebSocketServer({ port: 8080 });
// Heartbeat-Ping alle 30 Sekunden
const heartbeat = setInterval(() => {
wss.clients.forEach((client) => {
const ws = client as AuthenticatedWebSocket;
if (!ws.isAlive) {
ws.terminate();
return;
}
ws.isAlive = false;
ws.ping();
});
}, 30_000);
wss.on('connection', (ws: AuthenticatedWebSocket, req: IncomingMessage) => {
ws.isAlive = true;
// Authentifizierung: JWT aus Query-Parameter oder Header
const url = new URL(req.url ?? '', 'http://localhost');
const token = url.searchParams.get('token');
if (!token || !verifyToken(token)) {
ws.close(4001, 'Unauthorized');
return;
}
ws.userId = extractUserId(token);
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
handleMessage(ws, message);
} catch {
ws.send(JSON.stringify({ error: 'Invalid message format' }));
}
});
ws.on('close', (code, reason) => {
console.log(`Client ${ws.userId} disconnected: ${code} ${reason}`);
});
});
wss.on('close', () => clearInterval(heartbeat));
function verifyToken(token: string): boolean {
// JWT-Verifikation hier
return token.length > 0;
}
function extractUserId(token: string): string {
// User-ID aus JWT extrahieren
return 'user-id';
}
function handleMessage(ws: AuthenticatedWebSocket, message: unknown) {
// Message-Handler
}
Reconnect-Logik im Client
class ReconnectingWebSocket {
private ws: WebSocket | null = null;
private reconnectDelay = 1_000;
private maxDelay = 30_000;
private shouldReconnect = true;
constructor(private readonly url: string) {
this.connect();
}
private connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Verbunden');
this.reconnectDelay = 1_000; // Reset bei erfolgreicher Verbindung
};
this.ws.onclose = (event) => {
if (this.shouldReconnect && event.code !== 4001) {
// 4001 = Unauthorized — kein Reconnect sinnvoll
setTimeout(() => this.connect(), this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxDelay);
}
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.onMessage(data);
};
}
send(data: unknown) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
onMessage(_data: unknown) {
// Override in Subklassen oder via Callback
}
close() {
this.shouldReconnect = false;
this.ws?.close();
}
}
Nachrichtenformate versionieren
Persistente Verbindungen brauchen ein kleines Protokoll. „Wir schicken einfach JSON” reicht nur bis zum ersten Breaking Change.
{
"type": "order.status.changed",
"version": 1,
"id": "evt_01hxyz",
"sent_at": "2026-05-20T12:00:00Z",
"data": {
"order_id": "ord_123",
"status": "paid"
}
}
Vier Felder zahlen sich schnell aus:
type: Routing im Client ohne unsichere Heuristikversion: kontrollierte Änderung des Payloadsid: Deduplizierung nach Reconnectsent_at: Debugging von Verzögerungen
Ohne Event-ID kann ein Client nach Reconnect kaum erkennen, ob ein Event doppelt oder gar nicht angekommen ist.
Authentifizierung über persistente Verbindungen
Bei HTTP-Requests ist Authentifizierung straightforward: Authorization-Header oder Cookie, jeder Request wird geprüft. Bei persistenten Verbindungen gibt es diesen natürlichen Checkpoint nicht.
Das Standardmuster für WebSockets: JWT beim Verbindungsaufbau übergeben, einmalig validieren, User-Kontext in der Verbindung speichern. Für SSE funktioniert das ähnlich, aber einfacher — der initiale GET-Request kann normale Cookie-Auth nutzen.
// SSE: Cookie-Auth funktioniert direkt
// Der Browser schickt Cookies automatisch mit, wenn withCredentials: true
app.get('/api/events', requireAuth, (req, res) => {
const userId = req.user.id; // Aus der Session/JWT
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
});
// userId für diesen Stream verwenden
subscribeToUserEvents(userId, (event) => {
res.write(`data: ${JSON.stringify(event)}\n\n`);
});
});
// WebSocket: Token im Query-Parameter (einzige praktikable Option)
// Authorization-Header sind bei WebSocket-Upgrade nicht möglich
const wsUrl = `wss://api.example.com/ws?token=${encodeURIComponent(jwt)}`;
Infrastruktur: Das stille Problem mit persistenten Verbindungen
WebSockets und SSE halten Verbindungen offen. Das ist ihr Wesen — und gleichzeitig die Quelle der meisten Infrastruktur-Probleme.
Nginx-Konfiguration für WebSocket-Proxying
upstream websocket_backend {
least_conn;
server backend1:8080;
server backend2:8080;
# KEIN ip_hash hier — das erzwingt Sticky Sessions,
# besser über explizites Session-Routing lösen
}
server {
location /ws {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s; # 1 Stunde — für lange Verbindungen
proxy_send_timeout 3600s;
}
location /api/events {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_set_header X-Accel-Buffering no;
}
}
Sticky Sessions vs. Shared State
Horizontales Scaling mit WebSockets erzwingt eine Entscheidung: Sticky Sessions oder Shared State.
Sticky Sessions (ip_hash oder Cookie-basiert): Einfach zu konfigurieren, aber fragil. Bei Server-Ausfall verlieren alle Clients dieses Servers ihre Verbindung gleichzeitig. Load-Balancing ist ungleichmäßig.
Shared State (Redis Pub/Sub): Der robustere Ansatz. Jeder Backend-Node hält seine eigenen Verbindungen, Messages werden über Redis verteilt. Der Client verbindet sich mit einem beliebigen Node.
import Redis from 'ioredis';
const publisher = new Redis();
const subscriber = new Redis();
// Auf einem Node: Message an alle Clients mit userId schicken
async function broadcastToUser(userId: string, event: unknown) {
await publisher.publish(`user:${userId}`, JSON.stringify(event));
}
// Jeder Node subscribed auf Channels seiner verbundenen Clients
function subscribeForConnection(userId: string, ws: WebSocket) {
subscriber.subscribe(`user:${userId}`);
subscriber.on('message', (channel, message) => {
if (channel === `user:${userId}` && ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
});
}
Für SSE ist das deutlich einfacher: Jeder Request landet auf einem Node, der den Stream offen hält. Redis Pub/Sub funktioniert genauso, aber es gibt keine Sticky-Session-Problematik, da SSE-Verbindungen ohnehin bei Unterbrechung reconnecten.
Für weiterführende Überlegungen zur API-Struktur und wie Real-Time-Endpunkte ins Gesamtsystem passen, lohnt sich der Blick auf API-Design Best Practices mit REST und tRPC. Wie man persistente Verbindungen und Backend-Systeme im Betrieb beobachtet, behandelt der Artikel zu Monitoring und Observability mit Prometheus und Grafana.
Was in der Praxis regelmäßig schiefgeht
Nach mehreren Projekten mit Real-Time-Anforderungen haben sich einige Muster herausgestellt, die immer wieder Probleme erzeugen:
Kein Heartbeat: Viele Proxies (AWS ALB, Cloudflare) schließen Verbindungen nach 60 Sekunden Inaktivität. Ohne Heartbeat-Mechanismus bricht die Verbindung kommentarlos ab und der Client merkt es erst beim nächsten gesendeten Event.
Fehlende Reconnect-Strategie beim WebSocket-Client: EventSource reconnectet automatisch, WebSocket nicht. Ohne explizite Reconnect-Logik mit Exponential Backoff verliert der Client die Verbindung dauerhaft nach dem ersten Fehler.
Memory Leaks durch nicht aufgeräumte Subscriptions: Wenn eine Verbindung schließt, müssen alle zugehörigen Listener, Timer und Redis-Subscriptions entfernt werden. Das close-Event des Requests ist der richtige Ort dafür.
Zu viele gleichzeitige Verbindungen: Ein Browser öffnet maximal 6 HTTP/1.1-Verbindungen pro Domain. SSE belegt eine davon dauerhaft. HTTP/2 löst das Problem durch Multiplexing — aber nur wenn der Server HTTP/2 unterstützt und korrekt konfiguriert ist.
JWT-Ablauf während aktiver Verbindung: Token-Refresh bei WebSockets erfordert eigene Logik. Entweder das Token hat eine sehr lange Lebensdauer (schlecht für Security), oder es gibt einen Token-Refresh-Mechanismus über die bestehende Verbindung (besser, aber aufwändiger).
Keine Backpressure-Strategie: Wenn der Server schneller sendet als der Client verarbeitet, wächst der Speicherverbrauch. Bei WebSockets muss geprüft werden, ob der Socket noch sendebereit ist. Bei SSE sollten langsame Clients getrennt oder Events zusammengefasst werden.
Fehlende Observability: Real-Time-Systeme brauchen eigene Metriken: aktive Verbindungen, Reconnect-Rate, Nachrichten pro Sekunde, Sendefehler, Queue-Länge pro Client. Ohne diese Werte sieht ein System gesund aus, obwohl Nutzer ständig Verbindungen verlieren.
Die Entscheidung in der Praxis
Die pragmatische Leitfrage: Muss der Client Daten in Echtzeit zurückschicken, und ist die Latenz eines separaten HTTP-Requests ein Problem?
Wenn nein: SSE ist einfacher, infrastrukturfreundlicher und ausreichend. Wenn ja: WebSockets, aber mit klarer Strategie für Infrastruktur und Skalierung.
Die häufigste Fehlannahme ist, dass WebSockets “moderner” oder “besser” sind. Sie sind komplexer. SSE hat weniger Moving Parts, funktioniert besser mit Standard-HTTP-Infrastruktur und reconnectet automatisch. Für die meisten Server-Push-Anwendungsfälle ist das mehr als genug.