WebSockets mit Claude Code 2026

WebSockets sind das Fundament jeder modernen Echtzeit-App — und Claude Code kennt den gesamten Stack: vom Server-Setup über Rooms und JWT-Auth bis hin zu Redis Pub/Sub für horizontale Skalierung auf produktionsreifen Multi-Instance-Systemen.

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ätGeringMittel
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

  1. Server-Grundstruktur: "Erstelle einen WebSocket-Server mit ws, TypeScript, Connection-Handler und Message-Router"
  2. Rooms hinzufügen: "Füge Map-basierte Rooms mit Join/Leave/Broadcast und Presence-Events hinzu"
  3. React Hook: "Generiere einen useWebSocket Hook mit Exponential Backoff und ConnectionStatus-Enum"
  4. Auth integrieren: "Implementiere JWT-Auth via Query-Param und ein Redis Ticket-Pattern als sichere Alternative"
  5. Redis Pub/Sub: "Skaliere den Server horizontal mit Redis Pub/Sub als Cross-Instance Message Bus"
  6. 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 →