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.
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 case | Why WebSocket fits | First decision |
|---|---|---|
| Small chat | Low-latency two-way messages | Auth, rooms, recent history |
| Live dashboard | Server can push updates | Drop policy and reconnect behavior |
| Learning support | Conversation follows the article | Logs, 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
| Failure | Impact | Fix |
|---|---|---|
| No auth at upgrade | Untrusted sockets become active | Reject before handleUpgrade |
| No Origin check | Cross-site WebSocket hijacking risk | Allowlist known origins |
| No rate limit | One client can spam CPU and memory | Limit by connection, user, and IP |
| Unlimited memory history | Long-running process grows forever | Cap count, age, and storage policy |
| Immediate reconnect loop | Recovery causes a traffic spike | Use exponential backoff and jitter |
| Single-process fan-out only | Multi-node users miss messages | Add Redis Pub/Sub, NATS, or a queue |
Trusting from in the payload | User impersonation | Use 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.
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.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.