Support

Lesson 9 of 11 ·11 min read

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
WhenApp closed or backgroundedApp open, screen active
LatencySeconds, sometimes minutesSub-second
ReliabilityBest-effort, OS-mediatedReliable 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.

Rung 1

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.

Rung 2

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.

Rung 3

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.

Rung 4

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:

WebSocket upgrade handshake
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.

Chat frames (sample)
-> { "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:

  1. Reconnect with exponential backoff and jitter, automatically, forever.
  2. 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.
  3. 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.)
  4. 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:

  1. Client calls a normal REST endpoint with its bearer token: POST /ws/ticket.
  2. Backend returns a short-lived, single-use ticket (seconds of validity).
  3. 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.

OptionExamplesTrade
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

NeedUse
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 closedPush, 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

SymptomCause
Connections silently die after exactly N secondsA proxy/load balancer idle timeout shorter than your heartbeat interval; heartbeat more often, or configure the timeout
Works locally, fails in productionLoad balancer or CDN not configured to pass the Upgrade handshake / long-lived connections
Messages sent during a brief disconnect vanishNo resume cursor; the socket was treated as the truth (§5 violated)
Deploys cause outage-shaped traffic spikesReconnection storm without jitter or connection-rate limiting
The same chat message appears twiceReconnect overlap without de-duplication by event ID
Battery complaints from mobile usersThe 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 agoPresence built on connects without TTLs; servers crash without sending disconnects
Flask endpoint "hangs" when a socket route was addedWSGI 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 Protocols response, 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.