Ein Echtzeit-Dashboard mit WebSockets bauen
Schritt-für-Schritt zum live aktualisierten Dashboard mit WebSockets: Server-Setup, Verbindungsverwaltung, Daten-Streaming, Reconnect und Skalierung.

HTTP-Request-Response funktioniert gut für statische Seiten und Formularübermittlungen. Aber sobald du live aktualisierte Daten brauchst — Monitoring-Dashboards, Chat-Anwendungen, kollaboratives Editieren, Live-Sportergebnisse — führt Polling zu unnötiger Latenz und verschwendeter Bandbreite. WebSockets stellen eine persistente Full-Duplex-Verbindung zwischen Client und Server her, sodass der Server Updates in dem Moment pushen kann, in dem sie passieren.
Dieses Tutorial führt durch den Aufbau eines Echtzeit-Metrik-Dashboards von Grund auf mit WebSockets in TypeScript.
Server-Setup mit ws
Wir verwenden die Bibliothek ws für den WebSocket-Server. Sie ist leichtgewichtig, produktionserprobt und läuft auf Node.js ohne zusätzliche Abhängigkeiten.
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
// Create an HTTP server that the WebSocket server will attach to
const httpServer = createServer();
const wss = new WebSocketServer({ server: httpServer });
// Track connected clients with metadata
interface ClientConnection {
ws: WebSocket;
id: string;
subscribedChannels: Set<string>;
lastPing: number;
}
const clients: Map<string, ClientConnection> = new Map();
wss.on('connection', (ws: WebSocket) => {
const clientId = crypto.randomUUID();
const client: ClientConnection = {
ws,
id: clientId,
subscribedChannels: new Set(),
lastPing: Date.now(),
};
clients.set(clientId, client);
console.log(`Client connected: ${clientId} (total: ${clients.size})`);
// Send initial state on connection
ws.send(JSON.stringify({
type: 'connected',
clientId,
availableChannels: ['cpu', 'memory', 'requests', 'errors'],
}));
ws.on('message', (data: Buffer) => {
handleMessage(client, data.toString());
});
ws.on('close', () => {
clients.delete(clientId);
console.log(`Client disconnected: ${clientId} (total: ${clients.size})`);
});
ws.on('pong', () => {
client.lastPing = Date.now();
});
});
httpServer.listen(8080, () => {
console.log('WebSocket server running on ws://localhost:8080');
});Nachrichtenprotokoll
Definiere ein typisiertes Nachrichtenprotokoll, damit sowohl Client als auch Server wissen, was sie zu erwarten haben. Das verhindert das Chaos unstrukturierter String-Nachrichten.
// Shared types between client and server
type ClientMessage =
| { type: 'subscribe'; channels: string[] }
| { type: 'unsubscribe'; channels: string[] }
| { type: 'ping' };
type ServerMessage =
| { type: 'connected'; clientId: string; availableChannels: string[] }
| { type: 'data'; channel: string; payload: MetricPayload; timestamp: number }
| { type: 'error'; message: string }
| { type: 'pong' };
interface MetricPayload {
value: number;
unit: string;
trend: 'up' | 'down' | 'stable';
history: number[]; // Last 60 data points
}
function handleMessage(client: ClientConnection, raw: string): void {
let message: ClientMessage;
try {
message = JSON.parse(raw) as ClientMessage;
} catch {
sendToClient(client, { type: 'error', message: 'Invalid JSON' });
return;
}
switch (message.type) {
case 'subscribe':
for (const channel of message.channels) {
if (isValidChannel(channel)) {
client.subscribedChannels.add(channel);
}
}
break;
case 'unsubscribe':
for (const channel of message.channels) {
client.subscribedChannels.delete(channel);
}
break;
case 'ping':
sendToClient(client, { type: 'pong' });
break;
}
}
function sendToClient(client: ClientConnection, message: ServerMessage): void {
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.send(JSON.stringify(message));
}
}
const validChannels = new Set(['cpu', 'memory', 'requests', 'errors']);
function isValidChannel(channel: string): boolean {
return validChannels.has(channel);
}// ❌ Untyped, stringly-typed message handling
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.action === 'sub') { // Typos will silently fail
// Handle subscription
}
});
// ✅ Typed protocol with exhaustive message handling
function handleMessage(client: ClientConnection, raw: string): void {
const message = parseClientMessage(raw); // Validates structure
switch (message.type) {
case 'subscribe': handleSubscribe(client, message); break;
case 'unsubscribe': handleUnsubscribe(client, message); break;
case 'ping': sendToClient(client, { type: 'pong' }); break;
// TypeScript ensures all cases are handled
}
}Daten an Abonnenten senden
Der Server sammelt Metriken und sendet sie an abonnierte Clients. Nur Clients, die einen bestimmten Kanal abonniert haben, erhalten die Daten dieses Kanals.
// Simulate metric collection (replace with real data sources)
function collectMetrics(): Map<string, MetricPayload> {
const metrics = new Map<string, MetricPayload>();
metrics.set('cpu', {
value: 35 + Math.random() * 30,
unit: 'percent',
trend: Math.random() > 0.5 ? 'up' : 'stable',
history: Array.from({ length: 60 }, () => 30 + Math.random() * 40),
});
metrics.set('memory', {
value: 2048 + Math.random() * 1024,
unit: 'MB',
trend: 'up',
history: Array.from({ length: 60 }, () => 2000 + Math.random() * 1500),
});
metrics.set('requests', {
value: Math.floor(100 + Math.random() * 500),
unit: 'req/s',
trend: Math.random() > 0.7 ? 'up' : 'stable',
history: Array.from({ length: 60 }, () => Math.floor(50 + Math.random() * 600)),
});
metrics.set('errors', {
value: Math.floor(Math.random() * 10),
unit: 'errors/min',
trend: Math.random() > 0.8 ? 'up' : 'down',
history: Array.from({ length: 60 }, () => Math.floor(Math.random() * 15)),
});
return metrics;
}
// Broadcast metrics every second to subscribed clients
function startBroadcasting(): void {
setInterval(() => {
const metrics = collectMetrics();
const timestamp = Date.now();
for (const [clientId, client] of clients) {
for (const channel of client.subscribedChannels) {
const payload = metrics.get(channel);
if (payload) {
sendToClient(client, {
type: 'data',
channel,
payload,
timestamp,
});
}
}
}
}, 1000);
}
startBroadcasting();Client-seitige Verbindung mit Reconnect
Der Client muss Verbindungsabbrüche elegant abfangen. Netzwerke sind unzuverlässig — die WebSocket-Verbindung wird irgendwann geschlossen. Automatisches Reconnecting mit exponentiellem Backoff verhindert, dass der Server überlastet wird.
class DashboardSocket {
private ws: WebSocket | null = null;
private url: string;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private listeners: Map<string, ((data: MetricPayload) => void)[]> = new Map();
private subscribedChannels: Set<string> = new Set();
constructor(url: string) {
this.url = url;
this.connect();
}
private connect(): void {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected to dashboard server');
this.reconnectAttempts = 0;
// Re-subscribe to channels after reconnection
if (this.subscribedChannels.size > 0) {
this.send({
type: 'subscribe',
channels: [...this.subscribedChannels],
});
}
};
this.ws.onmessage = (event: MessageEvent) => {
const message: ServerMessage = JSON.parse(event.data);
if (message.type === 'data') {
const handlers = this.listeners.get(message.channel) ?? [];
for (const handler of handlers) {
handler(message.payload);
}
}
};
this.ws.onclose = () => {
console.log('Connection closed');
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnect attempts reached');
return;
}
// Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts),
30_000
);
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`);
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
}
subscribe(channel: string, callback: (data: MetricPayload) => void): void {
this.subscribedChannels.add(channel);
if (!this.listeners.has(channel)) {
this.listeners.set(channel, []);
}
this.listeners.get(channel)!.push(callback);
this.send({ type: 'subscribe', channels: [channel] });
}
private send(message: ClientMessage): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
disconnect(): void {
this.maxReconnectAttempts = 0; // Prevent reconnection
this.ws?.close();
}
}
// Usage
const dashboard = new DashboardSocket('ws://localhost:8080');
dashboard.subscribe('cpu', (data) => {
document.getElementById('cpu-value')!.textContent =
`${data.value.toFixed(1)}${data.unit}`;
});
dashboard.subscribe('errors', (data) => {
const el = document.getElementById('error-count')!;
el.textContent = `${data.value} ${data.unit}`;
el.className = data.value > 5 ? 'alert' : 'normal';
});Heartbeat und Bereinigung abgestorbener Verbindungen
WebSocket-Verbindungen können still sterben, ohne dass eine der beiden Seiten ein Close-Event erhält — besonders hinter Load Balancern oder NAT-Gateways. Ein Heartbeat-Mechanismus erkennt und bereinigt solche abgestorbenen Verbindungen.
// Server-side heartbeat: ping clients every 30 seconds
function startHeartbeat(): void {
setInterval(() => {
const now = Date.now();
const staleThreshold = 60_000; // 60 seconds without a pong
for (const [clientId, client] of clients) {
if (now - client.lastPing > staleThreshold) {
console.log(`Terminating stale connection: ${clientId}`);
client.ws.terminate();
clients.delete(clientId);
continue;
}
// Send ping — client should respond with pong
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.ping();
}
}
}, 30_000);
}
startHeartbeat();Die wichtigsten Erkenntnisse
- Definiere ein typisiertes Nachrichtenprotokoll — geteilte TypeScript-Typen zwischen Client und Server verhindern Missverständnisse und machen die API selbstdokumentierend
- Nutze kanalbasierte Abonnements — Clients abonnieren nur die Daten, die sie brauchen, was unnötige Bandbreite und Verarbeitung reduziert
- Implementiere Reconnecting mit exponentiellem Backoff — Verbindungen werden abbrechen; automatisches Reconnecting mit steigenden Verzögerungen verhindert Server-Überlastung
- Füge Heartbeat-Monitoring hinzu — Ping/Pong-Zyklen erkennen still abgestorbene Verbindungen, die sonst Ressourcen verschwenden würden
- Abonniere nach einem Reconnect erneut — der Client muss seinen Abonnement-Status nach dem Reconnect wiederherstellen, da der Server eine neue Sitzung vergibt
- Sende effizient — iteriere über Clients und ihre Abonnements, statt alle Daten an alle Clients zu schicken


