A complete pedagogical guide to WebSockets and real-time communication. Each question explains the concept in plain language and pairs it with a working code example.
Code examples use Node.js with the ws library and socket.io, plus browser JavaScript on the client side. To run the server samples:
npm install ws socket.io expressAnswer:
There are four main techniques:
| Technique | How it works |
|---|---|
| Short polling | Client repeatedly asks "anything new?" |
| Long polling | Server holds the request until data is ready |
| Server-Sent Events (SSE) | One-way HTTP stream from server to client |
| WebSocket | Full-duplex TCP connection upgraded from HTTP |
WebSocket is the only one that supports two-way push without re-establishing connections.
Answer:
| Feature | Short polling | Long polling | SSE | WebSocket |
|---|---|---|---|---|
| Direction | Client pull | Client pull (held) | Server push | Both |
| Reconnect logic | Implicit | Implicit | Browser auto | Manual |
| Overhead | High | Medium | Low | Lowest |
| Through proxies | Yes | Yes | Yes | Sometimes |
| Browser support | All | All | All except old IE | All modern |
| Best use | Rare updates | Cheap server push | One-way feeds | Chat, games, live data |
Answer:
When the server pushes and the client mostly listens — notifications, live scores, log tailing. SSE runs over plain HTTP so it works through every proxy, has built-in browser auto-reconnect, and is much simpler to implement.
// SSE server (Express)
app.get('/events', (req, res) => {
res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
setInterval(() => res.write(`data: ${Date.now()}\n\n`), 1000);
});
// Client
const es = new EventSource('/events');
es.onmessage = (e) => console.log(e.data);Answer:
- Updates are rare; polling is simpler.
- Most traffic is one-way; SSE is enough.
- Behind hostile proxies that block long-lived TCP.
- The client can't keep a persistent connection (e.g. SEO crawler, batch tool).
WebSockets bring real complexity: scaling, sticky sessions, reconnection, auth on long-lived connections.
Answer:
A WebSocket starts as an HTTP request with Upgrade: websocket. The client sends a base64 random key; the server responds with a hash of that key plus a magic GUID. After the handshake, the same TCP socket carries WebSocket frames in both directions.
Client request:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Server response:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
After 101, the connection is a WebSocket.
Answer:
A frame has a tiny header (2-14 bytes) and a payload. Key fields:
FINbit: last fragment of a message?Opcode: 0x1 text, 0x2 binary, 0x8 close, 0x9 ping, 0xA pong.Maskbit: client→server frames are always masked.- Payload length: 7-bit, 16-bit, or 64-bit.
You normally never deal with frames directly; the library handles them. But knowing this helps when debugging proxy issues.
Answer:
Control frames carry connection metadata, not data. The three are close, ping, and pong. They're small (under 125 bytes) and never fragmented. You should respond to a ping with a pong immediately.
Answer:
A ping/pong is a heartbeat. The server sends a ping; if the client doesn't reply with a pong within a timeout, the server considers the connection dead. This catches "half-open" connections where TCP doesn't realize one side is gone (e.g. after a wifi drop).
const ws = new WebSocket('ws://localhost:8080');
ws.on('pong', () => { ws.isAlive = true; });
setInterval(() => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
}, 30000);Answer:
Masking prevents cache poisoning attacks against legacy HTTP proxies. Without masking, a malicious script could craft bytes that look like an HTTP request and trick a proxy. Masking randomizes the payload so it can't be confused with HTTP. Server→client frames are not masked.
Answer:
1. Client opens TCP, sends HTTP Upgrade
2. Server replies 101 Switching Protocols
3. Both sides exchange frames
4. Either side sends a Close frame (opcode 0x8)
5. Other side replies with Close
6. TCP socket closes
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws, req) => {
console.log('open from', req.socket.remoteAddress);
ws.on('message', (data) => {
ws.send(`echo: ${data}`);
});
ws.on('close', (code, reason) => {
console.log('closed', code, reason.toString());
});
ws.on('error', (err) => console.error(err));
});// Client
const ws = new WebSocket('ws://localhost:8080');
ws.onopen = () => ws.send('hello');
ws.onmessage = (e) => console.log(e.data);
ws.onclose = (e) => console.log('closed', e.code, e.reason);Answer:
| Code | Meaning |
|---|---|
| 1000 | Normal closure |
| 1001 | Going away (page closed) |
| 1006 | Abnormal closure (no close frame) |
| 1008 | Policy violation |
| 1011 | Server error |
| 4000–4999 | Application-defined |
Use 4xxx codes for app-level reasons like "auth expired" or "rate limited" so your client can react specifically.
Answer:
Use exponential backoff with jitter. Don't reconnect instantly; thousands of clients flooding back at once will crash the server.
let attempt = 0;
function connect() {
const ws = new WebSocket('wss://example.com/ws');
ws.onopen = () => { attempt = 0; };
ws.onclose = () => {
const base = Math.min(1000 * 2 ** attempt, 30000);
const delay = base + Math.random() * 1000; // jitter
attempt++;
setTimeout(connect, delay);
};
ws.onerror = () => ws.close();
}
connect();Answer:
Two strategies:
- Resume by sequence number: server assigns sequence numbers; on reconnect, the client sends "last seen N" and the server replays N+1 onward.
- Out-of-band fetch: on reconnect, fetch missed data via REST and then resume the live stream.
ws.onopen = () => ws.send(JSON.stringify({ type: 'resume', lastSeq }));
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
lastSeq = msg.seq;
handle(msg);
};Server keeps a small ring buffer per session for replay.
Answer:
Half-open means TCP thinks the socket is alive but the other side is gone (laptop closed lid, wifi died). The OS won't tell you for minutes. The fix is application-level heartbeats: server pings every N seconds and closes the socket if no pong arrives.
function heartbeat() { this.isAlive = true; }
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', heartbeat);
});
setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);Answer:
Socket.io is built on top of WebSockets but adds:
- Auto-reconnect with backoff.
- Fallback to long polling when WebSocket isn't available.
- Rooms and namespaces.
- Acknowledgements (request-reply per message).
- Built-in JSON encoding.
- Adapter ecosystem (Redis, MongoDB) for multi-server fan-out.
Trade-off: Socket.io clients can only talk to Socket.io servers; it's not standard WebSocket on the wire.
Answer:
- A namespace is a logical channel like
/chator/admin; each has its own connection event. Use namespaces to split unrelated features. - A room is a subset of clients within a namespace; use rooms for per-conversation broadcast.
const io = require('socket.io')(server);
const chat = io.of('/chat');
chat.on('connection', (socket) => {
socket.on('join', (roomId) => socket.join(roomId));
socket.on('msg', (roomId, text) => chat.to(roomId).emit('msg', text));
});Answer:
Either side can pass a callback function as the last argument to emit. The other side calls it to ack. This gives you request-reply over WebSocket.
// Server
socket.on('save', (data, cb) => {
saveToDb(data);
cb({ ok: true, id: 42 });
});
// Client
socket.emit('save', { name: 'foo' }, (resp) => {
console.log('saved', resp.id);
});Answer:
The handshake is HTTP, so use HTTP auth: cookies, Authorization header, or a query string token.
// Token in query string (simple, OK for short-lived tokens)
const ws = new WebSocket(`wss://api.example.com/ws?token=${jwt}`);
// Server
wss.on('connection', (ws, req) => {
const url = new URL(req.url, 'http://x');
const token = url.searchParams.get('token');
const user = verifyJwt(token);
if (!user) return ws.close(4401, 'unauthorized');
ws.user = user;
});Cookies are nicer because they're set automatically by the browser, but watch out for CSRF if your handshake is on a different origin.
Answer:
Once authenticated, attach the user to the socket and check permissions on every incoming message. Don't trust the client to tell you "I'm user 42" on each message.
ws.on('message', (data) => {
const msg = JSON.parse(data);
if (msg.type === 'send' && !canSendTo(ws.user, msg.toUserId)) {
return ws.close(4403, 'forbidden');
}
});Answer:
Tokens expire, but a WebSocket can stay open for hours. Two approaches:
- Re-auth message: client sends a new token periodically over the socket; server verifies and stores it.
- Force reconnect: server closes the socket near token expiry; client reconnects with a fresh token.
// Client refreshes token every 14 minutes for a 15-minute JWT
setInterval(async () => {
const fresh = await refreshToken();
ws.send(JSON.stringify({ type: 'auth', token: fresh }));
}, 14 * 60 * 1000);Answer:
HTTP is stateless and any server can handle any request. WebSockets are long-lived and stateful — a connection lives on one server. To broadcast a message to user X, you have to know which server X is on. As you add servers, the fan-out problem grows.
Answer:
Use a pub/sub backplane. Each server subscribes to a channel; when one server wants to broadcast, it publishes to the channel and every server delivers to its connected users.
[server 1] - [server 2] - [server 3]
\ | /
\ | /
Redis Pub/Sub channel
// Each WS server
const sub = redis.duplicate();
const pub = redis;
await sub.subscribe('broadcast', (msg) => {
wss.clients.forEach((c) => c.send(msg));
});
// To broadcast from any server:
pub.publish('broadcast', JSON.stringify({ type: 'announce' }));For Socket.io, the socket.io-redis-adapter does this automatically.
Answer:
| Need | Choice |
|---|---|
| Low latency, fire-and-forget | Redis pub/sub |
| Replay missed events | Kafka |
| Strict durability | Kafka |
| Millions of msgs/sec | Kafka |
| Simple, in-memory only | Redis |
Redis pub/sub drops messages if a subscriber disconnects briefly. Kafka retains and lets the subscriber resume.
Answer:
It depends on memory per connection and message rate. A bare Node.js ws server can hold 50,000–100,000 idle connections per process on a normal VM. With heavy message rates, expect 5,000–20,000.
Limits to watch:
- File descriptors (
ulimit -n). - Ephemeral port range on the OS.
- Per-connection memory and timers.
Answer:
Sticky. A WebSocket lives on one server; the load balancer must route follow-up frames (and reconnects from the same client) to the same server. ALBs and most proxies support sticky sessions via cookies or source IP. With a pub/sub backplane you can still survive losing a server because reconnect lands on a different one.
Answer:
When a server is shutting down:
- Stop accepting new connections.
- Send each client a "please reconnect" message (with a 4xxx close code).
- Wait a few seconds with backoff so reconnects spread across remaining servers.
- Close any stragglers and exit.
process.on('SIGTERM', async () => {
server.close(); // stop accepting new connections
wss.clients.forEach((ws) => ws.close(4001, 'server-shutdown'));
await new Promise((r) => setTimeout(r, 5000));
process.exit(0);
});Answer:
Nginx needs explicit Upgrade headers and longer timeouts because WebSockets are long-lived.
location /ws {
proxy_pass http://app:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}Without Upgrade and Connection: upgrade, Nginx will treat the request as plain HTTP and the handshake will fail.
Answer:
- ALB: layer 7. Speaks HTTP and WebSocket. Supports cookie-based sticky sessions, path-based routing, and TLS termination. Default choice for most teams.
- NLB: layer 4. Pure TCP. Lower latency, higher throughput, no TLS termination unless you use TLS listeners. Use when you need extreme performance or non-HTTP protocols.
For 99% of WebSocket workloads, ALB is fine.
Answer:
CDNs traditionally don't accept WebSockets, but Cloudflare and similar now do. The flow is:
Client -> CDN (TLS termination, DDoS) -> ALB -> WS server -> Redis
Watch out: not every CDN PoP supports WebSocket; check your provider. Idle timeouts are usually 100s, so make heartbeats shorter than that.
Answer:
The WebSocket protocol is not subject to the browser's same-origin policy in the same way HTTP is. The browser sends an Origin header during handshake; the server should validate it.
wss.on('connection', (ws, req) => {
const origin = req.headers.origin;
if (!ALLOWED.includes(origin)) return ws.close(4403, 'bad origin');
});Without this check, any site could open a WebSocket to your server using the visiting user's cookies (cross-site WebSocket hijacking).
Answer:
- Use an ingress controller that supports WebSocket: NGINX Ingress, Traefik, Istio.
- Set long timeouts (
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"). - Enable session affinity if your fan-out depends on it; otherwise, use a Redis backplane and let any pod handle any client.
- Use
terminationGracePeriodSecondslong enough for connection draining.
metadata:
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
terminationGracePeriodSeconds: 60Answer:
Memory is usually the limiting factor: each connection needs buffers (~50KB-200KB) plus app state. CPU spikes happen during reconnect storms. Plan for capacity based on:
- Peak concurrent connections.
- Heartbeat overhead.
- Message rate per connection.
Run more, smaller pods to spread blast radius and reconnect load.
Answer:
[client] --WS--> [edge WS server] --pub--> [Redis pub/sub] --sub--> [other WS servers]
|
v
[Postgres / Cassandra messages table]
- WebSocket servers terminate connections.
- Each message is persisted before fan-out.
- Redis (or Kafka) broadcasts to all WS servers so every recipient gets the message regardless of which server they're on.
- On reconnect, the client fetches missed messages via REST.
socket.on('message', async ({ roomId, text }) => {
const msg = await db.insertMessage(roomId, text, socket.user.id);
await redis.publish(`room:${roomId}`, JSON.stringify(msg));
});Answer:
Server pushes metrics every N seconds. Clients subscribe to specific dashboards.
// Server-side periodic push
setInterval(() => {
for (const ws of wss.clients) {
if (ws.dashboard) ws.send(JSON.stringify(getStats(ws.dashboard)));
}
}, 1000);Use SSE if updates are server-to-client only; WebSocket if the client also sends commands (zoom, filter, drill-down).
Answer:
A user has a long-lived WS to receive notifications. The notification service publishes to a Redis channel keyed by user id; the WS server forwards to that user's socket.
sub.subscribe(`user:${ws.user.id}`, (msg) => ws.send(msg));For users who are offline, store the notification in the database and push a badge count on next connect.
Answer:
Two algorithms power this:
- Operational Transformation (OT): each edit is transformed against concurrent edits.
- CRDTs (Conflict-free Replicated Data Types): edits commute by construction; no central server needed.
WebSocket carries operations between clients. The server applies them in order and broadcasts to other clients in the same document.
socket.on('op', (op) => {
const transformed = doc.apply(op);
socket.to(doc.id).emit('op', transformed);
});CRDTs (e.g. Yjs, Automerge) are easier today and work peer-to-peer.
Answer:
A game server simulates the world tick by tick (e.g. 30 Hz) and sends snapshots/deltas to clients. Clients send input. Latency matters more than throughput. Often UDP via WebRTC is preferred over WebSocket for sub-50ms gameplay.
For turn-based games or simpler real-time games, WebSocket is fine.
Answer:
Backpressure is when a producer sends faster than a consumer can read. On a WebSocket, the server may try to send 1000 msg/s while the client's network can only handle 100. The OS buffers fill, then the WebSocket library buffers fill, then memory is exhausted.
Detect with ws.bufferedAmount:
if (ws.bufferedAmount > 1_000_000) {
// 1 MB queued — drop this update or close
ws.close(4008, 'slow consumer');
return;
}
ws.send(payload);Strategies: drop old updates, coalesce updates, slow the source, or kick the slow client.
Answer:
Apply token-bucket per connection. Don't trust the client.
ws.tokens = 10;
setInterval(() => { ws.tokens = Math.min(10, ws.tokens + 1); }, 100);
ws.on('message', (data) => {
if (ws.tokens-- <= 0) return ws.close(4009, 'rate-limited');
handle(data);
});Answer:
- Cross-site WebSocket hijacking: a malicious site opens a WS to your server using the user's cookies. Validate
Originand use CSRF-style tokens. - Lack of origin checks: see above.
- Insufficient auth on messages: don't trust client claims.
- DoS via slow clients or many connections: rate-limit, cap connections per IP.
- Plaintext (
ws://): always usewss://.
Answer:
WebSocket per-message-deflate compresses each frame. It saves bandwidth for text but uses CPU. Risks:
- Slightly amplifies CPU attacks.
- Some libraries have known bugs (memory leaks).
Enable for chat / large JSON; disable for binary or small frequent messages. Always set a serverMaxWindowBits and limit memory.
Answer:
| Metric | Why |
|---|---|
| Concurrent connections | Capacity planning |
| Connect / disconnect rate | Reconnect storms |
| Messages/sec in & out | Throughput |
| Avg & p99 message latency | User experience |
| Buffered bytes (backpressure) | Slow consumers |
| Heartbeat failures | Half-open detection |
| Memory per connection | Tune capacity |
Answer:
- Check the handshake: open browser dev tools → Network → WS tab.
- Look at close codes; 1006 means "abnormal" (network/proxy issue).
- Verify Nginx / ALB has Upgrade headers and long timeouts.
- Confirm heartbeats are flowing.
- Use
wscatorwebsocatto test from command line.
wscat -c wss://example.com/ws -H "Authorization: Bearer $TOKEN"Answer:
| WebSocket | WebRTC | |
|---|---|---|
| Topology | Client-server | Peer-to-peer |
| Transport | TCP | UDP-based (with TCP fallback) |
| Use cases | Chat, dashboards | Video, voice, low-latency games |
| Latency | Tens of ms | Low ms, with packet loss tolerance |
| Setup | Trivial | Complex (signaling, ICE, STUN/TURN) |
WebRTC also needs a signaling channel — and that signaling channel is usually WebSocket.
Answer:
| Use case | Choice |
|---|---|
| Browser-to-server real-time | WebSocket |
| Server pushes only | SSE |
| Rare updates | Polling |
| Voice / video / P2P | WebRTC |
| Real-time game state | WebSocket or WebRTC depending on latency |
Answer:
Without app-level heartbeats, a client that lost its network can stay "connected" on the server for minutes or hours. Memory accumulates and messages are sent into the void. Always implement ping/pong with a timeout, both client and server.
Answer:
If you restart a server, all clients reconnect at once. With 50k clients and naive instant reconnect, the surviving servers may collapse. Mitigate with:
- Random jitter on reconnect backoff.
- Drain connections gradually (Q26).
- Rolling restart instead of all-at-once.
Answer:
Storing per-room state only in the WS server's memory means a server restart loses it and broadcasts can't reach users on other servers. Use Redis or another shared store for state, and pub/sub for fan-out.
Answer:
Socket.io's polling fallback is for legacy browsers. On modern browsers, force WebSocket-only to avoid the polling overhead and quirks.
const io = require('socket.io')(server, { transports: ['websocket'] });Answer:
WebSocket is just a transport. If you build a complex app where many services need to fan out events to many users, don't bolt the pub/sub on the WebSocket protocol; put it in Redis or Kafka behind your WebSocket layer.
Answer:
Treat each incoming message as untrusted input even after auth. Validate types, sizes, and permissions. A logged-in user can still be malicious.
Answer:
- Default to WebSocket only when you actually need bi-directional push.
- Always heartbeat and auto-reconnect with jitter.
- Keep state in a shared store; never pin to one instance.
- Validate
Origin, usewss://, rate-limit per connection. - Plan for reconnect storms and slow consumers from day one.
- Monitor connection counts and buffered bytes, not just CPU.