62. WebSockets and Server-Sent Events
62.1 WebSocket: the upgrade
A WebSocket starts life as an HTTP request and then stops being HTTP.
CLIENT:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat (optional subprotocol negotiation)
Origin: https://example.com
SERVER:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= (SHA-1 of key + magic GUID)
-> the same TCP connection now carries WebSocket FRAMES, bidirectionally,
until either side sends a Close frame.
The Sec-WebSocket-Accept handshake exists to prove the server understood the
upgrade (defeating cache-poisoning attacks against naive proxies), not for
security.
62.2 Frames and operational details
| Aspect | Detail |
|---|---|
| Framing | Opcodes: text(1), binary(2), close(8), ping(9), pong(10). Continuation frames for fragmentation. |
| Masking | Client-to-server frames must be XOR-masked with a random key (an anti-cache-poisoning measure). Server-to-client must not be. |
| Ping/pong | The protocol's keepalive. Use it — see below. |
| Close | A close frame carries a status code (1000 normal, 1001 going away, 1006 = abnormal/no close frame seen) |
| Subprotocols | Sec-WebSocket-Protocol negotiates an application protocol (e.g. graphql-ws) |
| Compression | permessage-deflate extension; watch CPU and CRIME-style risks with mixed secret/attacker data |
| Over HTTP/2 | RFC 8441 :protocol extended CONNECT; over HTTP/3 similarly. Many stacks still downgrade to HTTP/1.1 for WS. |
62.3 The operational hazards
- Idle timeouts kill you. Every proxy, load balancer, and NAT has one (commonly 60 s, AWS ALB default 60 s, NLB 350 s). A WebSocket with no traffic is silently reaped. Send a ping every 20–30 s, from the server, and expect pongs.
1006means "connection died without a close frame" — almost always a middlebox timeout, a network drop, or a crashed process. It carries no reason, so log your own context.- Load balancers must be configured for it. L7 LBs need WebSocket support enabled; sticky routing matters because the connection is long-lived and stateful.
- Scaling is stateful. N servers × M connections; broadcasting requires a pub/sub bus (Redis, NATS) because a message must reach whichever server holds each connection.
- Reconnect with backoff and jitter, and resume with a cursor — a mass-disconnect otherwise produces a thundering herd.
62.4 Server-Sent Events: the simpler option
SSE is plain HTTP with a never-ending response body.
Request: GET /events Accept: text/event-stream
Response: HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
event: price
id: 42
data: {"symbol":"ACME","px":10.5}
: this is a comment, used as a heartbeat
data: another message
| WebSocket | SSE | |
|---|---|---|
| Direction | Bidirectional | Server → client only |
| Protocol | Custom framing after upgrade | Plain HTTP |
| Reconnect | You implement it | Automatic, built into EventSource |
| Event IDs / resume | You implement it | id: + Last-Event-ID header, built in |
| Proxy/CDN friendliness | Needs explicit support | Works through anything that streams |
| Binary | Yes | Text only (base64 if you must) |
| Browser connection limit | Fine | 6 per origin on HTTP/1.1 (use HTTP/2) |
Choose SSE for one-way feeds (notifications, dashboards, LLM token streaming, progress). Choose WebSocket when the client must push frequently (chat, collaborative editing, games).
Also consider long polling as a fallback, and note that plain HTTP chunked
streaming (what fetch + ReadableStream consumes) is often enough.
62.5 Real-world example
A trading dashboard used WebSockets behind an ALB with the default 60-second idle
timeout and no application ping. During quiet markets, connections dropped every
minute with code 1006, the client reconnected, and the reconnect storm at the
open took down the service. Fix: server-side ping every 25 s, ALB idle timeout
raised to 300 s, exponential backoff with jitter on reconnect, and Last-Event-ID-
style resume. Disconnects fell to near zero.
Try it yourself
# watch the upgrade handshake
curl -sv -N -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
https://echo.websocket.org/ 2>&1 | grep -iE '^< HTTP|upgrade|sec-websocket'
# consume an SSE stream (note it never ends)
curl -N -H 'Accept: text/event-stream' https://stream.wikimedia.org/v2/stream/recentchange | head -20
# websocat is the netcat of websockets
websocat wss://echo.websocket.org