SystemDesign.website
Track 5 Β· Module 5.4 Β· Communication Patterns & Asynchronous Systems

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.

⏱️ Reading Time: ~24 min
🎯 Core Architecture: Server Push over a Persistent Connection
🎬 Format: Restaurant Analogy + Live Overhead Simulator

🍽️ 1. The Real-World Intuition: Four Ways to Learn Your Food Is Ready

πŸ§‘β€πŸ³ The Analogy: You, a Waiter, and a Kitchen

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.

1Short Polling β€” "Is it ready yet?" every 30 seconds

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.

2Long Polling β€” "I'll wait right here until it's ready"

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.

3Server-Sent Events β€” the kitchen hatch shouts updates

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.

4WebSockets β€” you and the chef share an open phone line

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.

πŸ’‘ The Core Insight:Plain HTTP has no way for a server to speak first β€” the protocol is strictly request β†’ response. So every real-time technique is one of exactly two tricks: ask repeatedly (polling), or keep one connection open so the server already has a channel to write into (long polling, SSE, WebSockets). The entire design space is choosing how long you hold a connection open and who is allowed to write to it.

πŸ“Š 2. The Four Transports, Precisely

TransportDirectionLatencyOverhead per MessageServer CostReconnect Logic
Short PollingClient pulls❌ Β½ Γ— interval, average❌ Full HTTP headers (~900 B)βœ… Stateless β€” any box answersβœ… None needed
Long PollingClient pulls, held openβœ… Near-instant❌ Full HTTP headers per message⚠️ One held request per client⚠️ Re-issue after every message
Server-Sent EventsServer 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
πŸ“ The Overhead Gap Is Not Subtle

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.
πŸ’‘ The Judgement Most Engineers Get Backwards:WebSockets are not the "best" option β€” they are the most expensive one. They are the only choice when the client also needs to push at high frequency (collaborative editing, multiplayer, voice signalling). If your data flows server β†’ client only β€” notifications, live prices, progress bars, dashboards, AI token streaming β€” SSE is simpler, cheaper to operate, and gives you automatic reconnection for free. Reaching for WebSockets on a one-way feed is the most common over-engineering in this space.

πŸ”Œ 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.

🀝 The handshake
// ── 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
πŸ€” Why hash a key with a hard-coded magic string?

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.

🧱 Frame anatomy β€” the first two bytes carry almost everything
Byte 0 and Byte 1 of every WebSocket frame:
FIN1 bit
RSV1-33 bits
OPCODE4 bits
MASK1 bit
PAYLOAD LEN7 bits
EXTENDED LEN β€” only if len == 126 (16 bits) or 127 (64 bits)
MASKING KEY β€” only if MASK == 1 (4 bytes)
PAYLOAD
Opcodes
0x0 continuation β€” this frame extends the previous one
0x1 text β€” payload is UTF-8
0x2 binary β€” payload is opaque bytes
0x8 close β€” begins the closing handshake
0x9 ping β€” keepalive probe
0xA pong β€” mandatory reply to a ping
Payload length is variable-width
0–125 β†’ the 7-bit field holds it. Header = 2 bytes.
126 β†’ read the next 16 bits as the real length. Header = 4 bytes.
127 β†’ read the next 64 bits. Header = 10 bytes.
Client β†’ server adds a 4-byte mask, so the minimum client frame is 6 bytes.
🎭 Why client β†’ server frames MUST be masked

Every 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.

πŸšͺ Close codes worth memorising

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.
βœ… What SSE gives you free
Automatic reconnection. The browser's EventSource retries on its own. You write no reconnect code at all.
Gap recovery. On reconnect the browser sends Last-Event-ID: 43 automatically β€” if you emit id: lines, the server can replay exactly what was missed.
It's just HTTP. Compression, auth headers, cookies, HTTP/2, CDNs, and curl all work unchanged.
❌ Where SSE bites
One-way only. Client β†’ server needs a separate normal request.
Text only. UTF-8 by definition β€” binary must be base64'd, costing ~33%.
The 6-connection cap. Over HTTP/1.1 browsers allow only ~6 connections per origin. Open SSE streams in 6 tabs and the 7th hangs forever. HTTP/2 multiplexing fixes this β€” and is the single strongest reason to serve SSE over h2.
No custom headers from the native EventSource β€” auth must ride on cookies or a query param (or use a fetch-based polyfill).
πŸ’‘ Where You've Already Seen This:Streaming LLM responses β€” including the one you are reading this on β€” are overwhelmingly SSE, not WebSockets. The shape fits perfectly: the client sends one prompt via a normal POST, then the server streams tokens one way for 30 seconds. There is no client-side chatter to justify full duplex, and free reconnection is worth more than bidirectionality you'd never use.

πŸ—οΈ 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.

1The Fan-Out Problem: your users are on different machines

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.

2Load Balancer Idle Timeouts β€” the silent connection killer

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.

Rule: heartbeat interval < LB idle timeout < your reconnect budget.
Send a WebSocket ping (opcode 0x9) or an SSE : comment every ~25–30 s against a 60 s timeout.
Also raise the LB idle timeout β€” but never rely on that alone; corporate proxies impose their own.
3Half-Open Connections β€” TCP lies to you

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.

4The Reconnect Storm β€” your deploy becomes a DDoS

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.

Exponential backoff with jitter β€” delay = min(cap, base Γ— 2^attempt) Γ— random(0.5, 1.5). Jitter is the essential part; backoff alone keeps them synchronised.
Drain, don't kill β€” stop accepting new connections, send close code 1001 with a reconnect_after hint, and let clients leave in a staggered window.
Cache the auth path β€” reconnects hammer whatever validates tokens. That is usually what actually falls over first.
5C1M: what actually limits connections per box

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:

1. File descriptors β€” every socket is an fd. The default ulimit -n of 1024 caps you at ~1,000 users. Raise nofile and fs.file-max.
2. Socket buffer memory β€” the real ceiling. Default read+write buffers can be tens of KB per socket; 1M sockets Γ— 64 KB = 64 GB. Tune tcp_rmem/tcp_wmem down for many-small-messages workloads.
3. Ephemeral ports β€” only ~28k by default per destination tuple. It limits your outbound connections (proxy β†’ backend), not inbound. Widen ip_local_port_range.
4. Thread-per-connection β€” the architectural killer. 1M threads Γ— ~1 MB of stack is impossible. You need epoll/kqueue event loops, Go goroutines, or virtual threads.
πŸͺ Sticky Sessions: needed, but not the way people think

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

FeaturePickWhy
Chat, multiplayer game, collaborative editorWebSocketsGenuinely bidirectional and high-frequency in both directions. The only correct fit.
Live prices, sports scores, dashboards, notificationsSSEServer β†’ client only. Free reconnection and gap recovery via Last-Event-ID.
Streaming LLM tokensSSEOne request in, a long one-way stream out. Reconnect logic matters more than duplex.
"Your export is ready" β€” one event, minutes laterShort pollingHolding a connection open for minutes to deliver one bit is absurd. Poll every 5s.
Real-time needed, but a corporate proxy strips upgradesLong pollingThe universal fallback β€” it is indistinguishable from ordinary HTTP.
Server-to-server streaminggRPC streamingNo browser constraints, so you get a typed schema and binary framing instead.
πŸ’‘ The Interview Answer:When asked "how would you build real-time X?", do not open with "WebSockets". Open with "which direction does the data flow, and how often?" Then justify: one-way β†’ SSE; two-way and chatty β†’ WebSockets; rare events β†’ polling. Naming the cheapest transport that satisfies the requirement, and stating the fallback for hostile networks, is what separates a senior answer from a keyword.

πŸ’» 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.

Select Language:
# 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())
πŸ”‘ The 3 Things Everyone Gets Wrong
1. Trusting the socket state. A connection can be 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.
2. Broadcasting to local sockets only. Works flawlessly on one server, silently drops messages the moment you add a second. Always publish to the backplane, then let every node deliver locally β€” including the node that published.
3. Unbounded send buffers. One slow client on a hotel Wi-Fi will absorb messages slower than you produce them. Without a bufferedAmount / backpressure check you OOM the whole node to serve one bad connection. Drop the slow client.

πŸ§ͺ 8. Interactive Lab: Transport Overhead Simulator

πŸ“ Quantify the Trade-Off

Bytes, Requests & Latency for All Four Transports

Drag the sliders β€” watch polling collapse
30
how often the server has news
2
short-polling only
10,000
1 Β· 100 Β· 10k Β· 100k Β· 1M

πŸ’‘ 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

Scenario 1: The 3 A.M. Disconnects

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?

Scenario 2: It Worked on One Server

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?

Scenario 3: The Notification Feed

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?

β—‹

Completed this module?

Mark as finished to update your curriculum progress tracker.