HTTP's missing half
HTTP is client-initiated. The server cannot speak first. Everything realtime on the web is a workaround for that one limitation, and the four workarounds differ in how honest they are about it.
Polling and long polling
Short polling asks every N seconds. Simple, stateless, and mostly wasted requests — 99% return “nothing new”. Fine for a dashboard refreshing every 30 seconds; ruinous at scale.
Long polling is smarter: the server holds the request open until it has something to say or a timeout expires, then the client immediately reconnects. You get near-realtime delivery over completely ordinary HTTP.
async function listen(since) {
while (true) {
const res = await fetch(`/api/events?since=${since}&wait=25`);
if (res.status === 204) continue; // timeout, no events
const { events, cursor } = await res.json();
since = cursor; // resume exactly where we left off
events.forEach(handle);
}
}- Works through every proxy, firewall and corporate network on earth.
- Costs a connection slot per client anyway, plus request overhead per message.
- There is always a reconnect window — the cursor is what stops it dropping events.
Server-Sent Events
SSE is a standardised long-lived HTTP response with a tiny text format. The server keeps writing; the browser's EventSource parses events and reconnects automatically, replaying the last event id so the server can resume.
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
id: 1042
event: order.status
data: {"id":"8812","status":"SHIPPED"}
: heartbeat comment to keep proxies from closing us
id: 1043
event: order.status
data: {"id":"8812","status":"DELIVERED"}const es = new EventSource("/api/orders/8812/stream");
es.addEventListener("order.status", e => render(JSON.parse(e.data)));
es.onerror = () => {/* the browser retries on its own, with Last-Event-ID */};- Free reconnection with resume — the browser resends
Last-Event-ID. This alone saves a week of work versus WebSockets. - Plain HTTP, so auth headers, cookies, compression and observability all just work.
- Text only, and one-directional. Under HTTP/1.1 browsers cap ~6 connections per origin; under HTTP/2 that limit disappears.
- Send a comment heartbeat every 15–30s or intermediaries will close idle streams.
WebSockets
A WebSocket starts as an HTTP request with Upgrade: websocket, gets a 101 response, and then the TCP connection stops being HTTP entirely. From there both sides send framed messages — text or binary — with almost no per-message overhead.
GET /ws HTTP/1.1 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 HTTP/1.1 101 Switching Protocols Upgrade: websocket Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
What you have to build yourself, because the protocol gives you none of it:
- Reconnection with exponential backoff and jitter.
- Resume — a sequence number or cursor so a reconnect does not lose messages.
- Heartbeats — ping/pong to detect a half-open connection the OS has not noticed.
- Auth — browsers cannot set headers on the upgrade, so use a short-lived ticket in the query string or authenticate in the first message.
- Message schema and routing — there is no method, no path, no status code. It is your protocol now.
Originheader yourself, or any site can open an authenticated socket to your server with the user's cookies attached.Comparison
| Short polling | Long polling | SSE | WebSockets | |
|---|---|---|---|---|
| Direction | Pull | Pull (simulated push) | Server → client | Bidirectional |
| Protocol | HTTP | HTTP | HTTP | TCP after upgrade |
| Latency | Up to the interval | Near real-time | Real-time | Real-time |
| Payload | Any | Any | UTF-8 text | Text or binary |
| Auto reconnect | n/a | Manual | Built in with Last-Event-ID | Build it yourself |
| Overhead per message | Full request | Full request | Few bytes | 2–14 bytes |
| Proxy / firewall safe | Always | Always | Almost always | Sometimes blocked |
| Server cost | Requests | Held connections | Held connections | Held connections |
| Best for | Slow-changing data | Universal fallback | Feeds, progress, tokens | Chat, games, collaboration |
Scaling stateful connections
Long-lived connections break the assumption that any server can serve any request. A user's socket lives on exactly one node, and an event produced elsewhere has to find it.
- Connection budget — file descriptors, memory per connection, and ephemeral ports on the load balancer. 100k+ per node is achievable but must be tuned deliberately.
- Backplane — Redis pub/sub, NATS or Kafka so any producer can reach any gateway.
- Topic granularity — subscribe per room or per entity, never “broadcast everything and filter on the node”.
- Deploys are the hard part — every restart drops every socket. Drain gradually and make clients reconnect with jitter, or you will thundering-herd yourself.
- Idle timeouts — load balancers kill quiet connections; heartbeats must be shorter than the shortest timeout in the path.
Backpressure and slow consumers
A client on a train receives slower than you produce. Without a policy, the per-connection buffer grows until the node runs out of memory — one bad client degrading everyone.
- Bound every outbound queue and pick a drop policy: newest-wins, coalesce, or disconnect.
- Coalescing is usually right for state — send the latest snapshot, not fifty stale deltas.
- Sample high-frequency telemetry server-side; a UI cannot render 1000 updates a second anyway.
- Watch buffered-amount metrics per connection and disconnect the worst offenders deliberately.
Webhooks: push between servers
When the receiver is another server rather than a browser, the answer is a webhook: an HTTP POST to a URL the consumer registered. No connection to hold, no gateway tier.
const sig = req.header("X-Signature");
const ts = Number(req.header("X-Timestamp"));
if (Math.abs(Date.now()/1000 - ts) > 300) return res.status(400).end(); // replay window
const expected = hmacSha256(secret, `${ts}.${rawBody}`);
if (!timingSafeEqual(sig, expected)) return res.status(401).end();
await queue.enqueue(rawBody); // do the work asynchronously
return res.status(202).end(); // respond fast — senders time out in seconds- At-least-once delivery. Store the event id and ignore duplicates.
- Out of order. Compare a version or timestamp before applying state.
- Retries with backoff on the sender side, plus a dead-letter queue and a manual replay endpoint.
- Never trust the payload alone for anything financial — treat it as a signal and re-read from the provider's API.
Choosing
| Requirement | Use | Why |
|---|---|---|
| Data changes every few minutes | Short polling | Cheapest thing that works; no new infrastructure |
| Notifications, live status, progress bars | SSE | One-directional, auto-reconnect, plain HTTP |
| Streaming model output token by token | SSE | Exactly the shape of the problem |
| Chat, cursors, multiplayer, trading | WebSockets | Genuinely bidirectional and low overhead |
| Hostile corporate networks | Long polling fallback | Nothing blocks plain HTTP |
| Notifying another company's server | Webhooks | No held connection, standard integration |
| Internal service streaming | gRPC streaming | Typed, multiplexed, with deadlines |
Interview framing
“Live order status is one-directional, so it goes over SSE — the browser reconnects on its own and replays Last-Event-ID, and it is ordinary HTTP so auth and observability are unchanged. Chat needs client-to-server messages continuously, so that gets WebSockets with heartbeats, a sequence number for resume, and Origin validation because CORS does not apply to the upgrade. Connections terminate on a dedicated stateful gateway tier fed by Redis pub/sub, keeping the app servers stateless, and every outbound queue is bounded with a coalescing policy so one slow client cannot exhaust a node. Partners are notified with signed, idempotent webhooks rather than being asked to hold a connection.”