WebSockets
On this page
- Real-Time Bidirectional Communication
- The Upgrade Handshake
- Frames, Opcodes, and Close Codes
- A Working Server and Client in Python
- Reconnection Without Losing Data
- WebSockets vs. SSE vs. Long Polling
- Running WebSockets Behind Proxies and Load Balancers
- Scaling Horizontally
- Security Pitfalls
- Debugging Checklist
Real-Time Bidirectional Communication#
HTTP is a request/response protocol: the client asks, the server answers. Even with keep-alive connections, the server cannot speak until it is spoken to. That model fits web pages and APIs. It does not fit a chat app, a multiplayer game, a collaborative editor, or a live price feed, where the server needs to push data the moment it exists.
The old workaround was polling (ask every second) or long polling (ask, and the server holds the request open until it has something). Both repeat full HTTP headers on every exchange and keep reopening requests. WebSockets replace them with one persistent, full-duplex channel over a single TCP connection: either side can send a message at any time.
The Upgrade Handshake#
A WebSocket starts life as an ordinary HTTP/1.1 GET request that asks to switch protocols:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Origin: https://app.example.com
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
A server that accepts replies with status 101 Switching Protocols:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
From that point the same TCP connection stops carrying HTTP and carries WebSocket frames instead. The Sec-WebSocket-Accept value proves the server actually understood the WebSocket request (rather than being a confused HTTP cache replaying something). It is the SHA-1 of the client's key concatenated with a fixed GUID from RFC 6455, base64-encoded:
import base64, hashlib
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" # fixed by RFC 6455
def accept_for(key: str) -> str:
digest = hashlib.sha1((key + GUID).encode()).digest()
return base64.b64encode(digest).decode()
print(accept_for("dGhlIHNhbXBsZSBub25jZQ==")) # s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
This is a handshake check, not security. The key is random but not secret, and nothing here authenticates the user. Use wss:// (WebSocket over TLS, port 443) in production so the stream is encrypted and passes cleanly through proxies that would mangle plaintext traffic.
Frames, Opcodes, and Close Codes#
After the upgrade, data travels in frames. Each frame has a small header: a FIN bit (is this the last fragment of a message?), a 4-bit opcode, a mask bit, and a payload length. The header is 2 bytes for small payloads and grows to at most 14 bytes with an extended length and a masking key, compared with the hundreds of bytes of headers on a typical HTTP request.
| Opcode | Meaning |
|---|---|
0x0 |
Continuation of a fragmented message |
0x1 |
Text (must be valid UTF-8) |
0x2 |
Binary |
0x8 |
Close |
0x9 |
Ping |
0xA |
Pong |
Two details matter in practice:
- Client-to-server frames must be masked with a random 4-byte key. This exists to stop malicious pages from crafting bytes that confuse intermediary caches. Servers must reject unmasked client frames, which is one reason you should never hand-roll a WebSocket parser.
- Close frames carry a status code.
1000is a normal closure,1001means the endpoint is going away (a server restart or page navigation),1008is a policy violation, and1011is a server error.1006never appears on the wire. Libraries report it when the connection dropped without a close frame, which usually points at a proxy timeout, a crashed process, or a network cut.
Ping/pong frames are the keepalive. One side sends a ping; the other must answer with a pong. If pongs stop arriving, the connection is dead even though the OS may not know it yet, because a silently dropped TCP connection can look open for a long time.
A Working Server and Client in Python#
The websockets library handles framing, masking, and keepalives. This broadcast chat server sends every message to every connected client:
import asyncio
import json
from websockets.asyncio.server import serve
CLIENTS = set()
async def handler(websocket):
CLIENTS.add(websocket)
try:
async for raw in websocket: # ends cleanly when the client closes
msg = json.loads(raw)
out = json.dumps({"from": msg["user"], "text": msg["text"]})
for client in list(CLIENTS):
try:
await client.send(out)
except Exception:
pass # client vanished mid-broadcast
finally:
CLIENTS.discard(websocket)
async def main():
async with serve(handler, "localhost", 8765, ping_interval=20, ping_timeout=20) as server:
await server.serve_forever()
asyncio.run(main())
And a client:
import asyncio
import json
from websockets.asyncio.client import connect
async def main():
async with connect("ws://localhost:8765") as ws:
await ws.send(json.dumps({"user": "alice", "text": "hello"}))
print("received:", await ws.recv())
asyncio.run(main())
Running both prints received: {"from": "alice", "text": "hello"}. The non-obvious lines: ping_interval=20, ping_timeout=20 sends a ping every 20 seconds and closes the connection if no pong arrives within 20 more, and the finally block guarantees a disconnected client is removed from the set even when the connection dies abruptly. For quick manual testing, python -m websockets ws://localhost:8765 opens an interactive client in your terminal.
Reconnection Without Losing Data#
Connections will drop: laptops sleep, phones switch networks, servers deploy. A production client needs three things:
- Automatic reconnect with exponential backoff and jitter, so ten thousand clients do not reconnect in the same instant after a server restart.
- A resubscribe step after reconnecting, because the new server process knows nothing about the old connection.
- Message sequence numbers, so the client can say "the last message I saw was 4812" and the server can replay what was missed.
In the browser:
let lastSeq = 0;
let attempt = 0;
function connect() {
const ws = new WebSocket(`wss://app.example.com/feed?since=${lastSeq}`);
ws.onopen = () => { attempt = 0; };
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
lastSeq = msg.seq; // remember progress for the next reconnect
render(msg);
};
ws.onclose = () => {
const delay = Math.min(30000, 1000 * 2 ** attempt) * (0.5 + Math.random() / 2);
attempt += 1;
setTimeout(connect, delay); // backoff with jitter
};
}
connect();
In Python, websockets has this built in: async for ws in connect(uri): reconnects with exponential backoff each time the loop body raises ConnectionClosed.
WebSockets vs. SSE vs. Long Polling#
| Long polling | Server-Sent Events | WebSockets | |
|---|---|---|---|
| Direction | Server to client (client sends separate requests) | Server to client only | Both directions |
| Transport | Plain HTTP | Plain HTTP (text/event-stream) |
Upgraded TCP connection |
| Browser reconnect | You write it | Built in, with Last-Event-ID |
You write it |
| Binary data | Yes | Text only | Yes |
| Works through HTTP/2 multiplexing | Yes | Yes | Needs extra support at every hop |
| Typical use | Legacy fallback | Notifications, live scores, LLM token streams | Chat, games, collaborative editing |
If the client only listens, SSE is simpler and plays better with existing HTTP infrastructure. Choose WebSockets when the client also sends a steady stream of messages. HTTP/2 server push is not an alternative: browsers have removed it.
Running WebSockets Behind Proxies and Load Balancers#
The most common production failure is a proxy that does not pass the upgrade through, or kills idle connections. With nginx, the Upgrade and Connection hop-by-hop headers must be forwarded explicitly and the read timeout raised:
location /ws/ {
proxy_pass http://chat_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
Without the headers, the backend sees a plain GET and the client gets a 400 or 426 instead of 101. Without the timeout, nginx closes connections that have been quiet for 60 seconds by default, and the client sees 1006. Cloud load balancers have their own idle timeouts, so keep your ping interval shorter than the shortest timeout on the path. The reverse proxy lesson covers the surrounding configuration.
Scaling Horizontally#
HTTP requests can go to any server; a WebSocket is pinned to the one process that accepted it. If Alice is connected to server 1 and Bob to server 2, server 1 cannot write to Bob's socket. The standard fix is a pub/sub backbone such as Redis Pub/Sub, NATS, or Kafka: server 1 publishes Alice's message to a channel, every server subscribed to that channel receives it, and server 2 pushes it to Bob.
Other scaling realities:
- Each connection holds memory and a file descriptor for hours. Raise the process's open-file limit (
ulimit -n) and measure memory per idle connection. - Deploys disconnect everyone on the old process. Drain gradually by sending close code
1001in batches, rather than dropping all connections at once and causing a reconnect stampede. - A load balancer spreading new connections by least-connections keeps servers balanced better than round-robin, because connection lifetimes vary widely.
Security Pitfalls#
- Cross-Site WebSocket Hijacking. Browsers send cookies with the upgrade request, and the same-origin policy does not block cross-origin WebSocket connections. If you authenticate by cookie, a malicious page can open a socket to your server as the logged-in user. Check the
Originheader against an allowlist during the handshake, or authenticate with a short-lived token sent in the first message. - Unvalidated messages. Every frame is untrusted input: validate JSON schemas and sizes and apply per-connection rate limits. Set a maximum message size (
max_sizeinwebsockets) so a single client cannot exhaust memory. - Authorisation drift. A connection opened while a user had access may outlive a permission change. Re-check authorisation on sensitive actions, and close sockets when sessions are revoked.
Debugging Checklist#
Test the handshake on its own with curl. A working endpoint answers HTTP/1.1 101 Switching Protocols, and anything else (a 400, a 426, a redirect, or a 101 that never arrives) tells you the upgrade is failing before your application code runs:
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
http://localhost:8765/
- Browser devtools, Network tab, filter WS: the Messages pane shows every frame in both directions with timestamps.
- Connections dying at a round number of seconds (60, 100, 300) almost always means an intermediary idle timeout.
- For capturing real-time feeds as a data source, see WebSockets and real-time data.
Check your understanding
4 quick questions on this lesson. Get them all right and the lesson is marked complete.