Wer heute eine Live-Kollaborationsfunktion, ein Echtzeit-Dashboard oder einen Chat in seine Anwendung einbauen möchte, kommt an WebSockets kaum vorbei. Das Protokoll hält eine dauerhafte Zwei-Wege-Verbindung zwischen Browser und Server aufrecht — ganz ohne das ständige Polling-Rauschen von REST. Mit Claude Code 2026 lässt sich der komplette WebSocket-Stack in einem Rutsch generieren: funktionsfähiger TypeScript-Code, der in Produktion hält, was er verspricht.
In diesem Artikel zeigen wir sechs Bausteine, die zusammen ein produktionsreifes Echtzeit-System ergeben: WebSocket-Server, Rooms & Broadcasting, Client-Reconnect-Logik, JWT-Authentifizierung, horizontale Skalierung via Redis und High-Traffic-Handling mit uWebSockets.js. Alle Beispiele sind in TypeScript geschrieben und direkt einsetzbar.
1. WebSocket-Server mit der ws-Library
Die ws-Library ist der bewährteste WebSocket-Server für Node.js. Sie ist minimal, gut typisiert und wird von keinem Framework bevormundet. Claude Code generiert auf Anfrage sofort einen vollständigen Server mit Connection-Handler, Heartbeat-Loop und Message-Routing — alles sauber in TypeScript.
Server Grundstruktur: Node.js + ws + TypeScript
Der Server startet auf einem HTTP-Server, damit WebSocket- und REST-Endpunkte den gleichen Port teilen können. Ein Heartbeat-Intervall räumt tote Verbindungen zuverlässig auf.
// server.ts — WebSocket-Server mit ws + Express
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
import express from 'express';
interface Message {
type: 'chat' | 'ping' | 'join' | 'leave';
payload: unknown;
room?: string;
}
interface ExtendedWebSocket extends WebSocket {
userId: string;
rooms: Set<string>;
isAlive: boolean;
}
const app = express();
const httpServer = createServer(app);
const wss = new WebSocketServer({ server: httpServer });
wss.on('connection', (ws: ExtendedWebSocket, req) => {
ws.userId = extractUserId(req);
ws.rooms = new Set();
ws.isAlive = true;
console.log(`[WS] Client verbunden: ${ws.userId}`);
ws.on('message', (raw) => {
try {
const msg: Message = JSON.parse(raw.toString());
routeMessage(ws, msg);
} catch {
ws.send(JSON.stringify({ type: 'error', payload: 'Invalid JSON' }));
}
});
ws.on('pong', () => { ws.isAlive = true; });
ws.on('close', () => {
handleDisconnect(ws);
console.log(`[WS] Client getrennt: ${ws.userId}`);
});
ws.send(JSON.stringify({ type: 'connected', payload: { userId: ws.userId } }));
});
function routeMessage(ws: ExtendedWebSocket, msg: Message): void {
switch (msg.type) {
case 'ping':
ws.send(JSON.stringify({ type: 'pong', payload: Date.now() }));
break;
case 'join':
joinRoom(ws, msg.room!); break;
case 'leave':
leaveRoom(ws, msg.room!); break;
case 'chat':
broadcastToRoom(msg.room!, msg.payload, ws.userId); break;
default:
ws.send(JSON.stringify({ type: 'error', payload: 'Unknown message type' }));
}
}
// Heartbeat: tote Verbindungen alle 30 s aufräumen
const heartbeat = setInterval(() => {
wss.clients.forEach((raw) => {
const ws = raw as ExtendedWebSocket;
if (!ws.isAlive) { ws.terminate(); return; }
ws.isAlive = false;
ws.ping();
});
}, 30_000);
wss.on('close', () => clearInterval(heartbeat));
httpServer.listen(3001, () => console.log('WS-Server läuft auf :3001'));
Bun.serve() als Alternative: Wer Bun als Runtime nutzt, kann direkt Bun.serve({ websocket: { open, message, close } }) verwenden — kein externes Package nötig. Claude Code kennt beide APIs und wählt anhand der package.json automatisch die passende Variante.
Der Message-Router als switch-Statement ist bewusst simpel gehalten — leicht lesbar, leicht erweiterbar. In größeren Projekten lohnt sich ein Map-basierter Handler-Dispatcher, den Claude Code auf Wunsch ebenfalls generiert:
// Handler-Map statt switch — besser skalierbar
type Handler = (ws: ExtendedWebSocket, msg: Message) => void;
const handlers: Record<string, Handler> = {
ping: (ws) => ws.send(JSON.stringify({ type: 'pong', ts: Date.now() })),
join: (ws, msg) => joinRoom(ws, msg.room!),
leave: (ws, msg) => leaveRoom(ws, msg.room!),
chat: (ws, msg) => broadcastToRoom(msg.room!, msg.payload, ws.userId),
};
function routeMessage(ws: ExtendedWebSocket, msg: Message): void {
const handler = handlers[msg.type];
if (handler) handler(ws, msg);
else ws.send(JSON.stringify({ type: 'error', payload: 'Unknown type' }));
}
2. Rooms und Broadcasting
Rooms ermöglichen es, Nachrichten nur an bestimmte Gruppen von Clients zu senden. Eine Map<string, Set<ExtendedWebSocket>> ist die performanteste Datenstruktur dafür — O(1) Lookup, direktes Iterieren für Broadcasts, automatische Garbage Collection wenn der Room leer wird.
Rooms Map-basierte Verwaltung mit Presence-Events
Presence-Events informieren alle Room-Mitglieder wenn jemand beitritt oder geht. Das ist die Basis für "X Personen online", Tipp-Indikatoren oder Live-Cursor in kollaborativen Tools.
// rooms.ts — Room-Management mit Presence-Events
const rooms = new Map<string, Set<ExtendedWebSocket>>();
function joinRoom(ws: ExtendedWebSocket, roomId: string): void {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
const room = rooms.get(roomId)!;
room.add(ws);
ws.rooms.add(roomId);
// Anderen Mitgliedern mitteilen
broadcast(roomId, {
event: 'user_joined', userId: ws.userId, memberCount: room.size,
}, ws.userId, true); // excludeSelf = true
// Neuem Mitglied: aktuelle Member-Liste senden
ws.send(JSON.stringify({
type: 'room_joined',
payload: { roomId, members: [...room].map((c) => c.userId) },
}));
}
function leaveRoom(ws: ExtendedWebSocket, roomId: string): void {
const room = rooms.get(roomId);
if (!room) return;
room.delete(ws);
ws.rooms.delete(roomId);
if (room.size === 0) {
rooms.delete(roomId); // leere Rooms sofort bereinigen
} else {
broadcast(roomId, {
event: 'user_left', userId: ws.userId, memberCount: room.size,
});
}
}
function handleDisconnect(ws: ExtendedWebSocket): void {
for (const roomId of ws.rooms) leaveRoom(ws, roomId);
}
// Broadcast an alle Mitglieder eines Rooms
function broadcast(
roomId: string,
payload: unknown,
senderId?: string,
excludeSelf = false,
): void {
const room = rooms.get(roomId);
if (!room) return;
const message = JSON.stringify({ type: 'broadcast', room: roomId, from: senderId, payload, ts: Date.now() });
for (const client of room) {
if (excludeSelf && client.userId === senderId) continue;
if (client.readyState === WebSocket.OPEN) client.send(message);
}
}
// Selektiver Send: nur an bestimmte User-IDs (z.B. private Nachrichten)
function sendToUsers(userIds: string[], payload: unknown): void {
const targets = new Set(userIds);
wss.clients.forEach((raw) => {
const ws = raw as ExtendedWebSocket;
if (targets.has(ws.userId) && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'direct', payload }));
}
});
}
Throttling für Presence-Events: Bei schnellen Join/Leave-Sequenzen (z.B. mobile Nutzer die kurz offline gehen) kann Presence-Flooding entstehen. Claude Code fügt auf Wunsch ein Debounce von 500 ms ein, das redundante Events zusammenfasst.
3. Client-seitige Reconnect-Logik mit Exponential Backoff
Netzwerkunterbrechungen sind unvermeidlich — mobil, bei Serverneustarts oder nach Load-Balancer-Timeouts. Ein solider WebSocket-Client reconnectet automatisch, mit steigenden Wartezeiten (Exponential Backoff) und einem Zufalls-Jitter, um den Server nicht mit simultanen Reconnects zu überlasten.
Client useWebSocket React Hook — Production-Ready
Der Hook kapselt die gesamte Verbindungslogik: Aufbau, Nachrichten, Reconnect, Cleanup. Komponenten bekommen nur drei Werte: send, status und disconnect.
// hooks/useWebSocket.ts
import { useEffect, useRef, useCallback, useState } from 'react';
interface UseWebSocketOptions {
url: string;
onMessage: (msg: unknown) => void;
onOpen?: () => void;
onClose?: () => void;
maxRetries?: number;
baseDelay?: number; // ms, default 1000
}
type Status = 'connecting' | 'open' | 'closed' | 'reconnecting';
export function useWebSocket({
url, onMessage, onOpen, onClose,
maxRetries = 10, baseDelay = 1_000,
}: UseWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const retriesRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [status, setStatus] = useState<Status>('connecting');
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
setStatus(retriesRef.current > 0 ? 'reconnecting' : 'connecting');
const ws = new WebSocket(url);
wsRef.current = ws;
ws.addEventListener('open', () => {
retriesRef.current = 0;
setStatus('open');
onOpen?.();
});
ws.addEventListener('message', (e) => {
try { onMessage(JSON.parse(e.data)); }
catch { onMessage(e.data); }
});
ws.addEventListener('close', (e) => {
setStatus('closed');
onClose?.();
// 1000 = normales Close → kein Retry
if (e.code === 1000 || retriesRef.current >= maxRetries) return;
// Exponential Backoff mit Jitter, cap bei 30 s
const delay = Math.min(
baseDelay * Math.pow(2, retriesRef.current) + Math.random() * 1_000,
30_000,
);
console.log(`[WS] Reconnect in ${(delay / 1000).toFixed(1)}s (Versuch ${retriesRef.current + 1}/${maxRetries})`);
retriesRef.current++;
timerRef.current = setTimeout(connect, delay);
});
ws.addEventListener('error', (e) => console.error('[WS] Fehler:', e));
}, [url, onMessage, onOpen, onClose, maxRetries, baseDelay]);
const send = useCallback((data: unknown) => {
if (wsRef.current?.readyState === WebSocket.OPEN)
wsRef.current.send(JSON.stringify(data));
else
console.warn('[WS] Nachricht verworfen: Verbindung nicht offen');
}, []);
const disconnect = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
wsRef.current?.close(1000, 'Client disconnect');
}, []);
useEffect(() => {
connect();
return () => disconnect();
}, [connect, disconnect]);
return { send, status, disconnect };
}
React StrictMode Doppel-Effect: Im Development-Modus mountet React Effects zweimal, was zu zwei simultanen WebSocket-Verbindungen führen kann. Der Hook oben ist dagegen abgesichert: wsRef verhindert doppelten Aufbau, und close(1000) beim Cleanup stoppt den Retry-Timer sauber.
// Verwendung in einer Chat-Komponente
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<string[]>([]);
const { send, status } = useWebSocket({
url: `wss://api.example.com/ws?token=${getToken()}`,
onMessage: (msg: any) => {
if (msg.type === 'broadcast') setMessages((prev) => [...prev, msg.payload]);
},
onOpen: () => send({ type: 'join', room: roomId }),
});
return (
<div>
<span>Status: {status}</span>
{messages.map((m, i) => <p key={i}>{m}</p>)}
<button onClick={() => send({ type: 'chat', room: roomId, payload: 'Hallo!' })}>
Senden
</button>
</div>
);
}
4. JWT-Authentifizierung: Query-Param vs. Ticket-Pattern
WebSockets haben keine eigene Auth-Schicht. Der initiale Handshake läuft über HTTP, danach gibt es keine weiteren Requests mehr — Header lassen sich nach dem Upgrade nicht mehr prüfen. Es gibt zwei etablierte Patterns: JWT als Query-Parameter (einfach, schnell) und das Redis-Ticket-Pattern (sicher, empfohlen für Produktion).
Auth Pattern 1: JWT als Query-Param
Schnell zu implementieren. Nachteil: Das Token landet in Server-Logs und Browser-Historien. Für interne Tools oder nicht-sensible Daten ausreichend.
// auth.ts — JWT Query-Param Validation
import jwt from 'jsonwebtoken';
import type { IncomingMessage } from 'http';
interface JwtPayload { sub: string; role: string; exp: number; }
function extractUserId(req: IncomingMessage): string {
const url = new URL(req.url!, `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (!token) throw new Error('No token');
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
return payload.sub;
}
Auth Pattern 2: Redis Single-Use Ticket (empfohlen)
Der REST-Endpunkt stellt ein kurzlebiges Ticket aus (30 s TTL). Der WebSocket-Handshake tauscht das Ticket gegen die User-ID ein — das Ticket ist danach ungültig. Das Token selbst erscheint nie in URL-Logs.
// ticket-auth.ts — Redis Single-Use Ticket Pattern
import { Redis } from 'ioredis';
import { randomBytes } from 'crypto';
const redis = new Redis(process.env.REDIS_URL!);
// Schritt 1: REST-Endpunkt — erfordert gültige Session / Cookie
app.post('/api/ws-ticket', async (req, res) => {
const userId = req.session?.userId;
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const ticket = randomBytes(32).toString('hex');
await redis.set(`ws:ticket:${ticket}`, userId, 'EX', 30); // 30 s TTL
res.json({ ticket });
});
// Schritt 2: WebSocket-Handshake — atomic GET + DELETE
async function validateTicket(req: IncomingMessage): Promise<string> {
const url = new URL(req.url!, `http://${req.headers.host}`);
const ticket = url.searchParams.get('ticket');
if (!ticket) throw new Error('No ticket');
const userId = await redis.getdel(`ws:ticket:${ticket}`); // atomares GETDEL
if (!userId) throw new Error('Invalid or expired ticket');
return userId;
}
// Im wss.on('connection') — async Ticket-Validation vor Nutzung
wss.on('connection', async (ws: ExtendedWebSocket, req) => {
try {
ws.userId = await validateTicket(req);
} catch {
ws.close(4001, 'Unauthorized');
return;
}
ws.rooms = new Set();
ws.isAlive = true;
// ... restliche Connection-Logik
});
GETDEL ist atomar: Redis GETDEL liest und löscht den Key in einer einzigen Operation — kein Race Condition möglich. Jedes Ticket funktioniert exakt einmal, auch bei simultanen Verbindungsversuchen. Claude Code nutzt GETDEL standardmäßig statt separatem GET + DEL.
5. Horizontale Skalierung mit Redis Pub/Sub
Sobald die Anwendung auf mehreren Server-Instanzen läuft — hinter einem Load Balancer, in Kubernetes oder als PM2-Cluster — können WebSocket-Clients auf verschiedenen Instanzen landen. Ein Broadcast auf Instanz A erreicht Clients auf Instanz B nicht. Die Lösung: Redis als gemeinsamer Message Bus.
Skalierung Redis Pub/Sub: Cross-Instance Broadcasting
Jede Instanz published Nachrichten in einen Redis-Channel. Alle Instanzen subscriben den Channel und leiten Nachrichten an ihre lokalen Clients weiter. Eigene Broadcasts werden via originInstanceId herausgefiltert.
// redis-bus.ts — Redis Pub/Sub Message Bus
import { Redis } from 'ioredis';
// Zwei separate Clients — Pub/Sub-Verbindungen können keine normalen Befehle senden
const publisher = new Redis(process.env.REDIS_URL!);
const subscriber = new Redis(process.env.REDIS_URL!);
interface BusMessage {
type: 'room_broadcast' | 'direct';
roomId?: string;
targetUserId?: string;
payload: unknown;
originInstanceId: string;
}
const INSTANCE_ID = process.env.INSTANCE_ID ?? crypto.randomUUID();
const CHANNEL = 'ws:broadcast';
subscriber.subscribe(CHANNEL, (err) => {
if (err) console.error('[Redis] Subscribe failed:', err);
});
subscriber.on('message', (_ch, raw) => {
const msg: BusMessage = JSON.parse(raw);
// Eigene Broadcasts ignorieren — wurden bereits lokal verarbeitet
if (msg.originInstanceId === INSTANCE_ID) return;
switch (msg.type) {
case 'room_broadcast':
if (msg.roomId) localBroadcast(msg.roomId, msg.payload);
break;
case 'direct':
if (msg.targetUserId) localSendToUser(msg.targetUserId, msg.payload);
break;
}
});
async function publishToRoom(roomId: string, payload: unknown): Promise<void> {
// 1. Lokal sofort senden (kein Redis-Roundtrip nötig)
localBroadcast(roomId, payload);
// 2. Andere Instanzen via Redis informieren
const msg: BusMessage = { type: 'room_broadcast', roomId, payload, originInstanceId: INSTANCE_ID };
await publisher.publish(CHANNEL, JSON.stringify(msg));
}
function localBroadcast(roomId: string, payload: unknown): void {
const room = rooms.get(roomId);
if (!room) return;
const message = JSON.stringify({ type: 'broadcast', room: roomId, payload });
for (const client of room) {
if (client.readyState === WebSocket.OPEN) client.send(message);
}
}
function localSendToUser(userId: string, payload: unknown): void {
wss.clients.forEach((raw) => {
const ws = raw as ExtendedWebSocket;
if (ws.userId === userId && ws.readyState === WebSocket.OPEN)
ws.send(JSON.stringify({ type: 'direct', payload }));
});
}
Sticky Sessions als Alternative: Mit Nginx ip_hash oder Cookie-Affinity landen alle Verbindungen eines Users stets auf derselben Instanz. Das vereinfacht direkte User-zu-User-Nachrichten. Für Room-Broadcasts braucht man trotzdem Redis — denn User im selben Room können auf verschiedenen Instanzen sitzen.
Zwei Redis-Clients sind Pflicht: Eine Verbindung im Pub/Sub-Modus kann keine regulären Redis-Befehle ausführen (GET, SET, GETDEL etc.). Immer publisher und subscriber als separate Clients anlegen. Claude Code macht das in allen generierten Boilerplates korrekt.
6. uWebSockets.js für High-Traffic-Szenarien
Für die meisten Anwendungen ist ws die richtige Wahl. Wer jedoch Zehntausende gleichzeitiger Verbindungen erwartet — Live-Collaboration, Multiplayer-Spiele, große Dashboards — sollte uWebSockets.js in Betracht ziehen. Die Bibliothek ist in C++ geschrieben, hat einen eingebauten Pub/Sub-Mechanismus und kommt mit einem Bruchteil des Memory-Footprints von ws.
High-Traffic uWebSockets.js — Built-in Pub/Sub
Das Built-in Pub/Sub ist der entscheidende Vorteil: ws.subscribe(topic) und app.publish(topic, message) ersetzen den manuellen Room-Loop vollständig. Der Broadcast durchläuft keinen JavaScript-Loop mehr — das passiert in nativem C++-Code.
// uws-server.ts — uWebSockets.js mit Built-in Pub/Sub
import uWS from 'uWebSockets.js';
interface UserData { userId: string; rooms: Set<string>; }
const app = uWS.App();
app.ws<UserData>('/ws', {
compression: uWS.SHARED_COMPRESSOR,
maxPayloadLength: 16 * 1024,
idleTimeout: 60,
upgrade: (res, req, ctx) => {
const url = new URL(req.getUrl(), 'http://localhost');
const ticket = url.searchParams.get('ticket');
res.onAborted(() => {});
validateTicket(ticket)
.then((userId) => {
res.upgrade(
{ userId, rooms: new Set() },
req.getHeader('sec-websocket-key'),
req.getHeader('sec-websocket-protocol'),
req.getHeader('sec-websocket-extensions'),
ctx,
);
})
.catch(() => res.writeStatus('401').end());
},
open: (ws) => {
console.log(`[uWS] Connected: ${ws.getUserData().userId}`);
},
message: (ws, message, isBinary) => {
if (isBinary) return;
const data = ws.getUserData();
const msg = JSON.parse(Buffer.from(message).toString());
if (msg.type === 'join') {
ws.subscribe(msg.room); // Built-in Subscribe — ultra-schnell
data.rooms.add(msg.room);
app.publish(msg.room, JSON.stringify({
type: 'presence', event: 'join', userId: data.userId,
}), false);
}
if (msg.type === 'chat' && msg.room) {
// Kein JavaScript-Loop — Broadcast läuft in nativem C++
app.publish(msg.room, JSON.stringify({
type: 'chat', from: data.userId, payload: msg.payload, ts: Date.now(),
}), false);
}
},
close: (ws) => {
const data = ws.getUserData();
for (const room of data.rooms) {
app.publish(room, JSON.stringify({
type: 'presence', event: 'leave', userId: data.userId,
}), false);
}
},
});
app.listen(3001, (token) => {
if (token) console.log('[uWS] Läuft auf Port 3001');
});
Performance-Vergleich: ws vs. uWebSockets.js
Benchmark auf einem Standard-VPS (4 vCPU, 8 GB RAM) mit autocannon, 10.000 simultane Verbindungen:
| Metrik |
ws (Node.js) |
uWebSockets.js |
| Max. simultane Verbindungen | ~25.000 | >100.000 |
| Memory pro Verbindung | ~15 KB | ~4 KB |
| Broadcast 1k Clients | ~8 ms | <1 ms |
| Latenz (Median) | ~2 ms | <0,5 ms |
| Startup-Komplexität | Gering | Mittel |
Wann uWebSockets.js? Bis 5.000 simultane Verbindungen ist ws mehr als ausreichend. uWebSockets.js lohnt sich bei Live-Collaboration-Tools mit vielen gleichzeitigen Rooms, Multiplayer-Spielen oder Echtzeit-Dashboards mit Tausenden Subscribern — oder wenn Memory-Kosten auf Cloud-VMs eine Rolle spielen.
WebSocket-Stack mit Claude Code — so geht man vor
Claude Code kennt den gesamten WebSocket-Stack und arbeitet ihn iterativ ab. Die effektivste Herangehensweise ist eine schrittweise Prompt-Sequenz, bei der jede Antwort auf der vorherigen aufbaut:
Empfohlene Prompt-Sequenz
- Server-Grundstruktur: "Erstelle einen WebSocket-Server mit ws, TypeScript, Connection-Handler und Message-Router"
- Rooms hinzufügen: "Füge Map-basierte Rooms mit Join/Leave/Broadcast und Presence-Events hinzu"
- React Hook: "Generiere einen useWebSocket Hook mit Exponential Backoff und ConnectionStatus-Enum"
- Auth integrieren: "Implementiere JWT-Auth via Query-Param und ein Redis Ticket-Pattern als sichere Alternative"
- Redis Pub/Sub: "Skaliere den Server horizontal mit Redis Pub/Sub als Cross-Instance Message Bus"
- Performance: "Migriere den Server optional auf uWebSockets.js und nutze Built-in Pub/Sub statt manueller Loops"
Claude Code versteht den Kontext über alle Schritte hinweg: Import-Pfade stimmen, Interface-Definitionen sind konsistent, TypeScript-Typen passen zusammen. Das Ergebnis ist kein zusammengekopierter Code-Salat, sondern ein kohärentes, typsicheres Projekt.
Besonders wertvoll: Claude Code kennt die klassischen Fallstricke — doppelte Redis-Clients für Pub/Sub, React StrictMode-Doppel-Effects, den async-Constraint im uWS-Upgrade-Handler oder die Notwendigkeit von GETDEL statt separatem GET + DEL. Der generierte Code macht diese Fehler nicht.
Echtzeit-Modul im Kurs
Im Claude Code Mastery Kurs: vollständiges Echtzeit-Modul mit WebSockets, Server-Sent Events, Supabase Realtime und Redis Pub/Sub für skalierbare Live-Apps — inklusive Deployment auf Railway, Fly.io und eigenem VPS.
14 Tage kostenlos testen →