Zum Inhalt springen

Ein Echtzeit-Dashboard mit Server-Sent Events und React

Schritt-für-Schritt zum live aktualisierten Dashboard mit Server-Sent Events: das SSE-Protokoll, ein Node.js-Server und ein React-Client mit Reconnect.

6 Min. Lesezeit
Live-Dashboard mit gestreamten Diagrammen und Datenaktualisierungen in Echtzeit

Warum Server-Sent Events statt WebSockets

Jede Diskussion über Echtzeit-Features beginnt standardmäßig bei WebSockets. Aber für Dashboards, Benachrichtigungen und Live-Feeds — also Fälle, in denen Daten hauptsächlich vom Server zum Client fließen — sind Server-Sent Events (SSE) einfacher, zuverlässiger und brauchen weniger Infrastruktur.

SSE läuft über Standard-HTTP. Kein Protokoll-Upgrade, keine spezielle Load-Balancer-Konfiguration, keine Sticky Sessions. Es verbindet sich automatisch neu, wenn Verbindungen abbrechen. Es funktioniert durch Proxys und CDNs, die WebSocket-Upgrades blockieren. Für unidirektionales Daten-Streaming ist SSE die pragmatische Wahl.

Dieses Tutorial baut ein Echtzeit-Metrik-Dashboard von Grund auf: einen Node.js-Server, der Events pusht, einen React-Client, der sie konsumiert, und die Produktionsthemen (Reconnect, Fehlerbehandlung, Backpressure), die Tutorials üblicherweise überspringen.

Das SSE-Protokoll in fünf Minuten

SSE nutzt ein einfaches textbasiertes Protokoll. Der Server sendet eine Antwort mit Content-Type: text/event-stream und schreibt Events als reine Textzeilen. Jedes Event hat optionale Felder: event (Typ), data (Nutzlast), id (für den Reconnect) und retry (Reconnect-Intervall).

plaintextplaintext
event: metric
id: 1001
data: {"name":"cpu_usage","value":72.5,"timestamp":"2024-10-05T14:30:00Z"}
 
event: metric
id: 1002
data: {"name":"memory_usage","value":68.3,"timestamp":"2024-10-05T14:30:01Z"}
 
event: alert
id: 1003
data: {"severity":"warning","message":"CPU usage above 70%"}

Events werden durch doppelte Zeilenumbrüche getrennt. Das id-Feld ermöglicht den automatischen Reconnect: Wenn sich der Client neu verbindet, sendet er die zuletzt empfangene ID im Last-Event-ID-Header, und der Server kann verpasste Events nachliefern.

Server-Implementierung: Events aus Node.js streamen

Der Server hält die HTTP-Verbindung offen und schreibt Events, sobald sie eintreten. Das entscheidende Implementierungsdetail ist das saubere Aufräumen, wenn Clients die Verbindung trennen.

tstypescript
import { createServer, IncomingMessage, ServerResponse } from "http";
 
interface SSEClient {
  id: string;
  response: ServerResponse;
  lastEventId: number;
}
 
const clients: Map<string, SSEClient> = new Map();
let eventCounter = 0;
 
function setupSSEConnection(req: IncomingMessage, res: ServerResponse): void {
  const clientId = crypto.randomUUID();
 
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
    "X-Accel-Buffering": "no", // Disable nginx buffering
  });
 
  // Send initial retry interval
  res.write("retry: 5000\n\n");
 
  // Handle reconnection
  const lastEventId = parseInt(
    req.headers["last-event-id"] as string || "0",
    10
  );
 
  const client: SSEClient = {
    id: clientId,
    response: res,
    lastEventId,
  };
 
  clients.set(clientId, client);
 
  // Replay missed events if reconnecting
  if (lastEventId > 0) {
    replayEvents(client, lastEventId);
  }
 
  // Cleanup on disconnect
  req.on("close", () => {
    clients.delete(clientId);
    console.log(`Client ${clientId} disconnected. Active: ${clients.size}`);
  });
 
  console.log(`Client ${clientId} connected. Active: ${clients.size}`);
}
 
function sendEvent(
  client: SSEClient,
  eventType: string,
  data: object
): boolean {
  try {
    eventCounter++;
    const payload = [
      `event: ${eventType}`,
      `id: ${eventCounter}`,
      `data: ${JSON.stringify(data)}`,
      "",
      "",
    ].join("\n");
 
    return client.response.write(payload);
  } catch {
    clients.delete(client.id);
    return false;
  }
}
 
function broadcast(eventType: string, data: object): void {
  for (const [id, client] of clients) {
    const success = sendEvent(client, eventType, data);
    if (!success) {
      clients.delete(id);
    }
  }
}

Der Header X-Accel-Buffering: no ist bei nginx-Deployments kritisch. Ohne ihn puffert nginx die Antwort, und Clients erhalten Events gebündelt statt in Echtzeit.

Event-Replay für zuverlässige Zustellung

Wenn sich ein Client nach einer Netzwerkunterbrechung neu verbindet, sendet er die letzte empfangene Event-ID. Der Server sollte alle Events nachliefern, die der Client verpasst hat. Ein begrenzter Event-Puffer macht das möglich, ohne dass der Speicher unbegrenzt wächst.

tstypescript
interface StoredEvent {
  id: number;
  type: string;
  data: object;
  timestamp: number;
}
 
const EVENT_BUFFER_SIZE = 1000;
const eventBuffer: StoredEvent[] = [];
 
function storeEvent(type: string, data: object): number {
  eventCounter++;
 
  const event: StoredEvent = {
    id: eventCounter,
    type,
    data,
    timestamp: Date.now(),
  };
 
  eventBuffer.push(event);
 
  // Keep buffer bounded
  if (eventBuffer.length > EVENT_BUFFER_SIZE) {
    eventBuffer.splice(0, eventBuffer.length - EVENT_BUFFER_SIZE);
  }
 
  return eventCounter;
}
 
function replayEvents(client: SSEClient, afterId: number): void {
  const missed = eventBuffer.filter((e) => e.id > afterId);
 
  for (const event of missed) {
    sendEvent(client, event.type, event.data);
  }
 
  if (missed.length > 0) {
    console.log(`Replayed ${missed.length} events for client ${client.id}`);
  }
}
 
// Modified broadcast that stores events
function broadcastAndStore(eventType: string, data: object): void {
  storeEvent(eventType, data);
  broadcast(eventType, data);
}

Die Puffergröße von 1000 Events ist ein Kompromiss zwischen Speicherverbrauch und Reconnect-Zuverlässigkeit. Für ein Dashboard, das ein Event pro Sekunde pusht, deckt das etwa 16 Minuten Verbindungsabbruch ab. Passe die Größe an die erwarteten Unterbrechungsmuster an.

React-Client: SSE mit Hooks konsumieren

Die im Browser eingebaute EventSource-API übernimmt SSE-Verbindungen, automatischen Reconnect und das Parsen der Events. Verpackt in einen React-Hook ergibt sich eine saubere Integration in den Komponenten-Lebenszyklus.

tstypescript
// ❌ Bad: Bare EventSource without cleanup or error handling
import { useEffect, useState } from "react";
 
function useBadSSE(url: string) {
  const [data, setData] = useState(null);
 
  useEffect(() => {
    const source = new EventSource(url);
    source.onmessage = (e) => setData(JSON.parse(e.data));
    // No cleanup! Connection leaks on unmount
    // No error handling! Silent failures
  }, [url]);
 
  return data;
}
tstypescript
// ✅ Good: Full SSE hook with typed events, reconnection, and cleanup
import { useEffect, useRef, useState, useCallback } from "react";
 
interface SSEOptions {
  onOpen?: () => void;
  onError?: (error: Event) => void;
  maxRetries?: number;
}
 
interface SSEState<T> {
  data: T | null;
  isConnected: boolean;
  error: string | null;
  retryCount: number;
}
 
function useSSE<T>(
  url: string,
  eventType: string,
  options: SSEOptions = {}
): SSEState<T> {
  const [state, setState] = useState<SSEState<T>>({
    data: null,
    isConnected: false,
    error: null,
    retryCount: 0,
  });
 
  const sourceRef = useRef<EventSource | null>(null);
  const retryCountRef = useRef(0);
  const maxRetries = options.maxRetries ?? 10;
 
  const connect = useCallback(() => {
    if (sourceRef.current) {
      sourceRef.current.close();
    }
 
    const source = new EventSource(url);
    sourceRef.current = source;
 
    source.addEventListener("open", () => {
      retryCountRef.current = 0;
      setState((prev) => ({
        ...prev,
        isConnected: true,
        error: null,
        retryCount: 0,
      }));
      options.onOpen?.();
    });
 
    source.addEventListener(eventType, (event: MessageEvent) => {
      try {
        const parsed = JSON.parse(event.data) as T;
        setState((prev) => ({ ...prev, data: parsed }));
      } catch {
        console.error("Failed to parse SSE data:", event.data);
      }
    });
 
    source.addEventListener("error", (event: Event) => {
      setState((prev) => ({
        ...prev,
        isConnected: false,
        retryCount: retryCountRef.current,
      }));
 
      if (retryCountRef.current >= maxRetries) {
        source.close();
        setState((prev) => ({
          ...prev,
          error: "Max reconnection attempts reached",
        }));
      }
 
      retryCountRef.current++;
      options.onError?.(event);
    });
 
    return source;
  }, [url, eventType, maxRetries, options]);
 
  useEffect(() => {
    const source = connect();
 
    return () => {
      source.close();
      sourceRef.current = null;
    };
  }, [connect]);
 
  return state;
}

Der Hook verfolgt Verbindungsstatus, Retry-Zähler und Fehler — alles, was die UI für Verbindungsstatus-Indikatoren braucht. Die EventSource-API übernimmt den Reconnect automatisch, aber der Retry-Zähler ermöglicht ein maximales Backoff.

Dashboard-Komponente: Die Teile zusammensetzen

Mit dem SSE-Hook ist der Bau des Dashboards eine geradlinige Komposition von React-Komponenten.

tsxtsx
import { useState, useEffect } from "react";
 
interface MetricData {
  name: string;
  value: number;
  timestamp: string;
}
 
interface AlertData {
  severity: "info" | "warning" | "critical";
  message: string;
}
 
function MetricsDashboard() {
  const metrics = useSSE<MetricData>("/api/events", "metric");
  const alerts = useSSE<AlertData>("/api/events", "alert");
  const [history, setHistory] = useState<MetricData[]>([]);
 
  useEffect(() => {
    if (metrics.data) {
      setHistory((prev) => {
        const updated = [...prev, metrics.data!];
        return updated.slice(-100); // Keep last 100 data points
      });
    }
  }, [metrics.data]);
 
  return (
    <div className="grid grid-cols-1 gap-6 p-6 md:grid-cols-2">
      <ConnectionStatus isConnected={metrics.isConnected} />
 
      <MetricCard
        label="CPU Usage"
        value={metrics.data?.value ?? 0}
        unit="%"
        threshold={80}
      />
 
      <MetricHistory dataPoints={history} />
 
      {alerts.data && (
        <AlertBanner
          severity={alerts.data.severity}
          message={alerts.data.message}
        />
      )}
    </div>
  );
}
 
function ConnectionStatus({ isConnected }: { isConnected: boolean }) {
  return (
    <div className="flex items-center gap-2 text-sm">
      <span
        className={`h-2 w-2 rounded-full ${
          isConnected ? "bg-green-500" : "bg-red-500"
        }`}
      />
      {isConnected ? "Live" : "Reconnecting..."}
    </div>
  );
}
 
function MetricCard({
  label,
  value,
  unit,
  threshold,
}: {
  label: string;
  value: number;
  unit: string;
  threshold: number;
}) {
  const isWarning = value > threshold;
 
  return (
    <div
      className={`rounded-lg border p-4 ${
        isWarning ? "border-red-300 bg-red-50" : "border-gray-200"
      }`}
    >
      <p className="text-sm text-gray-500">{label}</p>
      <p className={`text-3xl font-bold ${isWarning ? "text-red-600" : ""}`}>
        {value.toFixed(1)}
        {unit}
      </p>
    </div>
  );
}

Die Begrenzung des Verlaufs-Puffers auf 100 Datenpunkte verhindert Speicherwachstum in langlaufenden Sitzungen. Für Produktions-Dashboards solltest du einen Ringpuffer in Betracht ziehen oder ältere Datenpunkte nach IndexedDB auslagern.

Produktionsthemen: SSE skalieren

SSE-Verbindungen sind langlebige HTTP-Verbindungen. Jeder verbundene Client hält eine Verbindung auf deinem Server offen. Das hat Auswirkungen auf die Skalierung.

tstypescript
// Server-side connection limits and backpressure
const MAX_CLIENTS = 10000;
 
function setupSSEConnection(req: IncomingMessage, res: ServerResponse): void {
  if (clients.size >= MAX_CLIENTS) {
    res.writeHead(503, { "Retry-After": "30" });
    res.end("Server at capacity");
    return;
  }
 
  // Keep-alive to prevent proxy timeouts
  const keepAliveInterval = setInterval(() => {
    try {
      res.write(":keepalive\n\n");
    } catch {
      clearInterval(keepAliveInterval);
    }
  }, 15000);
 
  req.on("close", () => {
    clearInterval(keepAliveInterval);
    clients.delete(clientId);
  });
 
  // ... rest of setup
}

Der Keepalive-Kommentar (:keepalive) ist ein gültiger SSE-Kommentar, der verhindert, dass Proxys und Load Balancer inaktive Verbindungen schließen. Sende einen alle 15–30 Sekunden. Ohne ihn schließt AWS ALB inaktive Verbindungen nach 60 Sekunden, was unnötige Reconnect-Zyklen auslöst.

Die wichtigsten Erkenntnisse

Server-Sent Events sind das richtige Werkzeug für Server-zu-Client-Streaming: Dashboards, Benachrichtigungen, Live-Feeds, Fortschrittsanzeigen. Sie sind einfacher zu implementieren als WebSockets, funktionieren durch die HTTP-Infrastruktur und behandeln Reconnects nativ.

Die Produktionsdetails zählen: Puffere Events für das Replay bei einem Reconnect, sende Keepalive-Kommentare gegen Proxy-Timeouts, begrenze Client-Verbindungen mit Backpressure und räume Ressourcen beim Disconnect auf. Im React-Client verfolgst du den Verbindungsstatus und stellst ihn der UI bereit, damit Nutzer wissen, wann Daten veraltet sind.

SSE hat echte Einschränkungen: keine Client-zu-Server-Kommunikation (nutze normale POST-Requests), ein Browser-Limit von 6 Verbindungen pro Domain (HTTP/2 erhöht das auf 100) und keine Unterstützung für Binärdaten. Für bidirektionale Kommunikation bleiben WebSockets die richtige Wahl. Aber für den üblichen Fall, Updates vom Server zum Client zu pushen, erledigt SSE die Aufgabe mit weniger Komplexität.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX