Build a Real-Time WebSocket Chat Server
A complete Node.js WebSocket chat server with rooms, message history, rate limiting, heartbeat, reconnection, tests, and honest scaling limits.
Project goal
Build a real-time chat server where authenticated users join rooms, exchange size-limited messages, see join/leave events, and reconnect without duplicating messages.
Final features
- WebSocket upgrade with session authentication and origin checks
- Multiple rooms with join, leave, and member snapshots
- Bounded message history per room and message sequence numbers
- Per-user token-bucket rate limiting and payload size limits
- Heartbeat that terminates unresponsive connections
- Client reconnection with exponential backoff and jitter
Architecture
An Express HTTP server handles static files and the upgrade request. A ws WebSocketServer with maxPayload manages connections. RoomManager tracks rooms, members, and a bounded history. Broadcaster validates events, applies rate limits, and fans out messages only to clients in the room. Multi-instance fan-out requires a shared pub/sub layer; in-memory rooms are explicitly single-instance.
Prerequisites
- Node.js 20 LTS or later
- pnpm or npm
- Basic understanding of HTTP upgrade and async JavaScript
Dependencies
- express ^4.21.0 — HTTP server and static file serving
- ws ^8.18.0 — WebSocket server and client
- nanoid ^5.0.0 — Message identifiers
Directory structure
chat-server/
├── src/server.ts
├── src/room-manager.ts
├── src/broadcaster.ts
├── src/types.ts
├── public/index.html
├── tests/chat.test.ts
└── package.json
Phases
1. HTTP and WebSocket bootstrap
Create the HTTP server, authenticate the upgrade, and enforce payload and origin limits.
Files: src/server.ts, src/types.ts
```ts
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
server.on('upgrade', (req, socket, head) => {
const origin = req.headers.origin;
if (!isAllowedOrigin(origin)) { socket.destroy(); return; }
const session = readSession(req);
if (!session) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req, session);
});
});
```
Expected behavior: An unauthenticated or cross-origin upgrade is rejected before a socket is created.
2. Room manager with member and history state
Track rooms, members by user id, and a bounded message history with sequence numbers.
Files: src/room-manager.ts
```ts
join(roomId: string, user: User) {
const room = this.rooms.get(roomId) ?? { members: new Map(), history: [], sequence: 0 };
this.rooms.set(roomId, room);
room.members.set(user.id, user);
}
append(roomId: string, message: Message): number {
const room = this.rooms.get(roomId);
if (!room) throw new Error('room not found');
room.sequence += 1;
message.sequence = room.sequence;
room.history.push(message);
if (room.history.length > 100) room.history.shift();
return room.sequence;
}
```
Expected behavior: A room keeps at most 100 messages and assigns a strictly increasing sequence.
3. Broadcast with validation and rate limits
Reject malformed events, enforce text length, apply a token bucket, and skip slow sockets.
Files: src/broadcaster.ts
```ts
if (typeof event.text !== 'string' || event.text.trim().length === 0) return sendError(user, 'empty message');
if (event.text.length > 2000) return sendError(user, 'message too long');
if (!allow(user.id)) return sendError(user, 'rate limited');
if (socket.bufferedAmount > 1_000_000) return; // slow consumer
```
Expected behavior: Empty, oversized, and rate-limited messages are rejected without reaching other clients.
4. Heartbeat and graceful shutdown
Ping clients on an interval, terminate unresponsive sockets, and close connections with code 1001 on shutdown.
Files: src/server.ts
```ts
const interval = setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) { ws.terminate(); continue; }
ws.isAlive = false;
ws.ping();
}
}, 30_000);
```
Expected behavior: Dead connections are removed within one heartbeat interval; shutdown closes clients with 1001.
5. Tests for rooms, limits, and disconnects
Start the server on an ephemeral port, connect real ws clients, and assert delivery, isolation, and rejection.
Files: tests/chat.test.ts
```ts
const server = http.createServer(app);
const wss = attachWebSocket(server, rooms, broadcaster);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
```
Expected behavior: Tests cover two-room isolation, join/leave events, malformed JSON, oversize text, and rate limiting.
Testing
Use real ws clients against an ephemeral server port. Assert that a message reaches the other client in the same room and not a client in another room. Test malformed JSON, oversized text, rate limiting, and that closing a socket emits user_left exactly once.
Troubleshooting
Upgrade returns 404
The client path and WebSocketServer path differ. Use noServer plus an upgrade handler, or match the path exactly.
Messages stop after a network blip
The client reconnects but the server never removed the old socket. Use heartbeat termination and room state keyed by a stable user id.
Server memory grows with every room
Empty rooms are not deleted, or history is unbounded. Delete empty rooms and cap history length.
Deployment
Deploy behind a proxy that forwards Upgrade headers and does not buffer WebSocket traffic. Use WSS with TLS termination, configure idle timeouts above the heartbeat interval, and run a single instance per in-memory room set. For horizontal scaling, publish room events through Redis pub/sub and subscribe on every instance; sticky sessions alone are not enough.
Known limitations
- In-memory rooms and history are lost on restart and are not shared across instances.
- The example authenticates a session but does not implement a full user directory or moderation system.
- Message history is bounded in memory; durable history requires a database and retention policy.
Project overview
A chat server is the canonical WebSocket example, but the tutorial version often hides the hard parts behind a few broadcast calls. A production chat needs authentication before the upgrade, bounded rooms and history, message validation, rate limiting, heartbeat handling, slow-consumer protection, and a reconnection strategy that does not duplicate messages. This project builds those pieces on top of Node.js, Express, and the ws library.
The first version is single-instance and keeps rooms in memory. That is a deliberate scope decision, not an accident. The scaling section explains what must change before running multiple instances, and the limitations section states what the example does not provide. The client is a minimal HTML page; the focus is the protocol and server state, not a frontend framework.
Architecture and data model
The HTTP server serves the client and handles the upgrade request. The WebSocket server owns connections. RoomManager owns rooms, members, and history. Broadcaster validates incoming events, applies rate limits, and writes to the sockets in a room. Keeping these responsibilities separate makes the state transitions testable.
A room has an id, a Map<userId, User> of members, a bounded array of recent messages, and a monotonically increasing sequence. A user has an id, a display name, a socket, and a join timestamp. A message has an id, a room id, a user id, text, a timestamp, and a sequence. The sequence lets clients detect gaps and lets the server explain ordering after a reconnect.
Do not identify users by socket. A reconnecting client gets a new socket but should keep the same user id if the session is still valid. The room manager keys members by user id and replaces the socket on reconnect. If the same user opens two tabs, decide whether those are two sessions or one; this example treats each connection as a separate session with its own id unless a session id is supplied.
Phase 1: Bootstrap with a safe upgrade
Install dependencies and create the HTTP server. Use noServer: true and handle the upgrade event so authentication and origin checks run before a WebSocket is created:
mkdir chat-server && cd chat-server
pnpm init
pnpm add express ws nanoid
pnpm add -D typescript tsx vitest @types/node @types/express @types/ws
npx tsc --init
import http from 'node:http';
import express from 'express';
import { WebSocketServer } from 'ws';
const app = express();
app.use(express.static('public'));
const server = http.createServer(app);
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
server.on('upgrade', (req, socket, head) => {
if (!isAllowedOrigin(req.headers.origin)) {
socket.destroy();
return;
}
const session = readSession(req);
if (!session) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req, session);
});
});
maxPayload caps the frame size before the application parses it. An origin allowlist blocks cross-site WebSocket hijacking when authentication relies on cookies. Never put a long-lived token in the query string; query strings end up in logs, proxies, and browser history. Read an HttpOnly session cookie or a short-lived ticket created specifically for the WebSocket handshake.
Phase 2: Track rooms and history
The room manager should make invalid states hard. join creates a room, adds the member, and returns the current history. leave removes the member and deletes the room when it is empty. append assigns the next sequence and trims history to a fixed size.
type Room = {
members: Map<string, User>;
history: Message[];
sequence: number;
};
export class RoomManager {
private rooms = new Map<string, Room>();
join(roomId: string, user: User): Message[] {
const room = this.rooms.get(roomId) ?? { members: new Map(), history: [], sequence: 0 };
this.rooms.set(roomId, room);
room.members.set(user.id, user);
return [...room.history];
}
leave(roomId: string, userId: string): void {
const room = this.rooms.get(roomId);
if (!room) return;
room.members.delete(userId);
if (room.members.size === 0) this.rooms.delete(roomId);
}
append(roomId: string, message: Omit<Message, 'sequence'>): Message {
const room = this.rooms.get(roomId);
if (!room) throw new Error('room not found');
room.sequence += 1;
const stored = { ...message, sequence: room.sequence };
room.history.push(stored);
if (room.history.length > 100) room.history.shift();
return stored;
}
}
The bounded history is a product decision. It gives a reconnecting client recent context without turning memory into an unbounded log. A real chat that promises history must store messages in a database and paginate; the in-memory ring buffer is a convenience, not a durable record. If messages are sensitive, define retention and deletion before persisting them.
Phase 3: Validate, rate-limit, and broadcast
Every inbound frame is untrusted. Parse JSON inside a try/catch, check the event type, validate text length, and reject unknown types. A single malformed frame should produce an error event, not crash the process.
type ClientEvent =
| { type: 'chat_message'; text: string }
| { type: 'typing'; active: boolean };
function parseEvent(raw: string): ClientEvent {
const value = JSON.parse(raw) as unknown;
if (typeof value !== 'object' || value === null) throw new Error('invalid event');
const event = value as Record<string, unknown>;
if (event.type === 'chat_message') {
if (typeof event.text !== 'string' || event.text.trim().length === 0) throw new Error('empty message');
if (event.text.length > 2000) throw new Error('message too long');
return { type: 'chat_message', text: event.text };
}
if (event.type === 'typing' && typeof event.active === 'boolean') {
return { type: 'typing', active: event.active };
}
throw new Error('unknown event');
}
A token bucket limits message bursts without punishing a normal conversation. Refill at a steady rate, consume one token per chat message, and send a rate_limited error with a retry hint. Apply the limit per user, not per socket; otherwise opening more tabs multiplies the allowed rate.
const buckets = new Map<string, { tokens: number; updated: number }>();
function allow(userId: string, capacity = 5, refillPerSecond = 2): boolean {
const now = Date.now();
const bucket = buckets.get(userId) ?? { tokens: capacity, updated: now };
bucket.tokens = Math.min(capacity, bucket.tokens + ((now - bucket.updated) / 1000) * refillPerSecond);
bucket.updated = now;
if (bucket.tokens < 1) return false;
bucket.tokens -= 1;
buckets.set(userId, bucket);
return true;
}
Broadcast only to sockets in the room. Check readyState === WebSocket.OPEN before sending. Skip a socket whose bufferedAmount exceeds a threshold; a slow client should not force the server to buffer an unbounded queue. If the backlog remains high, close the socket with code 1013 (try again later) and let the client reconnect.
for (const user of room.members.values()) {
if (user.socket.readyState !== WebSocket.OPEN) continue;
if (user.socket.bufferedAmount > 1_000_000) continue;
user.socket.send(JSON.stringify(event));
}
The original tutorial’s exclude logic used an empty loop and never found the sender. Keep a direct userId -> User map or pass the sender socket explicitly. Do not scan every room to exclude one user; that is both incorrect and unnecessary.
Phase 4: Heartbeat and disconnect handling
A TCP connection can be half-open: the peer disappeared but no close event arrived. The server should ping on an interval and terminate a socket that did not answer the previous ping. The ws library exposes ping(), the pong event, and terminate().
const interval = setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) {
ws.terminate();
continue;
}
ws.isAlive = false;
ws.ping();
}
}, 30_000);
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
});
wss.on('close', () => clearInterval(interval));
On disconnect, remove the user from the room and broadcast user_left exactly once. A reconnect with the same user id replaces the old socket; close the old socket first and remove it from the room so the member map does not hold a dead connection. Use a close code that tells the client whether to retry: 1001 for going away, 1013 for overload, and 4401 for an authentication failure.
Phase 5: Build a resilient client
The client should use wss:// when the page is served over HTTPS, otherwise ws://. Derive the protocol from location.protocol rather than hard-coding it. Render messages with textContent, never innerHTML, because message text is user input.
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
let socket;
let attempt = 0;
function connect() {
socket = new WebSocket(`${scheme}://${location.host}/ws?room=${encodeURIComponent(roomId)}`);
socket.addEventListener('open', () => {
attempt = 0;
setStatus('live');
});
socket.addEventListener('message', (event) => render(JSON.parse(event.data)));
socket.addEventListener('close', () => {
if (socket.code === 1008 || socket.code === 4401) return;
const delay = Math.min(30_000, 500 * 2 ** attempt) * (0.5 + Math.random());
attempt += 1;
setStatus('reconnecting');
setTimeout(connect, delay);
});
}
connect();
The jitter prevents every client from reconnecting at the same instant after a server restart. The backoff cap prevents an unbounded delay. On reconnect, the server sends the room history snapshot and a user_joined event; the client should replace its local view from the snapshot instead of appending a duplicate history.
Typing indicators should be debounced. Send typing: true at most once per second and typing: false after a short idle period. Do not persist typing state; it is ephemeral and should disappear when the user disconnects.
Phase 6: Tests for rooms and limits
Use real WebSocket clients against an ephemeral HTTP server. This exercises the upgrade, the room manager, and the broadcaster together:
const server = http.createServer(app);
const wss = attachWebSocket(server, rooms, broadcaster);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
const alice = new WebSocket(`ws://127.0.0.1:${port}/ws?room=general`);
await new Promise((resolve) => alice.once('open', resolve));
Cover:
- A message from Alice reaches Bob in the same room.
- A message does not reach Carol in another room.
- Closing Alice emits
user_leftonce and removes her from the member list. - Malformed JSON, an empty message, and a 2,001-character message are rejected.
- The sixth message in a burst is rate-limited when the bucket is configured to five.
- A slow or dead socket is removed after heartbeat termination.
Use once and a timeout helper to avoid a test that hangs forever when a message is missing. Close every client and the server in afterEach; an open WebSocket keeps the test process alive.
Phase 7: Deploy and scale
Deploy behind a reverse proxy that forwards the Upgrade and Connection headers, and configure timeouts longer than the heartbeat interval. Terminate TLS at the proxy and use wss for clients. If the proxy buffers WebSocket traffic, the connection may appear to open but no messages arrive.
A single Node process can hold a few thousand idle WebSocket connections depending on memory and file descriptor limits. Measure before promising a number. Set server.maxConnections or a per-process connection cap, monitor file descriptors, and reject new upgrades with a clear close code when the cap is reached.
Multiple instances require a shared fan-out layer. Publish each accepted room message to Redis pub/sub (or a broker), subscribe on every instance, and deliver to local sockets only. Sticky sessions help with the handshake but do not deliver a message published on instance A to a client connected to instance B. Persist messages in a database if history must survive restarts, and deduplicate by message id because pub/sub is at-most-once or at-least-once depending on the broker.
On shutdown, stop accepting upgrades, send a server_shutdown event, close clients with code 1001, and wait for the close handshake. Force-terminate sockets that do not close within a short deadline. A graceful shutdown that leaves clients guessing produces a reconnect storm against the next instance.
Phase 8: Durable history and ordering
The in-memory ring buffer is a convenience, not a record. If users expect history after a restart or across devices, persist messages. Store the room id, a per-room sequence, the sender id, the text, and a timestamp. The room sequence is assigned inside the database transaction that inserts the message, not by the application before the insert, or two concurrent writers can receive the same sequence.
CREATE TABLE messages (
id text PRIMARY KEY,
room_id text NOT NULL,
sequence bigint NOT NULL,
sender_id text NOT NULL,
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (room_id, sequence)
);
Accept a client-generated clientMessageId and store it with a unique constraint per sender and room. A reconnect may resend a message that was already accepted; the unique constraint makes the retry idempotent. Return the stored message and sequence to the sender so the UI can reconcile optimistic rendering. Do not use the message text as a deduplication key; two legitimate messages can have the same text.
History reads should paginate by sequence, not by offset. WHERE room_id = $1 AND sequence < $2 ORDER BY sequence DESC LIMIT 50 uses the unique index and remains stable while new messages arrive. Loading the entire room into memory works for a prototype and fails for a busy room. If the product allows editing or deleting messages, model those as new events with their own sequence and a reference to the original; do not mutate history in place without an audit trail.
Phase 9: Moderation, abuse, and privacy
A public chat needs abuse controls before launch. Provide a report action that captures the message id, reporter, reason, and timestamp, and route it to a monitored queue. Allow users to block or mute another user, and enforce the block at delivery time so blocked messages never reach the client. Rate limits and message size caps reduce flooding but do not replace moderation.
Do not build a profanity filter as the only safety measure. Word lists are easy to bypass and produce false positives in ordinary conversation. Use them as one signal, keep human review for reports, and document the escalation path for threats or illegal content. If minors may use the product, additional legal and safety requirements apply; do not assume a generic chat design is compliant.
Privacy decisions are part of the data model. Decide how long messages and connection metadata are retained, whether users can delete their messages, and how deletion propagates to caches and search indexes. Avoid logging message bodies in application logs. If messages are encrypted at rest, document who holds the keys and how rotation works. An in-memory room that disappears on restart is effectively ephemeral; say so in the product rather than implying durable history.
Phase 10: Observability and capacity
Instrument connections, rooms, messages accepted, messages rejected by reason, broadcast fan-out, and heartbeat terminations. Track the number of sockets per instance and the rate of new upgrades. A sudden drop in connections with a normal upgrade rate suggests a proxy timeout; a rising rejection rate suggests abuse or a client bug. Separate authentication failures, rate limits, and malformed frames in metrics so alerts are actionable.
Log one structured event per connection open and close with a connection id, user id hash, room id, and close code, but not message text. Trace a sampled message from receipt to broadcast to identify slow serialization or a blocked event loop. Measure the p95 time between message acceptance and the last socket write; a growing value means the fan-out loop or a slow consumer is the bottleneck.
Load-test with real WebSocket clients, not HTTP requests. A small script can open hundreds of connections, join rooms, send at a fixed rate, and record delivery latency. Watch file descriptors, memory, event-loop lag, and bufferedAmount. Set an explicit per-instance connection cap and return a clear close code when it is reached. Capacity is a measured number for a specific message size and fan-out pattern, not a generic claim.
On shutdown, stop accepting upgrades, notify connected clients, close with code 1001, and terminate sockets that do not complete the close handshake within a deadline. Keep the process alive until in-flight database writes commit. A clean drain avoids a reconnect stampede against the next instance and prevents half-written messages.
Security and limitations
Authenticate before the upgrade, authorize room membership, validate every event, cap message size, rate-limit per user, and render text safely. An origin allowlist is not authentication; it is an additional check for cookie-based sessions. Do not trust a userId or displayName sent by the client. Derive identity from the session and store the display name server-side.
The example does not implement moderation, message deletion, durable history, or end-to-end encryption. Those are product features with privacy and compliance implications, not small additions. In-memory rooms are appropriate for a prototype and a single-instance service; a production chat with multiple instances needs a pub/sub layer, durable storage, and a clear retention policy.