The Problem Nobody Talks About Until It's 3am
You shipped it. WebSockets are live. Users are connecting, data is flowing, and everything feels magical. Then, mysteriously, connections start dropping. Users complain the app "freezes." Your monitoring shows spikes of reconnects every few minutes. You restart the server — it helps for a bit, then breaks again. Sound familiar?
I've been there, staring at logs that tell me nothing useful, convinced there's a bug in my code when the real culprit was sitting quietly between the client and the server the whole time. Before you can fix these problems — or better yet, prevent them — you need to understand what WebSockets actually are under the hood. Not the marketing version. The real, messy, protocol-level truth.
WebSockets are not magic. They're a very specific upgrade to HTTP, with their own framing format, their own handshake ritual, and a whole set of assumptions that fall apart the second you put certain infrastructure in front of them. Let's break all of it down.
The HTTP Upgrade Handshake: How a Connection Is Born
WebSockets don't start as WebSockets. They start as a completely normal HTTP/1.1 request. This surprises a lot of people. The client opens a TCP connection — TCP being the underlying transport layer protocol, essentially a reliable pipe for sending bytes back and forth — and sends what looks like a regular GET request, but with some unusual headers attached.
Here's what that opening handshake actually looks like on the wire:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13The Upgrade: websocket and Connection: Upgrade headers are the critical ones. They're the client's way of saying "I know we started talking HTTP, but I'd like to switch protocols now." The Sec-WebSocket-Key is a randomly generated Base64-encoded value — Base64 is just a way of encoding binary data as plain text — that the server uses to prove it actually understands the WebSocket protocol. It's a security measure to prevent a regular HTTP server from accidentally being treated as a WebSocket endpoint.
The server responds with a 101 Switching Protocols status code. Not 200. Not 404. 101, which is HTTP's way of saying "agreed, we're switching." The server takes that Sec-WebSocket-Key, concatenates it with a specific magic string defined in the RFC (the official protocol specification), hashes it with SHA-1, and sends back the result as the Sec-WebSocket-Accept header. If the client validates that hash and everything checks out, the HTTP handshake is done forever. That TCP connection is now a WebSocket connection. No more HTTP. Both sides can send frames — small chunks of data — in either direction at any time.
Frames: The Data Format Hiding Inside Every Message
Once the handshake is complete, the two parties stop speaking HTTP entirely. They start speaking in frames. A WebSocket frame is a precisely structured chunk of bytes, and understanding its layout explains a lot about why WebSockets behave the way they do.
Every frame starts with a two-byte header. The first byte contains the FIN bit — a flag indicating whether this is the final fragment of a message — plus three reserved bits, plus a four-bit opcode. The opcode tells the receiver what kind of frame this is. Opcode 0x1 means it's a text frame. 0x2 is binary. 0x8 is a close frame. 0x9 is a ping, and 0xA is a pong. Pings and pongs are the protocol's built-in heartbeat mechanism, like two people occasionally saying "you still there?" to each other on a long phone call.
The second byte contains a masking bit and the payload length. Here's something most developers don't realize: all frames sent from client to server must be masked. Masking means the payload data is XOR-ed — a simple bitwise operation — with a four-byte masking key that's sent along with the frame. This is a security requirement from the RFC, designed to prevent certain types of cache poisoning attacks on proxy servers. Frames going from server to client are not masked. If you're implementing a WebSocket server from scratch and you forget to unmask incoming data, you'll get absolute garbage, and it won't be obvious why.
Here's a minimal Node.js example that manually constructs a WebSocket frame to see what we're dealing with — this is just for learning purposes, not production use:
function parseWebSocketFrame(buffer) {
const firstByte = buffer[0];
const secondByte = buffer[1];
const isFinalFragment = (firstByte & 0x80) !== 0; // FIN bit
const opcode = firstByte & 0x0f; // last 4 bits
const isMasked = (secondByte & 0x80) !== 0; // masking bit
let payloadLength = secondByte & 0x7f; // lower 7 bits
let offset = 2;
// Handle extended payload lengths
if (payloadLength === 126) {
payloadLength = buffer.readUInt16BE(offset);
offset += 2;
} else if (payloadLength === 127) {
// 8-byte extended length (for very large payloads)
payloadLength = buffer.readBigUInt64BE(offset);
offset += 8;
}
let payload;
if (isMasked) {
const maskingKey = buffer.slice(offset, offset + 4);
offset += 4;
payload = Buffer.alloc(payloadLength);
for (let i = 0; i < payloadLength; i++) {
payload[i] = buffer[offset + i] ^ maskingKey[i % 4];
}
} else {
payload = buffer.slice(offset, offset + payloadLength);
}
return {
isFinalFragment,
opcode,
isMasked,
payload: payload.toString('utf8'),
};
}This is what the ws library — and every other WebSocket library — is doing for you automatically. When you call socket.send("hello"), there's an entire encoding process happening. Frame header, payload length encoding, masking (if client-side), then the raw bytes go over TCP. When data arrives, the reverse happens. The library handles it so you don't have to, but knowing the structure means you can debug at the byte level when things go wrong.
Why Your Load Balancer Is Quietly Killing Connections
This is the part that causes the 3am incidents. Load balancers — the infrastructure components that distribute incoming traffic across multiple servers — were designed in an era where HTTP requests were short and stateful. A request comes in, a server handles it, the connection closes or is quickly reused. Clean, stateless, beautiful.
WebSockets break every assumption that load balancer was built on.
The Timeout Problem
Most load balancers have an idle timeout. This is the amount of time a connection can exist without any data flowing before the load balancer closes it. For normal HTTP traffic, this might be 60 seconds, which is generous. For WebSockets, a connection might legitimately sit quiet for minutes at a time — a user has the chat app open but isn't typing, or a trading dashboard is waiting for a market event. The load balancer doesn't know the connection is intentionally idle. It just sees silence, hits its timeout, and silently tears down the TCP connection.
Silently is the key word here. The load balancer doesn't always send a proper TCP FIN or RST packet — the signals that would tell your application "hey, the connection is closed." Sometimes it just stops forwarding packets. Your server still thinks the client is connected. The client still thinks it's connected to the server. Neither side knows. Messages get lost. This is called a half-open connection, and it's one of the most insidious bugs in networked systems.
The fix is the WebSocket ping/pong mechanism combined with application-level heartbeats. Your server should send a ping frame at regular intervals — I've found every 25-30 seconds works well for most environments — and if no pong comes back within a timeout window, you assume the connection is dead and clean it up. Here's a simple heartbeat implementation in Python using the websockets library:
import asyncio
import websockets
HEARTBEAT_INTERVAL = 25 # seconds
HEARTBEAT_TIMEOUT = 10 # seconds to wait for pong
async def handler(websocket):
async def heartbeat():
while True:
try:
await asyncio.wait_for(
websocket.ping(),
timeout=HEARTBEAT_TIMEOUT
)
await asyncio.sleep(HEARTBEAT_INTERVAL)
except asyncio.TimeoutError:
print("Client didn't respond to ping. Closing.")
await websocket.close()
return
except websockets.exceptions.ConnectionClosed:
return
heartbeat_task = asyncio.create_task(heartbeat())
try:
async for message in websocket:
print(f"Received: {message}")
await websocket.send(f"Echo: {message}")
finally:
heartbeat_task.cancel()
async def main():
async with websockets.serve(handler, "localhost", 8765):
await asyncio.Future() # run forever
asyncio.run(main())This keeps the connection alive and gives you a mechanism to detect dead connections before they silently pile up and eat your server's memory.
The Sticky Session Problem
Standard load balancing works by routing each request to whichever server has capacity. Request one might go to Server A, request two might go to Server B. That's fine for HTTP because each request carries all the context it needs. WebSockets are different. After the handshake, your WebSocket connection is a persistent connection to a specific server. If a subsequent HTTP request (like fetching a resource on the page) goes to a different server, that's fine — but the WebSocket tunnel itself stays anchored.
The problem arises when the load balancer decides to route a reconnecting WebSocket client to a different backend server. If your application stores any connection state on the server — which room the user is in, their subscription filters, any buffered messages — that state lives on the original server. The new server knows nothing. The client reconnects and finds itself in a blank slate.
The solution has two parts. First, configure your load balancer for sticky sessions — also called session affinity — which pins a client to the same backend server based on their IP address or a cookie. In AWS Application Load Balancer, this is called "target group stickiness." In Nginx, it's the ip_hash directive. Second, and more robustly, move your shared WebSocket state out of server memory entirely. Use Redis as a central store for connection state and pub/sub messaging. That way, any server can handle any connection because the truth lives in Redis, not in local memory.
The Protocol Awareness Problem
Some older load balancers and reverse proxies don't understand the WebSocket upgrade at all. They see the Connection: Upgrade header and either strip it — because they're configured to strip hop-by-hop headers, which are headers meant for the immediate connection rather than the end destination — or they mishandle it entirely. The handshake appears to succeed, but then the load balancer starts trying to parse the binary WebSocket frames as HTTP requests. It fails, gets confused, and closes the connection.
In Nginx, you have to explicitly tell it to handle the upgrade. Without this configuration, Nginx will strip the necessary headers and WebSocket connections will fail mysteriously:
http {
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name example.com;
location /ws/ {
proxy_pass http://backend_servers;
proxy_http_version 1.1;
# These two lines are non-negotiable for WebSockets
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Increase timeout to prevent idle disconnects
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}The proxy_http_version 1.1 line matters too. WebSockets require HTTP/1.1. If Nginx tries to proxy using HTTP/1.0, the upgrade will fail because HTTP/1.0 doesn't support persistent connections in the same way.
The Reconnection Strategy That Actually Works
Understanding all of the above changes how you think about client-side reconnection logic. Most developers start with a simple "if disconnected, reconnect immediately" approach. In production, this is a disaster. If your server restarts, or your load balancer briefly flaps, every client reconnects simultaneously. You get a thundering herd — thousands of connections hammering your server at once, potentially overwhelming it right as it's trying to come back up.
The standard solution is exponential backoff with jitter. Exponential backoff means each reconnection attempt waits longer than the last — 1 second, then 2, then 4, then 8, up to some maximum. Jitter means adding a small random offset to each delay. The randomness is crucial because without it, all clients that disconnected at the same time will still reconnect in synchronized waves, just slower waves. With jitter, they spread out organically.
In my experience, a max backoff of around 30 seconds with up to 1-2 seconds of random jitter handles the vast majority of real-world reconnection scenarios gracefully. Combine this with the server-side heartbeat detection mentioned earlier, and you have a system that handles the underlying transport realities of WebSockets rather than pretending they don't exist.
The Bigger Picture
WebSockets are a genuinely elegant protocol once you understand what they're doing. The handshake is a clever use of HTTP as a bootstrapping mechanism. The framing format is compact and well thought out. Ping/pong is exactly the right primitive for keepalive behavior. The problems don't come from the protocol — they come from deploying it into infrastructure that wasn't designed with persistent connections in mind.
The lesson I keep coming back to is this: knowing the protocol at the frame level isn't just academic. It's the difference between guessing when things go wrong and actually knowing what to look for. When a connection drops with no error in your application logs, now you know to check the load balancer timeout settings. When clients reconnect to a different server and lose their state, now you know to look at your sticky session configuration. When a proxy seems to mangle the handshake, now you know exactly which headers need to be preserved.
The infrastructure between your client and server is not transparent. It has opinions. It has timeouts. It makes decisions. Understanding WebSockets at this level means you stop being surprised by those decisions and start anticipating them.