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.

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).
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.
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.
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.
// ❌ 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;
}// ✅ 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.
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.
// 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.


