Real-Time and WebSockets
When request-response runs out of road: chat, live updates, and what they cost your backend.
After this lesson you can explain
- Why plain HTTP cannot push, and the ladder of techniques (polling, long polling, SSE, WebSockets) that fills the gap, including when the bottom rung is the right answer.
- What happens when an HTTP connection upgrades to a websocket, and what you must design yourself once verbs, paths, and status codes are gone.
- Why holding sockets turns stateless servers stateful, and what the pub/sub backplane and the reliability layer each exist to fix.
1. The problem, and where push fits
HTTP is request-response: the client asks, the server answers, the connection ends. That model is perfect until the server is the one with news: a chat message arrived, the score changed, a teammate moved a card. HTTP has no native way for the server to start the conversation.
Before reaching for anything, place real-time against push notifications. They solve different halves of the same problem and serious apps use both:
| Push (FCM/APNs) | Real-time channel | |
|---|---|---|
| When | App closed or backgrounded | App open, screen active |
| Latency | Seconds, sometimes minutes | Sub-second |
| Reliability | Best-effort, OS-mediated | Reliable while connected |
| Job | "Come back, something happened" | "Here it is, right now" |
A chat app runs a socket while you are in the app and falls back to FCM/APNs the moment you leave it.
2. The ladder of techniques
Like offline support, real-time is a ladder, and the bottom rungs are chronically underrated.
Short polling
Ask every N seconds. Wasteful (mostly empty responses), and latency equals the interval. But it is trivially simple, stateless, cache-friendly, and for a dashboard refreshing every 30 seconds it is the right answer. Do not let anyone shame you out of polling.
Long polling
The server holds the request open until it has news (or a timeout), answers, and the client immediately re-asks. Near-instant latency out of plain HTTP. Mostly a historical stepping stone ("Comet"), but still the fallback layer under several real-time libraries when sockets are blocked.
Server-Sent Events (SSE)
One long-lived HTTP response down which the server streams data: events indefinitely. One direction only (server → client), but it is plain HTTP (proxies and load balancers cope), the browser's EventSource API has auto-reconnect with a resume position (Last-Event-ID) built in, and the server side is simple. The workhorse for feeds, notifications, and dashboards. It is also how LLM APIs stream tokens: every streamed Claude or ChatGPT answer is SSE in action.
WebSockets
A persistent, full-duplex connection: both sides send at any time, sub-second, until someone hangs up. The complete answer to bidirectional real-time, and the point where your backend architecture changes (§4). HTTP/3's WebTransport is the eventual successor; same mental model.
3. What a websocket actually is
The connection starts life as an ordinary HTTP request that asks to stop being HTTP:
GET /ws HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
HTTP/1.1 101 Switching Protocols <- the handshake succeeds
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
After the 101, the socket is no longer HTTP. The conceptual shift that follows:
There are no more requests. No verbs, no paths, no status codes, no headers per message, just frames (usually JSON) flowing both ways. Everything HTTP gave you for free, you now design yourself: a type field for routing ("message.new", "typing.start"), your own error signalling, your own acknowledgements. A websocket API is a small protocol-design exercise where a REST API was mostly convention.
-> { "type": "message.send", "temp_id": "c7a1", "room": "42", "text": "on my way" }
<- { "type": "message.ack", "temp_id": "c7a1", "id": 4018, "ts": "..." }
<- { "type": "message.new", "id": 4019, "room": "42", "from": "dara", "text": "grand" }
Plus heartbeats: ping/pong frames every ~30 seconds, because a silently dead connection (train tunnel, sleeping laptop) looks identical to a quiet one. No pong → close and reconnect.
4. The backend consequence: your servers become stateful
This is the section that explains why real-time is architecturally expensive.
Plain HTTP scales out easily because it is stateless: any server can answer any request, so a load balancer can spray traffic anywhere. A websocket breaks that: the live connection is pinned to one specific server's memory. When your code needs to tell user X something, it must find which server holds X's socket.
┌──────────────┐
event: "msg for │ Redis │ every server subscribes;
room 42" ──publish──► pub/sub ├──► the one(s) holding room-42
(from any server │ (backplane) │ sockets deliver the frame
or worker) └──────────────┘
Server A ── sockets: alice, bob Server B ── sockets: carol
(knows nothing of carol) (knows nothing of alice, bob)
The standard pieces:
- A pub/sub backplane (typically Redis): servers publish events to channels; every server subscribes and delivers to whichever of its own sockets care. No server needs a global connection map.
- Connection registries / presence: knowing who is online means tracking connects and disconnects centrally, with TTLs, because servers crash without saying goodbye.
- Reconnection storms: a deploy or load-balancer blip drops 50,000 connections that all reconnect at once; backoff with jitter (client-side) and connection-rate limits (server-side) exist for this.
The Python angle: WSGI (the contract Flask speaks) is request-response; a WSGI worker cannot hold a websocket. Websockets in Python mean ASGI (FastAPI/Starlette, Django Channels) or bolting an async engine onto Flask (Flask-SocketIO with eventlet). Many teams sidestep the issue entirely: the boring REST API stays as-is, and a separate small real-time service (or a managed provider) handles the sockets. The two halves meet at the database and the pub/sub bus, not in one process.
5. The reliability layer you must build on top
The socket will drop: phones hop between WiFi and cellular constantly. Production real-time is the raw transport plus:
- Reconnect with exponential backoff and jitter, automatically, forever.
- Resume: every event carries an ID; on reconnect the client says "I have up to #4017" and the server replays the gap. This is the delta-sync cursor from Offline Data & Sync wearing a different hat: a socket is just a delta feed with very low latency.
- Persistence: messages are written to the database and pushed down sockets. The socket is a transport, not the truth. A client that missed the delivery must be able to fetch the same data over boring HTTP. (The single-source-of-truth rule, third appearance in the course.)
- Acknowledgements client→server (so the sender's UI can show sent/delivered) and de-duplication by ID, because reconnect overlap means the same event can arrive twice.
6. Auth on a websocket
Browsers cannot set an Authorization header on a websocket connection, so bearer-token habits break. The clean pattern is a ticket:
- Client calls a normal REST endpoint with its bearer token:
POST /ws/ticket. - Backend returns a short-lived, single-use ticket (seconds of validity).
- Client opens the socket with the ticket (
wss://api.example.com/ws?ticket=...); the server validates it, binds the connection to the user, and the ticket dies.
Long-lived tokens directly in the query string also "work" but leak into server and proxy logs; the ticket's short life is what makes the pattern safe. Native apps can set headers on the handshake, but the ticket pattern keeps web and native clients identical. After the handshake, the connection itself is the session; re-auth on reconnect only.
7. Buy vs build
Real-time tilts further toward buy than anything else in this course, because the hard part (stateful fan-out at scale) is pure undifferentiated heavy lifting.
| Option | Examples | Trade |
|---|---|---|
| Managed real-time | Ably, Pusher, Supabase Realtime, Firebase | Channels, presence, history, and scale as a product; per-connection/message pricing and a vendor in your data path |
| Self-hosted engine | Socket.IO (reconnection + rooms + fallbacks bundled), Centrifugo, Phoenix Channels (the gold standard) | Full control, no per-message fees; you run the backplane and eat the ops |
| Raw websockets | ASGI + websockets/Starlette by hand |
Maximum learning, minimum leverage; you rebuild §5 yourself |
8. Choosing: the decision rule
| Need | Use |
|---|---|
| Updates may be seconds-to-minutes stale (dashboards, prices, planner data) | Polling, and stop there without embarrassment |
| Server → client streaming only (feeds, notifications, progress, LLM tokens) | SSE |
| Genuinely bidirectional, sub-second (chat, collaboration, multiplayer, live cursors) | WebSockets, via a managed service or engine, rarely raw |
| User must hear about it when the app is closed | Push, alongside whichever of the above |
Applied to the running example: the planner app needs none of this. Cache plus pull-to-refresh (rung 1) is correct, and that is a feature of the design, not a limitation.
9. Common gotchas
| Symptom | Cause |
|---|---|
| Connections silently die after exactly N seconds | A proxy/load balancer idle timeout shorter than your heartbeat interval; heartbeat more often, or configure the timeout |
| Works locally, fails in production | Load balancer or CDN not configured to pass the Upgrade handshake / long-lived connections |
| Messages sent during a brief disconnect vanish | No resume cursor; the socket was treated as the truth (§5 violated) |
| Deploys cause outage-shaped traffic spikes | Reconnection storm without jitter or connection-rate limiting |
| The same chat message appears twice | Reconnect overlap without de-duplication by event ID |
| Battery complaints from mobile users | The app holds a socket (radio awake) for data that changes once a minute; polling or push was the right rung |
| "Online" shows users who left hours ago | Presence built on connects without TTLs; servers crash without sending disconnects |
| Flask endpoint "hangs" when a socket route was added | WSGI worker consumed by a long-lived connection; the ASGI/sidecar split in §4 |
The takeaway shape: real-time is a ladder (poll → long-poll → SSE → sockets): climb only as far as the product demands, because the top rung changes your backend from stateless to stateful, and that costs a backplane, a reliability layer, and a protocol of your own design. The socket is never the truth: it is a low-latency delta feed over the same single source of truth the sync lesson established, with the same cursors, the same idempotency, and boring HTTP always underneath as the fallback.
Check your understanding
- When is plain short polling the right answer, and what makes it defensible against "just use websockets"?
- What does the connection stop being after the
101 Switching Protocolsresponse, and what must you now design yourself? - Why does holding sockets require a pub/sub backplane, and why must the socket never be treated as the source of truth?
Sources and further reading
Reviewed 4 August 2026.