Zum Inhalt springen
CASOON

Real-Time Architektur: WebSockets und Server-Sent Events richtig einsetzen

Unterschiede, Einsatzgebiete und konkrete Implementierungspatterns für Echtzeit-Kommunikation im Web

11 Minuten
Real-Time Architektur: WebSockets und Server-Sent Events richtig einsetzen
#Architektur #JavaScript #Performance #Backend

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.

MerkmalSSEWebSocketLong PollingHTTP/2 Push
VerbindungstypHTTP (unidirektional)TCP (bidirektional)HTTP (polling)HTTP/2 (veraltet)
RückkanalSeparater HTTP-RequestGleiche VerbindungNeuer RequestNicht vorgesehen
Browser-SupportAlle modernen BrowserAlle modernen BrowserÜberallDeprecated (Chrome)
Proxy/Firewall-KompatibilitätSehr hochMittel (CONNECT-Tunnel)Sehr hochGering
ReconnectAutomatisch (EventSource)ManuellManuellEntfällt
Skalierung (horizontal)EinfachKomplex (Sticky Sessions)EinfachEntfällt
Typische Latenz~50–200ms~10–50ms~200–2000msEntfä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:

ProduktfallEmpfehlungGrund
Import läuft und Fortschritt soll sichtbar seinSSEServer sendet Status, Client muss nichts live zurückschicken
KI-Antwort streamenSSEText fließt nur vom Server zum Client
Chat zwischen NutzernWebSocketbeide Seiten senden jederzeit
kollaborativer EditorWebSocketniedrige Latenz und bidirektionale Konfliktauflösung
Admin-Dashboard mit MetrikenSSE oder Pollingmeist reicht periodischer Server-Push
Börsenkurse plus Order-EingabeWebSocketMarktdaten 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 Heuristik
  • version: kontrollierte Änderung des Payloads
  • id: Deduplizierung nach Reconnect
  • sent_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.