WebSockets fundamentals: real-world lessons and examples. What happens under the hood.
The upgrade handshake — An HTTP request with Upgrade: websocket switches protocols
Every WebSocket connection starts as an ordinary HTTP request. The client sends a special set of headers asking the server to upgrade the connection from HTTP to the WebSocket protocol. If the server agrees, both sides switch protocols on the same TCP connection. This handshake is crucial because it lets existing infrastructure — proxies, load balancers, firewalls — handle the initial negotiation using familiar HTTP rules.
Here's what a real upgrade request looks like:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
The Sec-WebSocket-Key header contains a random base64-encoded value. The server must compute a hash of this key combined with a magic string, then send it back. This prevents cache poisoning and accidental upgrades. The client validates the response key before treating the connection as a WebSocket.
The server's response looks like this:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Status code 101 signals success. From this point forward, the TCP connection carries WebSocket frames instead of HTTP messages. Many developers overlook this handshake, but understanding it helps you debug connection failures, work with proxies that strip headers, and recognize when upgrades fail silently.
Full duplex — Both sides send messages anytime on one TCP connection
Once the handshake completes, you have a single TCP connection where client and server can send messages to each other independently and asynchronously. This is fundamentally different from HTTP, where one side sends a request and waits for a response. With WebSockets, both sides can send data whenever they want, and messages don't need to be paired as request-response pairs.
In a real-time chat application, imagine two users connected to the same server. User A sends a message while User B sends a heartbeat ping. These two messages flow on separate logical channels within the same TCP stream. The server receives both, processes them out of order if network conditions vary, and can reply to each independently.
Full duplex means you avoid head-of-line blocking. With HTTP polling, if you poll for chat messages every 500ms and the request takes 2 seconds due to network congestion, you're blocked waiting for that one response. With WebSockets, the server pushes updates the instant they arrive, and your client can send new messages while receiving old ones.
This is also why connection state matters. A WebSocket connection lives for minutes, hours, or until one side closes it. That long-lived state lets you maintain server-side buffers, user presence information, and partial message sequences. When you scale horizontally, you must route reconnecting clients back to the same server or sync state across replicas — we'll touch on that in the stateful connections section.
Frames — Text, binary, ping, pong and close frames
WebSocket messages are wrapped in frames. Each frame has a header that specifies the frame type, payload length, and masking information. The frame types you'll encounter most often are text, binary, ping, pong, and close.
Text frames carry UTF-8 encoded strings. This is the default for most applications. If you send JSON over a WebSocket, it arrives wrapped in a text frame.
Binary frames carry raw bytes. Use these for serialized data structures, compressed payloads, or anything that isn't plain text. Binary frames are often more efficient than base64-encoding binary data and sending it as text.
Ping and pong frames are control frames for keep-alive. The client or server sends a ping, expecting a pong in response. If no pong arrives within a timeout, the connection is dead — either the peer crashed or the network dropped. Most frameworks handle ping/pong automatically, but you should understand that every few seconds your framework might exchange these invisible heartbeat frames.
Close frames signal that one side wants to close the connection. They include a status code and optional reason text. Status 1000 means normal closure, 1001 means the peer is going away, 1011 means the server hit an error. After sending or receiving a close frame, the TCP connection closes gracefully.
Here's a minimal Node.js example using the ws library that demonstrates frame types:
import WebSocket from 'ws';
const server = new WebSocket.Server({ port: 8080 });
server.on('connection', (ws) => {
// Incoming text frame
ws.on('message', (data, isBinary) => {
if (!isBinary) {
console.log('Text:', data.toString());
// Echo it back as a text frame
ws.send(JSON.stringify({ echo: data }));
} else {
console.log('Binary:', data);
// Echo it back as a binary frame
ws.send(data);
}
});
// Ping received (pong is automatic)
ws.on('ping', (data) => {
console.log('Ping received:', data);
// Framework usually auto-responds with pong
});
// Close frame received
ws.on('close', (code, reason) => {
console.log(`Closed with code ${code}: ${reason}`);
});
// Send a text frame to the client
ws.send('Welcome!');
});
Each frame has overhead — a 2-byte header minimum — so batching related messages into a single frame is more efficient than sending many small frames. If you're streaming sensor data at 1000 Hz, bundle 10 readings into one frame instead of sending 10 separate frames.
Stateful connections — Unlike HTTP, connections live for minutes or hours — that changes how you scale
HTTP is stateless by design. Each request from a client could go to any server in your pool. With WebSockets, a specific client's connection lands on a specific server instance, and that connection might live for an hour. If that server crashes, the connection dies. If you deploy a new version and restart servers, all connected clients drop.
This changes your deployment, load balancing, and error recovery strategy. Here are the main scenarios:
- Sticky sessions: Your load balancer must route reconnecting clients to the same server. Use session affinity based on IP or a session ID. If that server is gone, the client reconnects to a different one, but you lose the connection state.
- Graceful shutdown: When restarting a server, don't kill TCP connections immediately. Send close frames to all connected clients, giving them a chance to reconnect to another server before killing the process.
- State replication: If you need to handle mid-connection failures, replicate connection metadata and message queues to another server or a cache like Redis. When a client reconnects, the new server knows about the old connection and can resume it.
- Horizontal scaling: Each server instance holds a certain number of open connections. You'll run into OS limits (typically 10k-100k connections per server depending on OS tuning). Plan your server count based on expected concurrent connections, not request throughput.
Consider a real-time notification system. A user receives 100 notifications per day. With HTTP polling, you'd check every second or every 30 seconds. With WebSockets, the server pushes each notification the instant it's generated. But now that user's connection is pinned to one server. If you have 1 million concurrent users and each server can handle 50k connections, you need 20 server instances. Scaling is now about connection count, not message throughput.
In a Kafka-backed system, you might have a WebSocket server pool where each instance subscribes to certain Kafka topics. When a message arrives on Kafka, the instance pushes it to all connected clients subscribed to that topic. If an instance crashes, clients reconnect to another instance and re-subscribe. The Kafka offset is persistent, so the new instance knows where to resume.
Common mistakes
- Ignoring keep-alive: Networks drop idle connections after 30–90 seconds. If your application doesn't exchange ping/pong frames, the connection dies silently. Set a ping interval of 20–30 seconds to keep it alive.
- Not handling reconnection: Clients crash, networks fail, proxies timeout. Your client code must detect disconnection and attempt to reconnect with exponential backoff. Don't reconnect immediately; you'll hammer the server.
- Blocking on message processing: If processing a received message blocks the event loop, all other clients on that server connection hang. Use async/await and queue heavy work to a background task queue.
- Assuming connection order: Multiple TCP packets can arrive out of order. If you send frame A then frame B, the receiver might process B before A. Use sequence numbers in your application protocol if order matters.
- Forgetting about backpressure: If you send messages faster than the client can consume them, the OS buffer grows until memory is exhausted. Check the return value of
send()— if it's false, the buffer is full and you should slow down. - Broadcasting without filtering: In a large chat room, every server instance that receives a message should only send it to connected clients in that room, not all clients. Use topic-based routing or subscription management.
Key takeaways
- The WebSocket upgrade handshake uses HTTP headers to switch protocols on a single TCP connection, allowing proxies and firewalls to handle it using familiar HTTP rules.
- Full duplex communication lets both client and server send messages independently on the same connection, eliminating request-response pairing and head-of-line blocking.
- Frame types — text, binary, ping, pong, close — carry different payloads and control signals; ping/pong frames keep long-lived connections alive.
- Stateful connections change your scaling model: horizontal scaling is now about connection count and sticky session routing, not just request throughput.
- Plan for network failures, keep-alive timeouts, backpressure, and graceful shutdown; these are not optional in production.






Comments
Loading comments…