Use Cases (Aktualisiert: 3.6.2026)

WebSocket-Chat mit Claude Code bauen

Praxisguide für WebSocket-Chat mit Claude Code: Auth, Reconnects, Rate Limits und lauffähiger Node.js-Code.

WebSocket-Chat mit Claude Code bauen

Ein WebSocket-Chat wirkt klein, enthält aber viele echte Produktionsfragen: Wie wird die Verbindung authentifiziert? Was passiert bei Verbindungsabbruch? Wie werden Nachrichten nur an die richtige Raumgruppe verteilt? Wie verhindert man Spam? Und wie prüft man, dass spätere Änderungen den Chat nicht still kaputtmachen?

Dieser Leitfaden baut ein kopierbares Beispiel mit der Browser-WebSocket-API und dem Node.js-Paket ws. WebSocket bedeutet: Nach einem HTTP-Upgrade bleibt eine bidirektionale Verbindung offen, über die Browser und Server beide Nachrichten senden können. Nutze offizielle Quellen als Basis: MDN WebSocket API, Writing WebSocket client applications, RFC 6455, Node.js HTTP upgrade, ws README und den OWASP WebSocket Testing Guide. Für Claude Code selbst gilt die offizielle Claude Code Dokumentation.

Als Anschluss passen API-Entwicklung mit Claude Code, Code Review mit Claude Code und Sicherheits-Best-Practices.

Drei konkrete Anwendungsfälle

Erstens: ein kleiner Community- oder Support-Chat. Raum, Name, Text und kurzer Verlauf reichen zum Start. Ohne Authentifizierung und Limit kann aber ein einzelnes Skript den Prozess fluten.

Zweitens: ein Live-Dashboard. Build-Status, Zahlungen, Lagerbestand oder Job-Queues können sofort im Browser erscheinen. Hier ist wichtig, welche alten Ereignisse verworfen werden dürfen, denn die Browser-WebSocket-API hat keine starke eingebaute Backpressure.

Drittens: ein Fragenraum hinter einem Tutorial oder bezahlten Material. Leser können vom Artikel direkt in eine Unterhaltung wechseln. Das hilft bei Conversion, erfordert aber Regeln für Logs, Moderation und personenbezogene Daten.

FallWarum WebSocket passtErste Entscheidung
Kleiner ChatSchnelle bidirektionale NachrichtenAuth, Räume, Verlauf
Live-DashboardServer kann Updates pushenVerwerfen alter Events
Lern-SupportGespräch folgt auf ArtikelLogs, Moderation, CTA

Architektur

Fan-out heißt, eine eingehende Nachricht an alle Verbindungen im selben Raum zu verteilen. Heartbeat heißt, regelmäßig zu prüfen, ob eine lange Verbindung noch lebt.

flowchart LR
  BrowserA["Browser-Tab A"] -->|ws:// /chat| Node["Node.js server"]
  BrowserB["Browser-Tab B"] -->|ws:// /chat| Node
  Smoke["smoke-client.mjs"] -->|ws:// /chat| Node
  Node --> Auth["token and Origin check"]
  Node --> Rooms["room registry"]
  Rooms --> Fanout["fan-out to room peers"]
  Node --> History["last 50 messages"]
  Node --> Limit["rate limit and max payload"]

Prompt für Claude Code

Erstelle einen minimalen WebSocket-Chat mit Node.js 20+ und dem Paket ws.

Anforderungen:
- Nur server.js, index.html, smoke-client.mjs und package.json verwenden
- http://localhost:8080 muss die Chat-Oberfläche ausliefern
- Beim /chat WebSocket upgrade token und Origin prüfen
- Nachrichten pro Raum verteilen
- Nur die letzten 50 Nachrichten im Speicher halten
- Pro Verbindung 20 Nachrichten je 10 Sekunden erlauben
- Nachrichtentext auf 500 Zeichen begrenzen
- Browser-Client nach close mit exponentiellem Backoff neu verbinden
- Vor dem Senden readyState und bufferedAmount prüfen
- smoke-client.mjs muss verbinden, senden und erfolgreich beenden

Nicht tun:
- Nicht auf Socket.IO wechseln
- Keine unauthentifizierten Verbindungen akzeptieren
- JSON nicht ungeprüft verwenden
- Speicherverlauf nicht als Produktionspersistenz darstellen

Lokal starten

mkdir ws-chat-demo
cd ws-chat-demo
npm init -y
npm install ws
npm run start

package.json:

{
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "smoke": "node smoke-client.mjs"
  },
  "dependencies": {
    "ws": "^8.18.3"
  },
  "engines": {
    "node": ">=20"
  }
}

server.js

import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { WebSocket, WebSocketServer } from "ws";

const PORT = Number(process.env.PORT ?? 8080);
const AUTH_TOKEN = process.env.CHAT_TOKEN ?? "dev-token";
const MAX_MESSAGE_LENGTH = 500;
const HISTORY_LIMIT = 50;
const RATE_WINDOW_MS = 10_000;
const RATE_LIMIT = 20;
const CLIENT_FILE = join(fileURLToPath(new URL(".", import.meta.url)), "index.html");
const ALLOWED_ORIGINS = new Set(
  (process.env.ALLOWED_ORIGINS ?? `http://localhost:${PORT},http://127.0.0.1:${PORT}`)
    .split(",")
    .map((value) => value.trim())
    .filter(Boolean)
);

const rooms = new Map();
const histories = new Map();
const clients = new Map();

const server = createServer(async (request, response) => {
  if (request.url === "/" || request.url === "/index.html") {
    try {
      const html = await readFile(CLIENT_FILE, "utf8");
      response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
      response.end(html);
    } catch {
      response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
      response.end("index.html was not found");
    }
    return;
  }

  if (request.url === "/healthz") {
    response.writeHead(200, { "content-type": "application/json" });
    response.end(JSON.stringify({ ok: true, clients: clients.size }));
    return;
  }

  response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
  response.end("not found");
});

const wss = new WebSocketServer({ noServer: true, maxPayload: 2048 });

server.on("upgrade", (request, socket, head) => {
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
  if (url.pathname !== "/chat") {
    rejectUpgrade(socket, 404, "Not Found");
    return;
  }

  const origin = request.headers.origin ?? "";
  if (!ALLOWED_ORIGINS.has(origin)) {
    rejectUpgrade(socket, 403, "Forbidden");
    return;
  }

  const token = url.searchParams.get("token");
  if (token !== AUTH_TOKEN) {
    rejectUpgrade(socket, 401, "Unauthorized");
    return;
  }

  const context = {
    name: cleanName(url.searchParams.get("name")),
    room: cleanRoom(url.searchParams.get("room"))
  };

  wss.handleUpgrade(request, socket, head, (ws) => {
    wss.emit("connection", ws, request, context);
  });
});

wss.on("connection", (ws, request, context) => {
  const client = {
    id: randomUUID(),
    name: context.name,
    room: context.room,
    isAlive: true,
    rateResetAt: Date.now() + RATE_WINDOW_MS,
    messagesInWindow: 0
  };

  clients.set(ws, client);
  joinRoom(ws, client.room);

  send(ws, { type: "system", message: `connected as ${client.name}`, clientId: client.id });
  send(ws, { type: "history", messages: histories.get(client.room) ?? [] });
  broadcast(client.room, { type: "presence", message: `${client.name} joined`, online: roomSize(client.room) });

  ws.on("pong", () => {
    client.isAlive = true;
  });

  ws.on("message", (raw, isBinary) => {
    if (isBinary) {
      ws.close(1003, "text messages only");
      return;
    }

    if (!consumeQuota(client)) {
      send(ws, { type: "error", code: "rate_limited", message: "Too many messages. Wait a moment." });
      return;
    }

    const textBody = raw.toString("utf8");
    if (textBody.length > MAX_MESSAGE_LENGTH) {
      send(ws, { type: "error", code: "too_large", message: `Message must be ${MAX_MESSAGE_LENGTH} characters or less.` });
      return;
    }

    let event;
    try {
      event = JSON.parse(textBody);
    } catch {
      send(ws, { type: "error", code: "bad_json", message: "Send JSON such as {\"type\":\"message\",\"text\":\"hi\"}." });
      return;
    }

    if (event.type !== "message") {
      send(ws, { type: "error", code: "bad_type", message: "Only message events are accepted." });
      return;
    }

    const text = String(event.text ?? "").trim();
    if (!text) {
      send(ws, { type: "error", code: "empty", message: "Message text is required." });
      return;
    }

    const message = {
      id: randomUUID(),
      room: client.room,
      from: client.name,
      text,
      sentAt: new Date().toISOString()
    };

    remember(client.room, message);
    broadcast(client.room, { type: "message", message });
  });

  ws.on("close", () => {
    leaveRoom(ws);
    clients.delete(ws);
    broadcast(client.room, { type: "presence", message: `${client.name} left`, online: roomSize(client.room) });
  });

  ws.on("error", (error) => {
    console.error("websocket error", error);
  });
});

const heartbeat = setInterval(() => {
  for (const ws of wss.clients) {
    const client = clients.get(ws);
    if (!client) continue;
    if (!client.isAlive) {
      ws.terminate();
      continue;
    }
    client.isAlive = false;
    ws.ping();
  }
}, 30_000);

server.listen(PORT, () => {
  console.log(`Chat demo: http://localhost:${PORT}`);
  console.log(`Token: ${AUTH_TOKEN}`);
});

process.on("SIGINT", () => {
  clearInterval(heartbeat);
  wss.close();
  server.close(() => process.exit(0));
});

function rejectUpgrade(socket, statusCode, message) {
  socket.write(`HTTP/1.1 ${statusCode} ${message}\r\nConnection: close\r\n\r\n`);
  socket.destroy();
}

function cleanName(value) {
  const name = String(value ?? "guest").trim().slice(0, 24);
  return /^[\w -]+$/.test(name) ? name : "guest";
}

function cleanRoom(value) {
  const room = String(value ?? "lobby").trim().slice(0, 32);
  return /^[a-zA-Z0-9_-]+$/.test(room) ? room : "lobby";
}

function joinRoom(ws, room) {
  if (!rooms.has(room)) rooms.set(room, new Set());
  rooms.get(room).add(ws);
}

function leaveRoom(ws) {
  const client = clients.get(ws);
  if (!client) return;
  const peers = rooms.get(client.room);
  if (!peers) return;
  peers.delete(ws);
  if (peers.size === 0) rooms.delete(client.room);
}

function roomSize(room) {
  return rooms.get(room)?.size ?? 0;
}

function send(ws, payload) {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify(payload));
  }
}

function broadcast(room, payload) {
  const peers = rooms.get(room);
  if (!peers) return;
  const body = JSON.stringify(payload);
  for (const peer of peers) {
    if (peer.readyState === WebSocket.OPEN) {
      peer.send(body);
    }
  }
}

function remember(room, message) {
  const history = histories.get(room) ?? [];
  history.push(message);
  while (history.length > HISTORY_LIMIT) history.shift();
  histories.set(room, history);
}

function consumeQuota(client) {
  const now = Date.now();
  if (now > client.rateResetAt) {
    client.rateResetAt = now + RATE_WINDOW_MS;
    client.messagesInWindow = 0;
  }
  client.messagesInWindow += 1;
  return client.messagesInWindow <= RATE_LIMIT;
}

index.html

<!doctype html>
<html lang="de">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>WebSocket-Chat-Demo</title>
    <style>
      body { font-family: system-ui, sans-serif; max-width: 760px; margin: 32px auto; padding: 0 16px; }
      label { display: block; margin-top: 12px; }
      input, button { font: inherit; padding: 8px; }
      #log { border: 1px solid #ccc; min-height: 240px; padding: 12px; overflow: auto; }
      .message { margin: 0 0 8px; }
      .muted { color: #666; }
      .error { color: #b00020; }
    </style>
  </head>
  <body>
    <h1>WebSocket-Chat-Demo</h1>
    <p id="status" class="muted">offline</p>

    <label>Raum <input id="room" value="lobby" /></label>
    <label>Name <input id="name" value="masa" /></label>
    <label>Token <input id="token" value="dev-token" /></label>
    <button id="connect">Verbinden</button>
    <button id="disconnect">Trennen</button>

    <div id="log" aria-live="polite"></div>

    <form id="form">
      <label>Nachricht <input id="message" autocomplete="off" maxlength="500" /></label>
      <button type="submit">Senden</button>
    </form>

    <script>
      const statusEl = document.querySelector("#status");
      const logEl = document.querySelector("#log");
      const formEl = document.querySelector("#form");
      const roomEl = document.querySelector("#room");
      const nameEl = document.querySelector("#name");
      const tokenEl = document.querySelector("#token");
      const messageEl = document.querySelector("#message");
      const connectEl = document.querySelector("#connect");
      const disconnectEl = document.querySelector("#disconnect");

      let socket;
      let reconnectTimer;
      let reconnectAttempt = 0;
      let manuallyClosed = false;
      const pendingMessages = [];

      connectEl.addEventListener("click", connect);
      disconnectEl.addEventListener("click", () => {
        manuallyClosed = true;
        clearTimeout(reconnectTimer);
        if (socket) socket.close(1000, "user disconnected");
      });

      formEl.addEventListener("submit", (event) => {
        event.preventDefault();
        const text = messageEl.value.trim();
        if (!text) return;
        const payload = JSON.stringify({ type: "message", text });

        if (socket?.readyState === WebSocket.OPEN && socket.bufferedAmount < 64 * 1024) {
          socket.send(payload);
        } else {
          pendingMessages.push(payload);
          writeLog("Socket ist nicht bereit; Nachricht wartet.", "muted");
        }

        messageEl.value = "";
      });

      function connect() {
        manuallyClosed = false;
        clearTimeout(reconnectTimer);
        if (socket && socket.readyState === WebSocket.OPEN) return;

        const params = new URLSearchParams({
          token: tokenEl.value,
          name: nameEl.value,
          room: roomEl.value
        });
        const protocol = location.protocol === "https:" ? "wss:" : "ws:";
        socket = new WebSocket(`${protocol}//${location.host}/chat?${params.toString()}`);
        setStatus("verbindet");

        socket.addEventListener("open", () => {
          reconnectAttempt = 0;
          setStatus("online");
          flushPending();
        });

        socket.addEventListener("message", (event) => {
          const data = JSON.parse(event.data);
          renderEvent(data);
        });

        socket.addEventListener("close", (event) => {
          setStatus(`geschlossen ${event.code}`);
          if (!manuallyClosed) scheduleReconnect();
        });

        socket.addEventListener("error", () => {
          writeLog("WebSocket-Fehler. Serverlog prüfen.", "error");
        });
      }

      function scheduleReconnect() {
        const delay = Math.min(1000 * 2 ** reconnectAttempt, 10000) + Math.floor(Math.random() * 250);
        reconnectAttempt += 1;
        setStatus(`Reconnect in ${delay}ms`);
        reconnectTimer = setTimeout(connect, delay);
      }

      function flushPending() {
        while (pendingMessages.length > 0 && socket.readyState === WebSocket.OPEN && socket.bufferedAmount < 64 * 1024) {
          socket.send(pendingMessages.shift());
        }
      }

      function renderEvent(data) {
        if (data.type === "message") {
          writeLog(`${data.message.from}: ${data.message.text}`);
        } else if (data.type === "history") {
          writeLog(`${data.messages.length} frühere Nachrichten geladen.`, "muted");
          data.messages.forEach((message) => writeLog(`${message.from}: ${message.text}`));
        } else if (data.type === "error") {
          writeLog(`${data.code}: ${data.message}`, "error");
        } else {
          writeLog(data.message ?? JSON.stringify(data), "muted");
        }
      }

      function setStatus(value) {
        statusEl.textContent = value;
      }

      function writeLog(value, className = "message") {
        const line = document.createElement("p");
        line.className = className;
        line.textContent = value;
        logEl.append(line);
        logEl.scrollTop = logEl.scrollHeight;
      }

      connect();
    </script>
  </body>
</html>

smoke-client.mjs

import { WebSocket } from "ws";

const url = new URL("ws://localhost:8080/chat");
url.searchParams.set("token", process.env.CHAT_TOKEN ?? "dev-token");
url.searchParams.set("name", "smoke");
url.searchParams.set("room", "lobby");

const ws = new WebSocket(url, {
  headers: {
    Origin: "http://localhost:8080"
  }
});

const timeout = setTimeout(() => {
  console.error("smoke test timed out");
  ws.terminate();
  process.exit(1);
}, 5000);

ws.on("open", () => {
  ws.send(JSON.stringify({ type: "message", text: "hello from smoke test" }));
});

ws.on("message", (raw) => {
  const data = JSON.parse(raw.toString("utf8"));
  console.log(data);
  if (data.type === "message" && data.message.text === "hello from smoke test") {
    clearTimeout(timeout);
    ws.close(1000, "done");
  }
});

ws.on("close", (code, reason) => {
  console.log(`closed ${code} ${reason.toString()}`);
  process.exit(code === 1000 ? 0 : 1);
});

ws.on("error", (error) => {
  clearTimeout(timeout);
  console.error(error);
  process.exit(1);
});
npm run smoke

Häufige Fehler

FehlerFolgeGegenmaßnahme
Keine Authentifizierung beim UpgradeUnsichere Verbindung ist aktivVor handleUpgrade ablehnen
Origin nicht geprüftRisiko durch fremde SeitenErlaubte Origins festlegen
Kein Rate LimitEin Client überlastet CPU und SpeicherNach Verbindung, Nutzer und IP begrenzen
Unbegrenzter Verlauf im SpeicherProzess wächst dauerhaftAnzahl, Alter und Speicherziel begrenzen
Sofortige Reconnect-SchleifeLastspitze nach AusfallExponentieller Backoff mit Jitter
Fan-out nur in einem ProzessAndere Server bekommen nichtsRedis Pub/Sub, NATS oder Queue nutzen

Produktion und Einnahmepfad

Nutze wss://, sobald Konten, Zahlungen, Support oder personenbezogene Daten beteiligt sind. Prüfe Rechte nicht nur beim Verbinden, sondern auch beim Betreten eines Raums, Senden, Löschen und Moderieren. Verlauf gehört in eine Datenbank; mehrere Node-Prozesse brauchen einen gemeinsamen Pub/Sub-Kanal.

Einzelne Entwickler starten mit dem kostenlosen Claude Code Cheatsheet. Wiederverwendbare Prompts und Setup-Material findest du unter ClaudeCodeLab Produkte. Teams, die Auth, Audit-Logs, Pub/Sub, CI und Review-Regeln in einem echten Repository festlegen wollen, sollten Claude Code Training und Beratung nutzen.

Praktisches Ergebnis: Masa hat server.js gestartet, http://localhost:8080 in zwei Tabs geöffnet und Nachrichten im Raum lobby sofort in beiden Tabs gesehen. npm run smoke sendete und empfing hello from smoke test. Ein falscher Origin führte zu 403, ein falscher Token zu 401. Reconnects, Rate Limits, Origin-Prüfung und 50 Nachrichten Verlauf von Anfang an in die Claude-Code-Aufgabe zu schreiben, machte den Diff deutlich leichter prüfbar.

#Claude Code #WebSocket #Chat #Echtzeit #Node.js
Kostenlos

Kostenloses PDF: Claude-Code-Cheatsheet

E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.

Wir schützen Ihre Daten und senden keinen Spam.

Masa

Über den Autor

Masa

Engineer für praktische Claude-Code-Workflows und Team-Einführung.