Warming up the neural circuits...
By the end of this chapter you will:
HTTP is a postman who rings the bell, hands over one package, and leaves. WebSockets are a phone call — both sides talk until someone hangs up.
Walk into a busy restaurant. The host hands you a pager — a small disk that buzzes when your table is ready. That's HTTP polling. Every 30 seconds, you walk up to the host and ask "Is my table ready?" The host says "Not yet," you sit back down. 90% of your requests are wasted. The pager (WebSocket) flips this: the restaurant tells YOU when the table is ready. One message, exactly when it matters.
HTTP was built for documents — request a page, get a page, connection over. But modern apps are conversations: chat messages, stock tickers, live dashboards, collaborative editors, gaming . In these apps, the server needs to PUSH data to the client without the client asking. WebSockets give you a full-duplex TCP tunnel over a single connection, initiated by one HTTP upgrade, then running until either side closes it. You'll learn the protocol internals, the two main library choices (ws vs Socket.IO), reconnection strategies that survive flaky mobile networks, and how to scale beyond a single server using Redis pub/sub.
A WebSocket connection starts as an HTTP request — specifically, an upgrade request. The client asks the server to switch protocols.
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13The server responds with a 101 Switching Protocols:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=That Sec-WebSocket-Accept value isn't random. The server computes it as:
BASE64(SHA1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))This "magic string" (defined in RFC 6455) prevents a WebSocket connection from accidentally succeeding against a non-WebSocket server. A regular HTTP server won't know this string, so it won't respond with the correct Sec-WebSocket-Accept — the browser rejects the connection.
After the 101 response, the TCP socket is no longer HTTP. It's a raw frame-based protocol where either side can send messages at any time.
It's a security measure from RFC 6455 §4.2.2. Without it, a misconfigured caching proxy might respond to a WebSocket upgrade with a cached HTTP response, confusing the client. The magic string guarantees the server explicitly opted into WebSockets.
The ws library (20M+ weekly downloads) implements the WebSocket protocol with zero abstractions. It gives you a WebSocketServer and WebSocket objects that emit events. That's it — no rooms, no namespaces, no auto-reconnect, no fallback to HTTP long-polling.
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
const server = createServer();
const wss = new WebSocketServer({ server }
TCP connections can die silently. A mobile user switches from WiFi to 4G — the server thinks the connection is alive, but packets are going nowhere. Without heartbeats, you accumulate "zombie" connections that consume memory and file descriptors.
WebSockets have built-in ping/pong frames at the protocol level. The ws library supports them:
const HEARTBEAT_INTERVAL = 30_000; // 30 seconds
wss.on('connection', (ws) => {
// Mark this connection as alive
(ws as any).isAlive = true;
WebSocket ping/pong frames are handled by the browser automatically, but some proxies (especially corporate firewalls) swallow control frames. If your users report frequent disconnections behind corporate networks, add an application-level heartbeat: send { type: 'ping' } as a regular message every 30 seconds, and have the client respond with { type: 'pong' }.
Clients disconnect. WiFi drops, laptops close, phones switch towers. The frontend must reconnect gracefully. Here's the production pattern:
class ReconnectingWebSocket {
constructor(url, maxRetries = Infinity) {
this.url = url;
this.maxRetries = maxRetries;
this.retryCount = 0;
this
Without jitter, every client retries at exactly 1s, 2s, 4s, 8s... If 10,000 clients disconnect simultaneously (server restart, blip), they all reconnect at the same millisecond. Jitter spreads them across a range, preventing a "thundering herd" that could overwhelm the server on restart.
Socket.IO is the most popular WebSocket library (60M+ weekly downloads). It provides:
/chat, /notifications)import { Server } from 'socket.io';
const io = new Server(3000, {
pingInterval: 25_000, // Send ping every 25s
pingTimeout: 20_000, // Disconnect if no pong in 20s
connectTimeout: 10_000, //
Socket.IO is a custom protocol on top of WebSocket (or long-polling). A raw new WebSocket('ws://...') cannot connect to a Socket.IO server. If your needs third-party clients (mobile apps, embedded devices, other backend services), use raw ws. If your only client is a browser and you want rooms, auto-reconnect, and fallback — Socket.IO saves weeks of work.
A single Node.js process can handle ~10,000 concurrent WebSocket connections comfortably (memory is the bottleneck, typically ~20-50KB per connection). Beyond that, you need multiple server instances. But WebSockets are stateful — if client A is connected to server 1 and client B to server 2, how do they exchange messages?
Solution: Redis Pub/Sub as a message bus.
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
import { Server } from 'socket.io';
const pubClient = createClient({ url: process.
When io.to('room-42').emit(...) fires on Server A, the Redis adapter publishes the event to a Redis channel. Server B (and C, D...) is subscribed and picks it up, forwarding to its local clients in room-42.
The sticky session requirement: If you use HTTP long-polling as a fallback (Socket.IO's default), the load balancer must route the same client to the same server (sticky sessions). HTTP long-polling requires multiple requests to the same process. With WebSocket-only (ws library), sticky sessions aren't required — the WebSocket connection itself stays pinned to one server after the upgrade.
If your app only needs server → client push (live scores, notification feed, build status), SSE is simpler than WebSockets. SSE uses regular HTTP with text/event-stream content type, works through all proxies, and auto-reconnects natively in browsers.
import { Request, Response } from 'express';
app.get('/events', (req: Request, res: Response) => {
res.writeHead(200,
| Feature | WebSocket | SSE | HTTP Polling |
|---|---|---|---|
| Direction | Bidirectional | Server → Client | Client → Server |
| Protocol | ws:// (upgrade from HTTP) | HTTP | HTTP |
| Binary data | Yes | No (text only) | Yes |
| Auto-reconnect | Manual (or Socket.IO) | Browser built-in | N/A |
| Max connections (single server) | ~10K-50K | ~10K-50K | N/A (stateless) |
| Proxy friendliness | Can be blocked | Always works | Always works |
Slack's real-time messaging infrastructure handles millions of concurrent WebSocket connections across hundreds of servers. Their architecture is one of the most studied in the industry.
Connection flow: When you open Slack in a browser, the client calls rtm.connect (legacy) or events-api (modern) to get a WebSocket URL. The URL contains a short-lived token that authenticates the connection. The client opens a WebSocket to that URL — typically routed to the geographically closest Slack edge server.
Message delivery: When User A sends a message in #general, Slack's message server (written in Java/Go) processes it: validates, persists to their Vitess (MySQL) cluster, resolves mentions and channel memberships, then publishes to the channel's pub/sub topic. Every edge server subscribed to that channel receives the event and forwards it to locally connected WebSocket clients. User B (connected to a different edge server) receives the message with typical latency under 100ms.
Key architectural decisions from Slack:
Stateless WebSocket servers. The WebSocket gateway servers hold NO business logic. They're thin proxies: accept connections, relay messages between clients and the backend pub/sub system. This means you can kill any WebSocket server without losing data — the client reconnects to another and misses nothing (events are buffered on the backend).
Connection draining. When Slack needs to restart a WebSocket server (deployment, maintenance), they send a GOAWAY frame. The server stops accepting new connections, waits for existing clients to reconnect elsewhere (clients receive a reconnect_url in the close frame), then shuts down. Zero message loss.
Presence at scale. "Online/away/offline" status is NOT broadcast on every change. Instead, clients subscribe to presence for users they care about (sidebar users, current channel members). Presence updates are batched and sent at most once every few seconds. This prevents the N² problem: 1000 users × 1000 subscribers = 1M presence messages per status change.
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| No heartbeat/keepalive | Zombie connections accumulate; server runs out of file descriptors | Implement ping/pong every 30s; terminate unresponsive clients |
| Broadcasting to all clients on one server | Doesn't scale past one process; messages lost across instances | Use Redis adapter or a dedicated message broker (NATS, Kafka) |
| Socket.IO without sticky sessions | HTTP long-polling fallback fails when load balancer routes to different server | Enable sticky sessions in Nginx/HAProxy, or disable transport fallback (WebSocket only) |
| No reconnection logic on the client | One network blip = permanently disconnected; user sees stale UI | Exponential backoff with jitter; show reconnecting UI banner |
| Sending sensitive data without auth check | ws.on('connection') fires before you check the token — attacker gets one free message | Validate auth token during upgrade event (HTTP phase), reject before switching protocols |
| Using WebSocket for request-response | WebSocket is for push; request-response wastes an open connection | Use regular HTTP fetch for one-off requests; reserve WebSocket for server-initiated data |
| No backpressure handling | Client can't keep up with message rate; server buffers grow until OOM | Check ws.bufferedAmount before sending; drop or throttle messages for slow clients |
ws defaults to 100MB. Set maxPayload: 1048576 (1MB) for a chat server — a malicious client shouldn't be able to allocate 100MB of server memory with one message.1001 Going Away), wait 5 seconds for acknowledgments, then force-terminate. This prevents clients from sitting in a broken state until their next heartbeat timeout.INCR + EXPIRE).ws_connections_active, ws_messages_received_total, ws_messages_sent_total, ws_errors_total. Set alerts on connection spikes and error rate > 1%.channel field in messages).ws supports perMessageDeflate. It saves bandwidth (~70% for JSON) but costs CPU. Enable only if bandwidth is your bottleneck, not CPU.JSON.stringify per client.bufferedAmount — Before sending, check ws.bufferedAmount. If it exceeds a threshold (e.g., 16KB), the client can't keep up. Skip non-critical messages (typing indicators) and only send essential data (actual messages).Origin header. Reject connections from unknown origins: if (req.headers.origin !== 'https://myapp.com') { ws.close(4001, 'Unauthorized'); return; }upgrade event in Node's HTTP server fires before the protocol switch).password, credit_card, or internal IDs that expose counting patterns in WebSocket payloads.wss:// (WebSocket Secure). ws:// on a public network is plaintext — anyone between client and server can read (and inject) messages.Build a basic echo server. Create a WebSocket server with ws that accepts connections and sends back any message it receives, prefixed with Echo: . Test it with a browser WebSocket client and wscat.
Add heartbeats. Take the echo server and add ping/pong heartbeat detection. Log when a client is terminated for being unresponsive. Test by killing a client process without sending a close frame.
Build a real-time chat with rooms. Using Socket.IO, create a chat server with room support. Users can create rooms, join rooms, and send messages scoped to a room. Implement typing indicators (typing-start/typing-stop events) that broadcast to the room.
Horizontal scaling with Redis. Take the chat server and scale it to two Node.js processes. Configure the Redis adapter so messages from a client on server A reach clients on server B. Verify by connecting browser tabs to different servers.
Build a presence system. Implement an online/offline presence system that tracks which users are in which rooms. Handle edge cases: what happens when a user disconnects without a close frame? How do you prevent presence "flickering" during brief reconnections? Use a grace period (5 seconds) before broadcasting "offline."
Reconnection with message recovery. Clients reconnect with a lastEventId. The server, backed by Redis streams, replays missed messages since that ID. Build this without Socket.IO (use raw ws and Redis streams). This is how Slack and WhatsApp Web handle message delivery after disconnection.
Q1: How does a WebSocket connection start?
Answer: It starts as an HTTP 1.1 request with Upgrade: websocket and Connection: Upgrade headers. The server responds with 101 Switching Protocols. After this handshake, the TCP connection becomes a WebSocket — a full-duplex, frame-based protocol. No more HTTP headers; both sides can send messages at any time.
Q2: What does ws.ping() do and why does it matter?
Answer: It sends a WebSocket ping control frame. The receiving end's WebSocket implementation automatically responds with a pong frame. It matters because TCP connections can die silently (network changes, firewall timeouts). Without pings, the server holds dead connections indefinitely, consuming memory and file descriptors. A missing pong response after a timeout triggers connection termination.
Q3: What's the difference between ws and Socket.IO?
Answer: ws implements the WebSocket protocol (RFC 6455) exactly — raw WebSocket frames, no abstractions. Socket.IO is a higher-level library that adds rooms, namespaces, auto-reconnection, acknowledgments, and fallback to HTTP long-polling. Socket.IO's protocol is custom — a raw WebSocket client cannot talk to a Socket.IO server. Choose ws for interoperability and minimal overhead; choose Socket.IO for browser-only apps that need the features.
Q4: You have 50,000 concurrent WebSocket connections. The server's memory usage is 2GB. Walk through your diagnosis.
Answer: I'd check per-connection memory: 2GB / 50K = ~40KB per connection — that's in the normal range. First, check if there are zombie connections: count connections vs active heartbeats. Then profile the heap: is it message buffering (backpressure from slow clients), Socket.IO's internal data structures (rooms, namespaces), or application-level state accumulated per socket? I'd look at ws.bufferedAmount or Socket.IO's socket.rooms. If it's legitimate usage, I'd set --max-old-space-size appropriately, add horizontal scaling with Redis pub/sub, or offload connection state to Redis (stateless WebSocket gateways). If it's zombies, fix the heartbeat configuration.
Q5: How do you handle WebSocket connections during a rolling deployment?
Answer: Before a server shuts down, it enters "drain mode": (1) Stop accepting new connections — the load balancer should detect this via health check. (2) Send a close frame to all existing clients with a reconnect_url pointing to the new deployment. (3) Wait for clients to reconnect elsewhere (typically 5-10 seconds). (4) Force-terminate remaining connections and shut down. The client-side reconnection logic (exponential backoff) ensures smooth transition. Bonus: buffer incoming messages during the drain window so they're not lost.
Q6: Slack uses "stateless WebSocket gateways." What does that mean and why does it matter?
Answer: It means the WebSocket server holds NO business data — it's purely a relay between clients and a backend message bus (pub/sub). When a user sends a chat message, the gateway forwards it to the backend (which persists and processes it), then the backend publishes the result to a channel that all relevant gateways subscribe to. The benefit: any gateway server can die or be replaced without data loss. A client just reconnects to another gateway and picks up where it left off. This is significantly simpler than stateful WebSocket servers where each server must synchronize state with peers.
WebSockets transform the web from a request-response medium into a real-time communication channel. The handshake upgrades HTTP to a raw TCP tunnel, after which both sides speak freely. ws gives you protocol-level control; Socket.IO gives you rooms, reconnection, and fallback at the cost of interoperability. Production systems need heartbeats to detect dead connections, exponential backoff with jitter for reconnection, and Redis pub/sub to scale beyond one server. SSE offers a simpler alternative when you only need server-to-client push. Slack's architecture shows the gold standard: stateless gateways, connection draining, and presence batching. Choose raw WebSockets when third-party clients need to connect; choose Socket.IO when your browser app needs the full feature set yesterday.
Upgrade: websocket → server responds 101 Switching Protocols → raw TCP tunnelSec-WebSocket-Accept = BASE64(SHA1(client-key + magic GUID)) — prevents accidental upgradews library: protocol-level, no rooms, no auto-reconnect, interoperable with any WebSocket clientping() every 30s, terminate on missing pong — prevents zombie connectionstext/event-stream, server → client only, auto-reconnect built into browsers, works through all proxiesWhat HTTP status code does the server return to complete a WebSocket upgrade? Answer: 101 (Switching Protocols).
What is the purpose of the magic string 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 in the WebSocket handshake?
Answer: It's concatenated with the client's Sec-WebSocket-Key before SHA-1 hashing, ensuring the server explicitly opted into WebSockets — a non-WebSocket server won't compute the correct Sec-WebSocket-Accept value.
Why add jitter to reconnection delays? Answer: To spread simultaneous reconnection attempts across a time range, preventing a thundering herd that could overwhelm the server after a mass disconnect.
Can a raw new WebSocket('ws://...') connect to a Socket.IO server?
Answer: No — Socket.IO uses a custom protocol on top of WebSocket. Raw WebSocket clients get connection errors.
What does the Redis pub/sub adapter do in a scaled Socket.IO deployment? Answer: It forwards events between server instances. When a message is emitted in a room on Server A, Redis publishes it; Servers B, C, D pick it up and relay to their local clients in that room.
What's the key advantage of SSE over WebSockets for server-to-client-only use cases? Answer: SSE uses regular HTTP (no upgrade), works through all proxies without configuration, and browsers provide built-in auto-reconnection.
You're building a stock ticker with 10,000 price updates per second. Should you use JSON messages? Answer: No — JSON.parse at that rate is expensive. Use binary WebSocket frames with MessagePack or Protocol Buffers for 3-5x faster deserialization.