WebSockets vs SSE vs Long Polling: Choosing a Real-Time Transport
Every design with a live element — a chat thread, an order status, a collaborative document, a dashboard that has to be current — eventually hits the same question: how does the client find out something changed? Most candidates answer it in four seconds ("WebSockets") and spend the rest of the interview paying for that choice without realising it, because a persistent connection is not a transport decision. It is an architectural one. It makes your edge stateful, and everything downstream of that is harder.
This post works through what actually separates the four options, the arithmetic that rules polling out at scale, why server-sent events are the right default more often than their reputation suggests, what the WebSocket upgrade genuinely buys, and the part nobody plans for: the connection that is dead but still open, and what happens to events during a reconnect.
The arithmetic that kills polling
Take a feed with 1,000,000 users online, each polling every 5 seconds.
1,000,000 / 5s = 200,000 requests per second
200,000 × ~1.5 KB = 300 MB/s ≈ 2.4 Gbit/s of headers
That 1.5 KB is request headers plus response headers plus an empty JSON body — cookies, a bearer token, User-Agent, TLS record overhead. The payload is zero. You are spending 2.4 Gbit/s to transmit the word "no".
Now the hit rate. If a given user actually has something new roughly once a minute, they issue 12 polls per minute and 11 of them return nothing. 92% of the requests are waste, and that waste is not just bandwidth — it is 200,000 TLS sessions per second to resume, 200,000 auth-token validations, 200,000 rows in your access log.
The part that matters for the interview is what happens when you try to make it feel fast. With uniformly arriving events, the average delay a poll introduces is half the interval, so a 5-second poll means a 2.5-second mean latency. To get mean latency under 250 ms you need a 500 ms interval, which is 2,000,000 requests per second. Polling's cost scales inversely with the latency you want. A push transport's cost does not: the connection is open either way, and an event that arrives in 10 ms costs the same as one that arrives in 10 minutes.
The four options, and what actually separates them
| Transport | Direction | Protocol | Reconnect | Per-event overhead | Through an L7 proxy |
|---|---|---|---|---|---|
| Short polling | client pulls | plain HTTP | n/a | full headers, ~1–2 KB | trivially fine |
| Long polling | client pulls, server holds | plain HTTP | implicit in the next request | full headers, once per event | fine, but read timeouts bite |
| SSE | server → client only | plain HTTP, text/event-stream | built into the browser, with Last-Event-ID | ~10 bytes of framing | fine once buffering is off |
| WebSocket | full duplex | HTTP Upgrade, then RFC 6455 frames | you build it | 2 bytes server→client, 6 client→server | needs explicit upgrade support |
The column that decides most designs is the last two. Once a stream is open, SSE and WebSocket are within a few bytes of each other for server-to-client traffic — the "WebSockets are more efficient" instinct is about client-to-server messages, where WebSocket's 6-byte frame header replaces a ~1 KB HTTP request. If your clients send one message a minute and receive hundreds, that advantage is worth almost nothing.
The direction column decides the rest. SSE is one-way by construction. That is not a limitation you work around: the client still talks to the server with ordinary POST requests, which keeps your write path on the same authenticated, rate-limited, observable HTTP machinery as the rest of your API. You only need a bidirectional socket when the upstream direction is frequent — cursor positions, keystrokes in a collaborative editor, a game loop — or when request/response round-trip latency on the upstream path genuinely matters.
Server-sent events: HTTP you already know
An SSE response is a 200 that never ends, with Content-Type: text/event-stream and a body in a line-oriented format:
id: 1875
event: order.updated
data: {"id":"ord_91","status":"shipped"}
: keepalive
Blank line terminates an event. A line starting with : is a comment the client ignores. That is essentially the whole specification, and three properties fall out of it.
Reconnect is free and correct. If you emit id:, the browser remembers the last one it saw, and after a drop it reconnects on its own and sends Last-Event-ID: 1875. You get the resume cursor without designing a protocol for it. With a WebSocket you write the backoff, the reconnect, the sequence tracking and the resume handshake yourself — and you will get the resume handshake wrong the first time.
It rides existing infrastructure. Cookies, bearer tokens, CORS, HTTP/2 multiplexing, gzip, your L7 load balancer's routing rules, your access logs, your tracing headers. Nothing special.
It has two traps, both silent. First, any proxy that buffers responses will hold your events until its buffer fills, so the stream works perfectly in development and delivers nothing in staging. Send Cache-Control: no-cache, no-transform and, for nginx, X-Accel-Buffering: no. Second, over HTTP/1.1 browsers cap concurrent connections to six per origin, and an open SSE stream occupies one of them — a user with four tabs open has one request slot left for the rest of your app. HTTP/2 multiplexes everything over a single connection and the problem disappears, so SSE in production means HTTP/2 or better. Note that WebSockets are not subject to that cap (Chrome allows 255), which is a real argument for them if you are stuck on HTTP/1.1.
WebSockets: what the upgrade buys
The handshake is an ordinary HTTP request with Upgrade: websocket and a Sec-WebSocket-Key. The server answers 101 Switching Protocols, and from that point the TCP connection carries RFC 6455 frames instead of HTTP: a 2-byte header, an opcode, a payload length, and a 4-byte masking key on anything the client sends. Full duplex, binary or text, with ping/pong control frames built in.
What you buy is a genuinely symmetric channel and tiny upstream overhead. What you pay:
- You own the reliability layer. No automatic reconnect, no resume semantics, no
Last-Event-ID. Every production WebSocket client ends up with jittered exponential backoff, a sequence number, and a replay request on reconnect. - You lose HTTP along the way. The negotiated connection is opaque to anything that understands HTTP. L7 routing by path, response caching, per-endpoint rate limits, request-level tracing,
Retry-After— none of it applies once you are past the upgrade. Your observability becomes "connections open" and whatever you build inside the frames. - No backpressure signal. HTTP/2 gives an SSE stream per-stream flow control. A WebSocket gives you nothing above TCP, so a slow client on a train means your server's send queue grows until something dies. You have to watch
bufferedAmounton the client or the socket's write queue on the server and decide to drop messages or close the connection — a real outage mode, not a theoretical one. - Middleboxes. Most things speak WebSocket now, but a corporate proxy that does not will fail the upgrade, and your fallback is long polling anyway.
Which I would pick
Default to SSE. For the overwhelming majority of "the page should update when something happens" problems — notifications, order and job status, live dashboards, feed updates, streaming LLM tokens — the traffic is server-to-client, the client's writes are infrequent, and the reconnect-and-resume behaviour you get for free is the single most valuable thing in the comparison. SSE costs you one endpoint and no new protocol.
Reach for WebSockets when the upstream direction is hot or symmetric: collaborative editing with cursor presence, multiplayer, anything where the client emits tens of messages a second, or where you need a true request/response pattern over the socket. Chat is the interesting boundary case — sends are rare enough that SSE plus POST /messages works fine, but typing indicators and read receipts push it toward a socket, which is why the chat system walkthrough assumes one.
Keep long polling as the fallback, not the plan. It is one HTTP request per event with full headers, and a held request occupies a worker and a proxy slot exactly like a socket does — the cost is comparable, the ergonomics are worse. It earns its place as a degradation path.
And consider not pushing at all. For data whose freshness requirement is measured in tens of seconds, a conditional GET with an ETag on a 30-second interval is ~33,000 req/s at a million users, mostly answered with a 304 of a few hundred bytes, and it keeps your edge completely stateless. That is often the correct engineering answer and it reads as judgment rather than ignorance — provided you say why.
The real cost is connections, not bytes
This is the part the four-second answer skips, and the part interviewers actually probe.
An idle connection is not free. On a tuned Linux box the kernel socket costs roughly 4–10 KB, but TLS dominates: OpenSSL allocates read and write record buffers of about 16 KB each per connection by default, so 35–45 KB per idle TLS connection is a realistic planning number unless you deliberately shrink the buffers.
100,000 connections × 40 KB ≈ 4 GB of RAM, doing nothing
Which puts a comfortable ceiling somewhere around 100k–200k connections per node, not the million you see in tuned benchmark posts. Two more limits show up before you get there: file descriptors (raise ulimit -n, mundane) and ephemeral ports on the proxy, because a proxy opening connections to a single backend IP and port is limited to roughly 28,000–64,000 source ports for that tuple. An L7 proxy terminating 200,000 client sockets needs multiple backend addresses or SO_REUSEPORT, and that surprises people.
Then deploys. A fleet holding a million open connections cannot be rolled like a stateless one: draining a node sends every client reconnecting at once, and without jittered backoff on the client the fleet re-handshakes a few hundred thousand TLS sessions in the same second. Stagger the drain, cap the reconnect rate, and give every client a random delay.
The architectural consequence is worse than the resource one:
Both answers are legitimate and the choice is a nice thing to have an opinion about. Broadcast-and-filter costs every gateway O(all events), which is fine until it is not: 50 nodes each filtering 100k events/s is 5M deliveries/s of mostly-discarded work. Sharding the topic space — hash the user id into one of a few hundred topics, assign topics to gateways with consistent hashing, and route the client's connection to the gateway that owns its topic — bounds that at the cost of a rebalance whenever the fleet changes. A registry in Redis (user:{id} -> gw-17, short TTL, refreshed by heartbeat) is the pragmatic middle, and you accept that a publish occasionally goes to a node that no longer holds the socket. The durable backlog described in message queues explained is what makes that acceptable: a missed push is recoverable because the event is still in a log.
Heartbeats, and the connection that is dead but open
TCP will happily keep a socket in ESTABLISHED forever when the peer has vanished — a laptop lid closed, a phone off wifi, a NAT table entry expired. Nothing tells either side. You have a connection object consuming 40 KB and a user who sees a live indicator and no messages.
So every push transport needs an application-level heartbeat, and the interval is set by the most impatient box in the path, not by you:
| Hop | Default idle timeout |
|---|---|
| AWS ALB | 60s (configurable) |
nginx proxy_read_timeout | 60s |
| Cloudflare (WebSocket) | ~100s |
| Carrier NAT | often 30–120s, undocumented |
Send something every 25–30 seconds and you are under all of them. For SSE that is a : keepalive comment line, which costs 13 bytes and is specified to be ignored. For WebSocket it is a ping frame; the peer must answer with pong, and that answer is the liveness signal — sending pings without timing out on the missing pong detects nothing. A server that closes a connection after two missed pongs reclaims memory promptly; one that does not will slowly fill with ghosts and fall over days after the deploy that caused it.
Reconnects are the feature, not the edge case
Clients disconnect constantly. On mobile, median connection lifetimes are measured in minutes. A design that only works while the socket is up works almost never.
The mechanism is small, and the ordering inside it is the part people get wrong. An SSE handler that replays the backlog and then subscribes to the live bus misses everything published in between:
app.get("/events", async (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no", // stop nginx buffering the stream
});
const userId = req.user.id;
let lastSent = Number(req.headers["last-event-id"] ?? 0);
// Subscribe FIRST, buffering, so nothing published during the
// backlog read falls into a gap.
const pending = [];
let streaming = false;
const unsubscribe = bus.subscribe(userId, (e) =>
streaming ? send(e) : pending.push(e)
);
for (const e of await backlogSince(userId, lastSent)) send(e);
pending.forEach(send);
streaming = true;
const beat = setInterval(() => res.write(": keepalive\n\n"), 25_000);
req.on("close", () => { clearInterval(beat); unsubscribe(); });
function send(e) {
if (e.seq <= lastSent) return; // dedupe backlog against live
lastSent = e.seq;
res.write(`id: ${e.seq}\ndata: ${JSON.stringify(e.payload)}\n\n`);
}
});
Three things are load-bearing. Subscribing before reading the backlog closes the gap. The e.seq <= lastSent guard makes the overlap between backlog and live harmless, which means the delivery is at-least-once and the client must tolerate duplicates — the same property, and the same fix, as in idempotency explained. And lastSent is per-connection state, so your backlog store has to be able to answer "everything for this user after sequence N" cheaply: a per-user ordered list in Redis with a few hundred entries, or a windowed table partitioned by day.
On the client, the thing to get right is backoff. A browser's native EventSource reconnects for you after ~3 seconds (tunable with a retry: field), which is fine for one client and a stampede for a million. If you are writing the reconnect yourself, jitter it: min(cap, base × 2^attempt) × random(0.5, 1.5).
What a push transport does not solve
Backgrounded mobile apps. iOS and Android suspend your process and tear down its sockets within seconds of backgrounding. No transport survives that. Reaching a user whose app is not in the foreground requires APNs or FCM, full stop — a push notification whose payload is usually just "something changed, come and fetch it". Any mobile design that answers "how do we notify the user?" with "WebSocket" is wrong, and it is a standard follow-up.
Delivery guarantees. The transport gives you ordered bytes on one connection and nothing else. Acknowledgement, retry, deduplication and ordering across reconnects are yours to build on top.
Ordering across devices or streams. Per-connection order says nothing about what a user's phone and laptop see relative to each other, or about events arriving on two different topics. If order matters globally you need a sequence the clients can reason about.
Fan-out cost. A persistent connection makes delivery cheap per event per connection; it does nothing about 400,000 connections needing the same celebrity's update. That is the same fan-out problem as a feed, with the same answers.
Auth that expires. A 15-minute access token on a 6-hour connection means you authorised the first second and nothing after. Either re-validate periodically over the channel and close on failure, or cap connection lifetime to the token's and let the client reconnect. Long-lived connections are how revoked sessions stay alive, and almost nobody mentions it unprompted.
Slow consumers. Covered above, but worth repeating as a limit: the transport will not tell you to stop sending. You decide what to drop.
The alternatives worth naming
Push notifications (APNs, FCM). Not a competitor to SSE — the complement. The only way to reach a backgrounded or offline client, at the price of OS-mediated, unordered, best-effort delivery you cannot inspect.
Webhooks. For server-to-server, inverting the direction is usually right: the recipient exposes an endpoint and you POST to it with retries and signatures. No connection state on either side.
gRPC server streaming. Excellent between services, and the natural choice if your internal traffic is already gRPC. In browsers it needs grpc-web and a translating proxy, which is a lot of machinery to re-create SSE.
WebTransport over HTTP/3. Multiple streams plus unreliable datagrams over QUIC, with no head-of-line blocking — genuinely better than WebSocket for anything latency-sensitive and loss-tolerant. Browser support is still uneven outside Chromium, so today it is a thing to name as a direction, not something to design around.
MQTT. Built for exactly this over bad networks, with three QoS levels, retained messages and last-will — the right answer for IoT fleets and a reasonable one for mobile. Facebook Messenger famously used it for precisely the battery and reconnect reasons.
Managed realtime layers. Ably, Pusher, Centrifugo, Phoenix Channels, API Gateway WebSocket APIs. These exist because the gateway tier, the registry, the heartbeats and the resume protocol are a quarter of engineering work that has nothing to do with your product. Saying "I would buy this before building it, and here is the piece I would still have to own" is a strong interview answer, not a cop-out — the piece you still own is the backlog and the sequence numbers.
Saying this in an interview
The transport question usually arrives disguised. "How does the user see the new message?" is it. "How does the UI know the job finished?" is it. The order that lands:
- Establish the direction and the rate first. "Server to client, a few events per user per minute, clients send rarely." That sentence picks the transport for you, and saying it out loud shows you did not pattern-match.
- Rule out polling with the arithmetic, not with a wave of the hand. A million users at a 5-second interval is 200k req/s and a 2.5-second mean delay.
- Pick SSE and justify it by reconnect, not by efficiency —
Last-Event-IDand the browser's built-in retry are the real win. Then say what would change your mind: hot upstream traffic, or symmetric latency. - Volunteer the statefulness. "This makes my edge stateful, so I need a gateway tier, a way to route an event to the node holding the socket, and a connection count per node I can defend."
- Name the heartbeat with a number. "Keepalive every 25 seconds, under the 60-second ALB idle timeout, and I close after two missed pongs."
- Pre-empt the two follow-ups that always come. Mobile background delivery needs APNs/FCM, and the gap during a reconnect needs a sequence number plus a short backlog.
Steps 4 and 6 are where the signal is. Anyone can name three transports; the candidates who stand out are the ones who treat the open connection as a resource with a budget and an owner, and who notice — before the interviewer asks — that their design has quietly become at-most-once.
If you want to hear how that holds up when someone interrupts to ask what happens to the events published while your gateway was draining, you can practise it out loud in a voice system design mock on Whitepad.
Practice this out loud
Reading is the easy part. Sit across from a senior AI interviewer that talks, watches your whiteboard, and scores you like the real thing — your first mock is free.
Start a free mock →