Use Cases (Updated: 6/3/2026)

Build a WebSocket Chat App with Claude Code

Build a secure WebSocket chat with Claude Code, including auth, reconnects, rate limits, and runnable Node.js code.

Build a WebSocket Chat App with Claude Code

A WebSocket chat app is small enough to build in one sitting, but realistic enough to expose the mistakes that matter in production. Claude Code can generate the files quickly, yet the quality depends on the constraints you give it: authentication, reconnects, rate limiting, message validation, fan-out, and cleanup when a connection dies.

This guide builds a copy-pasteable Node.js chat demo with the browser WebSocket API and the ws package. It also explains what to ask Claude Code for, which failure cases to review, and how to turn the demo into a safer production design. WebSocket means a long-lived, full-duplex connection: after the HTTP upgrade handshake succeeds, browser and server can both send messages without waiting for a new HTTP request.

Use primary references while you work. For browser behavior, read MDN WebSocket API and Writing WebSocket client applications. For the protocol, use RFC 6455. For the Node upgrade entry point, check Node.js HTTP upgrade. For the server library, use the ws README. For Origin checks and security testing, read the OWASP WebSocket testing guide. For Claude Code workflow context, keep the Claude Code docs nearby.

For adjacent ClaudeCodeLab material, pair this with production API development, Claude Code code review, and security best practices.

Three Practical Use Cases

The first use case is a small community or support chat. The data model looks simple: room id, sender name, message text, timestamp, and a short history. The risk is that an anonymous socket can send unlimited messages unless you require authentication and a per-connection limit.

The second use case is a live operations dashboard. Build status, queue depth, stock changes, and payment events can be pushed to the browser immediately. In this case, the hard part is not the UI. It is deciding which old events can be dropped. MDN notes that the browser WebSocket API does not provide built-in backpressure, so a fast stream can consume memory or CPU if the app cannot keep up.

The third use case is a learning or paid support room. A reader can ask a question after a tutorial, and the operator can point them to the next article, template, or consultation page. This is a useful monetization path, but it also means you need logging rules, personal-data handling, moderation, and a clear deletion process.

Use caseWhy WebSocket fitsFirst decision
Small chatLow-latency two-way messagesAuth, rooms, recent history
Live dashboardServer can push updatesDrop policy and reconnect behavior
Learning supportConversation follows the articleLogs, moderation, and CTA placement

Architecture

Fan-out means distributing one inbound message to every connected peer in the same room. A heartbeat means checking that a long-lived socket is still alive. In Claude Code discussions, a harness is the tooling layer that lets an agent read files, edit code, and run commands in a real project.

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 for Claude Code

Do not prompt with only “make a WebSocket chat app.” That usually produces a demo with no security boundary. Give Claude Code the exact scope and the failure cases you care about.

Create a minimal WebSocket chat using Node.js 20+ and the ws package.

Requirements:
- It must run with only server.js, index.html, smoke-client.mjs, and package.json.
- http://localhost:8080 should serve the chat UI.
- The /chat WebSocket upgrade must validate token and Origin.
- Messages must fan out by room.
- Keep only the latest 50 messages in memory.
- Add a per-connection limit of 20 messages per 10 seconds.
- Limit message text to 500 characters.
- The browser client must reconnect with exponential backoff after close.
- The browser client must check readyState and bufferedAmount before sending.
- smoke-client.mjs must connect, send one message, and exit successfully.

Do not:
- Replace this with Socket.IO.
- Accept unauthenticated connections.
- Trust unvalidated JSON input.
- Pretend the in-memory history is production persistence.

Local Setup

Create the files below in one directory. Start the server, open http://localhost:8080 in two browser tabs, then run the smoke test from a second terminal.

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

Replace package.json with this:

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

Server

This server serves the HTML page and accepts WebSocket upgrades on /chat. The demo token is dev-token unless you set CHAT_TOKEN. In production, avoid long-lived URL tokens because URLs are often logged. Prefer a short-lived ticket, an HTTP-only session cookie, or authentication at the reverse proxy.

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;
}

Browser Client

The browser WebSocket constructor accepts a URL and optional subprotocols. It does not work like fetch with arbitrary request headers, so this demo uses a query token. That is convenient for local testing, not ideal for long-lived production secrets.

<!doctype html>
<html lang="en">
  <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>Room <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">Connect</button>
    <button id="disconnect">Disconnect</button>

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

    <form id="form">
      <label>Message <input id="message" autocomplete="off" maxlength="500" /></label>
      <button type="submit">Send</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("Queued because the socket is not ready.", "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("connecting");

        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(`closed ${event.code}`);
          if (!manuallyClosed) scheduleReconnect();
        });

        socket.addEventListener("error", () => {
          writeLog("WebSocket error. Check the server log.", "error");
        });
      }

      function scheduleReconnect() {
        const delay = Math.min(1000 * 2 ** reconnectAttempt, 10000) + Math.floor(Math.random() * 250);
        reconnectAttempt += 1;
        setStatus(`reconnecting 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(`Loaded ${data.messages.length} previous messages.`, "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

Manual testing is useful, but a tiny smoke client catches broken auth, Origin validation, and message fan-out after refactors.

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);
});

Run it while the server is still running:

npm run smoke

Failure Cases to Review

FailureImpactFix
No auth at upgradeUntrusted sockets become activeReject before handleUpgrade
No Origin checkCross-site WebSocket hijacking riskAllowlist known origins
No rate limitOne client can spam CPU and memoryLimit by connection, user, and IP
Unlimited memory historyLong-running process grows foreverCap count, age, and storage policy
Immediate reconnect loopRecovery causes a traffic spikeUse exponential backoff and jitter
Single-process fan-out onlyMulti-node users miss messagesAdd Redis Pub/Sub, NATS, or a queue
Trusting from in the payloadUser impersonationUse the identity attached to the socket

Production Notes

Move from ws:// to wss:// when any account, payment, support, or personal data is involved. Keep authorization checks on each meaningful action, not only on connection. A user may lose access to a room while a socket is still open.

Move message history out of process memory. PostgreSQL is fine for durable chat history; Redis is useful for shared rate limits and short-lived presence; Pub/Sub or a broker is needed when multiple Node processes must deliver the same event to their own connected clients.

Monitor connection count, message rate, close codes, rate-limit events, and heartbeat terminations. Plain HTTP access logs do not tell you whether long-lived sockets are healthy. Keep /healthz or a similar endpoint for load balancers and uptime checks.

When you ask Claude Code for the next iteration, keep the task narrow: “add PostgreSQL history,” “replace in-memory fan-out with Redis Pub/Sub,” or “write a Playwright two-tab test.” Narrow changes protect working auth, analytics, and monetization links.

CTA and Result

If you are learning solo, start with the free Claude Code cheatsheet and apply this example to a throwaway room. If you want reusable prompts and setup patterns, browse ClaudeCodeLab products. If your team needs auth, audit logs, CI checks, Pub/Sub, and review rules mapped onto a real repository, use Claude Code training and consultation.

After trying this workflow, Masa’s result was concrete: two browser tabs on http://localhost:8080 received messages in the same lobby room, and npm run smoke sent and received hello from smoke test. Changing the Origin returned 403, and changing the token returned 401. Giving Claude Code reconnects, rate limits, Origin validation, and a 50-message history limit up front produced a much smaller, easier-to-review change than adding those safeguards afterward.

#Claude Code #WebSocket #chat #real-time #Node.js
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.