Real-Time WebSockets & SSE
HTTP was built so the client asks and the server answers. Real-time systems need the opposite. Master long polling, Server-Sent Events, the WebSocket handshake and frame format, and the part that actually breaks in production β scaling a million stateful connections.
π½οΈ 1. The Real-World Intuition: Four Ways to Learn Your Food Is Ready
You have ordered food. The kitchen knows when it is ready; you do not. Every real-time transport ever invented is one of these four conversations β and the trade-offs map exactly.
You walk to the kitchen door every 30 seconds and ask. Almost every trip is wasted, you annoy the staff, and on average you still learn the news 15 seconds late. But it needs nothing special from the kitchen β any restaurant on earth supports being asked a question.= a plain GET /messages?since=β¦ on a timer.
You ask once and stand at the door. The waiter doesn't answer until there is news (or until their shift forces them to say "nothing yet, ask again"). You hear the moment it happens. The cost: one waiter is now tied up holding your unanswered question.= a request the server deliberately holds open for ~30s.
You open the hatch once and leave it open. The kitchen calls out "order 12 plated", "order 12 leaving" β as many times as it likes, whenever it likes. You cannot shout back through the hatch, but you were never going to.= one HTTP response that never ends. One-way, server β client.
One call is placed, and then either side speaks at any time with no further ceremony. Maximum power, and the only option that is genuinely two-way. The cost: someone must hold that line open for every single diner, forever.= a full-duplex TCP connection after an HTTP upgrade.
π 2. The Four Transports, Precisely
| Transport | Direction | Latency | Overhead per Message | Server Cost | Reconnect Logic |
|---|---|---|---|---|---|
| Short Polling | Client pulls | β Β½ Γ interval, average | β Full HTTP headers (~900 B) | β Stateless β any box answers | β None needed |
| Long Polling | Client pulls, held open | β Near-instant | β Full HTTP headers per message | β οΈ One held request per client | β οΈ Re-issue after every message |
| Server-Sent Events | Server pushes (one-way) | β Near-instant | β
~8 bytes (data: β¦\n\n) | β οΈ One open connection per client | β Built into the browser |
| WebSockets | β Full duplex | β Near-instant | β 2 bytes of frame header | β οΈ One open connection per client | β You must write it yourself |
A realistic HTTP/1.1 request with cookies and a user agent runs ~650 bytes, and the response headers another ~250 bytes β call it 900 bytes of pure ceremony per exchange, carrying a payload that might be 40 bytes. A WebSocket frame carrying the same payload has a 2-byte header. Poll once every 2 seconds for one hour:
polling every 2s for 1 hour = 1,800 requests short polling 1,800 Γ 900 B = 1,620,000 B = 1,582 KiB of headers per client per hour websocket 1,800 Γ 2 B = 3,600 B = 3.5 KiB of frame headers (+ one ~900 B handshake) ββββββββββββββββββ saving: 1,578 KiB per client per hour (450Γ less) at 10,000 concurrent clients, short polling burns 15.09 GiB/hour in HTTP headers alone β before a single byte of your actual payload is counted.
π 3. Inside the WebSocket Protocol (RFC 6455)
A WebSocket starts life as an HTTP request. That is deliberate: it means the connection traverses port 443, existing proxies, and existing TLS termination. Once the upgrade completes, HTTP is gone and a raw framed protocol takes over the same TCP socket.
// ββ CLIENT β SERVER ββββββββββββββββββββββββββββββββββ GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== // base64 of 16 RANDOM bytes Sec-WebSocket-Version: 13 // ββ SERVER β CLIENT ββββββββββββββββββββββββββββββββββ HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= // How Sec-WebSocket-Accept is derived β a FIXED recipe from the spec: accept = base64( SHA1( key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" ) ) = base64( SHA1("dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11") ) = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" // β the literal example from RFC 6455
It is not security β the GUID is published in the RFC, so anyone can compute it. It is a proof of comprehension. A cache or proxy that merely echoes headers, or a plain HTTP server that happens to return 101, cannot produce the correct digest. The client verifies the digest before sending a single frame, which prevents it from talking WebSocket at something that does not actually understand WebSocket.
0x0 continuation β this frame extends the previous one0x1 text β payload is UTF-80x2 binary β payload is opaque bytes0x8 close β begins the closing handshake0x9 ping β keepalive probe0xA pong β mandatory reply to a pingEvery frame a browser sends is XOR-ed with a fresh random 4-byte key. This buys zero confidentiality β the key travels in plaintext right next to the data. Its purpose is defeating cache-poisoning attacks against intermediaries.
Without masking, attacker-controlled JavaScript could send bytes over an upgraded connection that a naive transparent proxy β one that never understood the 101 and still thinks it is reading HTTP/1.1 β parses as a second, forged GET request. The proxy would then cache that forged response and serve it to other users. Masking makes the on-wire bytes unpredictable to the sender, so no attacker can reliably construct a request the proxy would parse.Consequence for you: a server that skips unmasking, or a client that forgets to mask, must close the connection per spec β this is the #1 bug in hand-rolled WebSocket implementations.
1000 normal closure Β· 1001 going away (server restarting, tab closing) Β· 1009 message too big Β· 1011 internal error.And the one that matters most: 1006 abnormal closure β it is never sent on the wire. It is what your library reports locally when the TCP connection vanished without a close frame. Seeing 1006 in your logs means a network middlebox, load balancer idle timeout, or crash killed the socket β not that the peer said goodbye.
π‘ 4. Server-Sent Events: The Underrated Option
SSE is just an HTTP response that never ends. No upgrade, no framing, no new protocol β the server sets Content-Type: text/event-stream and keeps writing. It is plain text you can read with curl, which makes it dramatically easier to debug than a binary frame stream.
HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive X-Accel-Buffering: no // β tells nginx NOT to buffer. Forget this and // your "real-time" stream arrives in 4 KB chunks. event: price id: 42 data: {"sym":"ACME","px":141.20} // β BLANK LINE dispatches the event. This is the delimiter. event: price id: 43 data: {"sym":"ACME","px":141.35} retry: 5000 // β client should wait 5s before reconnecting : this is a comment β a common heartbeat to keep middleboxes from : killing an idle connection. Costs 1 line, saves your uptime.
EventSource retries on its own. You write no reconnect code at all.Last-Event-ID: 43 automatically β if you emit id: lines, the server can replay exactly what was missed.curl all work unchanged.EventSource β auth must ride on cookies or a query param (or use a fetch-based polyfill).ποΈ 5. The Hard Part: Scaling Stateful Connections
The protocol is the easy half. The reason real-time systems are hard is that a persistent connection turns your stateless web tier into a stateful one β and every operational assumption you had breaks at once.
Alice is connected to ws-node-1. Bob is on ws-node-7. Alice sends a chat message. Node 1 holds no socket to Bob β it physically cannot deliver it. A single-server tutorial hides this entirely; it is the first thing that breaks in production.
The fix is a backplane. Every node subscribes to a shared bus β Redis Pub/Sub, NATS, or Kafka. Node 1 publishes to room:42; every node receives it and writes to whichever local sockets are in that room. Nodes stop needing to know about each other.The catch: plain Redis Pub/Sub is fire-and-forget β a node that is briefly disconnected silently misses messages. If missed messages are unacceptable, you need Redis Streams, NATS JetStream, or Kafka with real offsets.
An AWS ALB closes an idle connection after 60 seconds by default. Your chat app is quiet at 3 a.m., so nothing flows for 61 seconds, and the LB tears down a connection both endpoints still believe is alive. Users report "it randomly stops working". Your logs show close code 1006 and nothing else.
ping (opcode 0x9) or an SSE : comment every ~25β30 s against a 60 s timeout.A user's phone goes into a tunnel. No FIN packet is ever sent, because nothing gracefully closed β the packets simply stop. Your server's socket stays ESTABLISHED and will happily keep buffering messages for a peer that is gone, potentially for hours, because default TCP keepalive is ~2 hours.This is why application-level heartbeats are mandatory, not optional: send a ping, start a timer, and if no pong arrives within (say) 2 intervals, you declare the connection dead and reclaim its memory. Never trust the socket state.
You restart a node holding 50,000 connections. All 50,000 clients notice at the same instant and reconnect immediately. The surviving nodes take a synchronised spike of 50,000 TLS handshakes plus 50,000 auth lookups β and fall over, dumping their connections into the same storm. This is how a routine deploy becomes a full outage.
delay = min(cap, base Γ 2^attempt) Γ random(0.5, 1.5). Jitter is the essential part; backoff alone keeps them synchronised.1001 with a reconnect_after hint, and let clients leave in a staggered window.A connection is cheap but not free. Modern event-loop servers reach hundreds of thousands of connections per host β but only after you fix, in this order:
ulimit -n of 1024 caps you at ~1,000 users. Raise nofile and fs.file-max.tcp_rmem/tcp_wmem down for many-small-messages workloads.ip_local_port_range.A WebSocket is inherently sticky β once the TCP connection is established it is pinned to one backend by definition, with no cookie required. Stickiness matters for two narrower reasons: (1) long-polling and SSE reconnects, where each new HTTP request must land on the node holding your session state; and (2) the HTTP handshake and the upgraded connection needing to reach the same box behind a layer-7 proxy.The better answer is to avoid needing it: keep per-connection state in the node (fine β it dies with the socket) but keep shared state in Redis, so any node can serve any reconnect. Stickiness plus in-memory session state means one node's death loses real data.
π§ 6. Which Transport, For Which Feature
| Feature | Pick | Why |
|---|---|---|
| Chat, multiplayer game, collaborative editor | WebSockets | Genuinely bidirectional and high-frequency in both directions. The only correct fit. |
| Live prices, sports scores, dashboards, notifications | SSE | Server β client only. Free reconnection and gap recovery via Last-Event-ID. |
| Streaming LLM tokens | SSE | One request in, a long one-way stream out. Reconnect logic matters more than duplex. |
| "Your export is ready" β one event, minutes later | Short polling | Holding a connection open for minutes to deliver one bit is absurd. Poll every 5s. |
| Real-time needed, but a corporate proxy strips upgrades | Long polling | The universal fallback β it is indistinguishable from ordinary HTTP. |
| Server-to-server streaming | gRPC streaming | No browser constraints, so you get a typed schema and binary framing instead. |
π» 7. Production Code: Heartbeats & Backoff (Multi-Language)
Every version below implements the three things tutorials omit and production requires: a heartbeat that detects half-open sockets, a Redis backplane for cross-node fan-out, and reconnect with jittered exponential backoff.
# Python β WebSocket hub with heartbeat + Redis backplane (asyncio) import asyncio, json, contextlib import websockets import redis.asyncio as aioredis HEARTBEAT_S = 25 # MUST be below the LB idle timeout (ALB default: 60s) PONG_TIMEOUT_S = 10 class Hub: def __init__(self): self.rooms: dict[str, set] = {} # room -> local sockets only def join(self, room, ws): self.rooms.setdefault(room, set()).add(ws) def leave(self, room, ws): self.rooms.get(room, set()).discard(ws) async def deliver_local(self, room, payload): # Snapshot the set: sockets can close while we iterate. targets = list(self.rooms.get(room, ())) if not targets: return await asyncio.gather( *(self._safe_send(ws, payload) for ws in targets), return_exceptions=True) async def _safe_send(self, ws, payload): with contextlib.suppress(Exception): await ws.send(payload) hub = Hub() async def backplane(redis): # Every node subscribes. A publish on ANY node reaches ALL nodes. pubsub = redis.pubsub() await pubsub.psubscribe("room:*") async for msg in pubsub.listen(): if msg["type"] != "pmessage": continue room = msg["channel"].decode().split(":", 1)[1] await hub.deliver_local(room, msg["data"].decode()) async def heartbeat(ws): # TCP will NOT tell us the peer vanished. We must probe. while True: await asyncio.sleep(HEARTBEAT_S) try: await asyncio.wait_for(await ws.ping(), PONG_TIMEOUT_S) except (asyncio.TimeoutError, Exception): await ws.close(code=1011, reason="heartbeat timeout") return async def handler(ws): redis = aioredis.from_url("redis://localhost") room = "lobby" hub.join(room, ws) hb = asyncio.create_task(heartbeat(ws)) try: async for raw in ws: # Publish to the bus, NOT to local sockets β other nodes need it too. await redis.publish(f"room:{room}", raw) except websockets.ConnectionClosed: pass finally: hb.cancel() hub.leave(room, ws) async def main(): redis = aioredis.from_url("redis://localhost") asyncio.create_task(backplane(redis)) async with websockets.serve(handler, "0.0.0.0", 8080, ping_interval=None): # we ping manually await asyncio.Future() asyncio.run(main())
ESTABLISHED for hours after the peer vanished. Only an application heartbeat with a timeout detects it. No heartbeat = a slow memory leak that looks like a traffic mystery.bufferedAmount / backpressure check you OOM the whole node to serve one bad connection. Drop the slow client.π§ͺ 8. Interactive Lab: Transport Overhead Simulator
Bytes, Requests & Latency for All Four Transports
π‘ Assumptions: ~650 B request + ~250 B response headers per HTTP exchange (realistic HTTP/1.1 with cookies); WebSocket frame header 2 B serverβclient; SSE framing data: β¦\n\n β 8 B; long polling re-issues one request per delivered message plus a 30 s timeout refresh. Payload bytes are excluded on purpose β this isolates protocol overhead, the part you control by choosing a transport.
π― 9. Test Your Understanding
Your chat app works perfectly during the day. Overnight, users on the mobile app report being silently disconnected β but only when idle. Your logs show close code 1006 and no exceptions. What is happening?
Your WebSocket chat works flawlessly in staging (one instance). In production behind 8 instances, users only see messages from some other users β roughly one in eight. Why?
You are building a notification bell: the server pushes alerts to the browser, and the client never sends anything except marking one read (a normal REST call). A teammate proposes WebSockets. What is the stronger choice, and why?