| name | websocket-patterns |
| description | Design and implement WebSocket connections — connection lifecycle, message protocol, authentication, reconnection strategy, backpressure, and horizontal scaling with a pub/sub broker. Use when asked to "add WebSocket", "real-time updates", "live feed", "Socket.IO", "ws library", "reconnect logic", "backpressure", "scale WebSocket", or "pub/sub over WebSocket". Do NOT use for: one-way server push with no client messages — use SSE instead. Do NOT use for: request/response APIs where HTTP polling is acceptable.
|
| origin | yamtam-original |
| license | MIT © 2026 Vũ Văn Tâm |
| version | 1.0.0 |
| compatibility | ws ≥ 8.x, Socket.IO ≥ 4.x, Node.js ≥ 18. Patterns apply to any WS server. |
When to Use
- Use when: client needs real-time push (chat, live dashboard, collaborative editing)
- Use when: bidirectional messaging — client sends events, server responds async
- Use when: latency matters and HTTP polling overhead is unacceptable
- Do NOT use for: server-only push (notifications, progress bars) — SSE is simpler
- Do NOT use for: infrequent updates (< 1/min) — polling or long-polling is fine
Connection Lifecycle
connect → HTTP Upgrade 101 → auth frame → auth_ack
→ messages ↔ heartbeat (30s ping/pong) → close frame → TCP close
Authenticate on the first message — tokens in query params appear in server logs.
Message Protocol
Define a typed envelope — never send raw strings or untyped JSON.
type WSMessage =
| { type: 'subscribe'; channel: string }
| { type: 'unsubscribe'; channel: string }
| { type: 'publish'; channel: string; payload: unknown }
| { type: 'ack'; id: string }
| { type: 'error'; code: string; message: string };
ws.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw); } catch { return ws.close(1003, 'invalid json'); }
if (!msg.type) return send(ws, { type: 'error', code: 'MISSING_TYPE' });
switch (msg.type) {
case 'subscribe': return handleSubscribe(ws, msg.channel);
case 'unsubscribe': return handleUnsubscribe(ws, msg.channel);
default: return send(ws, { type: 'error', code: 'UNKNOWN_TYPE' });
}
});
const send = (ws, msg) => ws.readyState === ws.OPEN && ws.send(JSON.stringify(msg));
Authentication
ws.once('message', async (raw) => {
const { type, token } = JSON.parse(raw);
if (type !== 'auth') return ws.close(4001, 'auth required');
const user = await verifyJWT(token);
if (!user) return ws.close(4003, 'forbidden');
ws.user = user;
send(ws, { type: 'auth_ack', userId: user.id });
ws.on('message', handleMessage);
});
Close codes 4000–4999 are application-defined — use them for auth errors.
Heartbeat (Ping/Pong)
const HEARTBEAT_INTERVAL = 30_000;
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
});
setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, HEARTBEAT_INTERVAL);
Client Reconnection
function connect(url) {
let ws, attempt = 0;
function open() {
ws = new WebSocket(url);
ws.onopen = () => { attempt = 0; authenticate(ws); };
ws.onclose = () => {
const delay = Math.min(1000 * 2 ** attempt, 30_000);
const jitter = Math.random() * 1000;
attempt++;
setTimeout(open, delay + jitter);
};
ws.onmessage = handleMessage;
}
open();
return () => { ws?.close(); };
}
Exponential backoff with jitter prevents thundering herd when a server restarts.
Backpressure
function safeSend(ws, msg) {
if (ws.bufferedAmount > 64 * 1024) {
metrics.increment('ws.message.dropped');
return;
}
send(ws, msg);
}
For high-frequency feeds (price ticks, cursor positions): send latest state only, not every delta — use setImmediate to coalesce updates within a tick.
Horizontal Scaling
Single-server WebSocket state breaks with > 1 instance: client connects to pod A, event fires on pod B — B can't reach the client.
Fix: pub/sub broker between pods (Redis, NATS, Kafka).
sub.subscribe(`user:${userId}`);
sub.on('message', (channel, msg) => {
const ws = connections.get(parseUserId(channel));
ws && send(ws, JSON.parse(msg));
});
pub.publish(`user:${userId}`, JSON.stringify(event));
Socket.IO has a Redis adapter built in — use it instead of rolling your own.
Common Pitfalls
| Mistake | Fix |
|---|
| Auth token in query string | Send token in first message after connect |
| No heartbeat → ghost connections | Server-side ping/pong + terminate() on timeout |
| No reconnect logic on client | Exponential backoff + jitter |
| Broadcasting to all clients on one pod | Redis pub/sub adapter |
| Sending to closed socket | Check ws.readyState === ws.OPEN before send |
| No message size limit | Set maxPayload on the server (default 100 MB in ws!) |
const wss = new WebSocketServer({ port: 8080, maxPayload: 64 * 1024 });
Anti-Fake-Pass Rules
Before claiming WebSocket implementation is done, you MUST show:
Reference: gates/anti-fake-pass-gate.md