Module A-10·32 min read

Long polling vs SSE vs WebSockets, connection fan-out at a million sockets, presence that is not a lie, and pub/sub that survives a reconnect storm.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module A-10 — Real-Time Systems

What this module covers: The four ways a server can push to a client, and the practical rule most teams get wrong — choose SSE unless you genuinely need the client to stream too. Why holding connections makes your servers stateful, and what that costs at deploy time: a reconnect storm whose expense is authentication and state rehydration rather than sockets. Scaling to a million connections, where the real bottleneck is fan-out writes rather than connection count, and where the ephemeral-port limit is widely misunderstood. The backplane that lets any server reach any client, and the subscription topology that quietly multiplies every message by your server count. Presence that is not a lie, since TCP will happily hold a socket to a phone that lost signal an hour ago. Delivery guarantees over a socket — a send() is not a delivery — and the sequence-plus-replay design that survives a reconnect. Backpressure on a slow client, and conflation as the technique that makes high-frequency data tractable. Then the security and infrastructure details that break real-time specifically: CORS not applying to WebSockets, a JWT that expires mid-connection, and a load balancer's 60-second idle timeout.


Four Mechanisms, and the Rule for Choosing

HTTP is request/response: the client asks, the server answers. Real-time needs the opposite direction, and there are four ways to get it.

DirectionPer-message overheadReconnect/resumeProxy-friendlyUse when
Pollingclient asksfull HTTP requesttrivial (stateless)yesfrequency ≥ several seconds
Long pollingclient asks, server holdsfull request per messagetrivialmostlylegacy support needed
SSEserver → client~a few bytesbuilt in (Last-Event-ID)yesnotifications, feeds, progress, token streaming
WebSocketbidirectional~2–6 bytes/frameyou build itneeds configclient also streams: chat, collaboration, games

Polling is not embarrassing. If the requirement is "the dashboard should be roughly current," a 10-second poll is stateless, cacheable, survives deploys with no reconnect logic, and needs no backplane. The latency is half the interval on average, and the waste is real but often trivially affordable. Reach for something else when the interval you would need is under a couple of seconds, or when the poll is expensive relative to the change rate.

Long polling holds the request open until data arrives or a timeout fires. It gets you push latency over ordinary HTTP — and it costs a held connection per client anyway, so you pay WebSocket's resource profile with HTTP's per-message overhead. It exists for environments that cannot do better.

Server-sent events is a plain HTTP response that never ends, streaming data: lines. Its underrated advantages: automatic reconnection with resume is in the browser, via the Last-Event-ID header replaying from where the client left off; it passes through proxies as ordinary HTTP; and it needs no special server protocol.

text

Its limits: one direction only, text only (base64 for binary), and the HTTP/1.1 six-connections-per-origin limit — a user with four tabs open exhausts the browser's connection budget for your domain, which manifests as the rest of your site hanging. Over HTTP/2 this disappears because streams are multiplexed (Module F-6), which makes SSE substantially more attractive than its reputation suggests.

WebSockets give a full-duplex frame-based channel after an HTTP upgrade. Genuinely necessary when the client streams — chat, cursors, collaborative editing, gameplay. The costs are the ones people underestimate: no reconnect or resume semantics (you implement both), HTTP caching and standard header-based auth no longer apply, load balancers and proxies need explicit configuration, and every message needs its own framing convention because the protocol gives you only bytes.

The rule: use SSE unless the client needs to stream to the server. Most "real-time" features are server→client — notifications, live counts, order status, progress bars, LLM tokens — and for those SSE gives you reconnection and replay for free, which is precisely the machinery teams end up hand-rolling badly on WebSockets. Client→server events can go over ordinary POST requests; they are usually infrequent.

(WebTransport over HTTP/3 is the emerging option, adding unreliable and unordered datagrams alongside reliable streams — genuinely useful for games and media. WebRTC data channels solve peer-to-peer and very low latency, at the cost of a signalling server, ICE/TURN infrastructure, and considerable complexity.)

Analogy: the difference between checking your letterbox (polling), standing at the letterbox until something arrives (long polling), a radio broadcast you tune into and which tells you the last bulletin number so you can catch up after a dropout (SSE), and a telephone call (WebSocket). The telephone is the most capable and the only one where hanging up loses the conversation, where the exchange must remember which line you are on, and where nobody can tell whether you are still listening unless one of you speaks.


Connections Make Servers Stateful, and Deploys Become the Problem

A stateless HTTP server can be replaced at will: drain, exit, start a new one, and no client notices (Module A-5). A server holding 40,000 WebSockets cannot — terminating it drops 40,000 connections that must all re-establish.

That is the reconnect storm, and the arithmetic decides whether it is a non-event or an outage:

text

The expensive part of a reconnect is not the socket. It is what happens on connect: the TLS handshake (CPU), the token validation, the session lookup, the subscription restore, and the "what did I miss?" query — typically several database or cache reads per client. A million clients reconnecting is a million-fold read spike against your database, which is Module P-12's thundering herd in its most concentrated form.

The mitigations, all of which must be in place before you have a million connections:

  • Jittered exponential backoff in the client (Module A-6), non-negotiable. Without jitter, every client that dropped at the same moment retries at the same moment, forever.
  • Stagger the rollout deliberately, and treat "how long does a full fleet replacement take" as a design parameter rather than whatever your orchestrator defaults to.
  • Tell the client when to come back. On graceful shutdown, send a close frame with a reconnect hint — a randomised delay the server chooses — so the server controls the return rate rather than hoping clients are polite.
  • Make reconnect cheap. Cache the session lookup, make the "what did I miss" query a single indexed read from a cursor rather than a scan, and consider a resumable token that avoids re-authenticating from scratch.
  • Do not require sticky routing. If a client can reconnect to any instance and still receive its messages, you have removed an entire class of problem — which is what the backplane below is for.

Scaling Connections: the Bottleneck Is Fan-Out, Not Sockets

Holding many connections costs four things: memory per connection (socket buffers plus TLS state, tens of kilobytes each), file descriptors, CPU for TLS, and — the one that actually binds — the writes.

Rough figures worth having: a tuned Node process holds on the order of 50,000–100,000 idle WebSocket connections, memory-bound at roughly 10–50 KB each. So a million connections is 10–20 processes plus headroom — a real cluster, but not an exotic one.

Two misconceptions to clear up:

File descriptors are a limit you must raise, not a limit that stops you. ulimit -n defaults are often 1,024, which fails at a trivial connection count and produces confusing EMFILE errors. Raise it explicitly.

The 65,535 port limit is not a per-server connection limit. A TCP connection is identified by the four-tuple (source IP, source port, destination IP, destination port), so a server listening on one port accepts connections from many clients without exhausting anything. Ephemeral port exhaustion affects outbound connections from one host to one destination — which is a real problem for a proxy or a server making many connections to one backend, and not a limit on inbound sockets.

And the real bottleneck: fan-out writes. One message to a room of 10,000 subscribers is 10,000 socket writes. At 100 messages/second into that room, that is a million writes per second — and the connection count barely mattered.

The single most effective optimisation is to serialise (and compress) once and write the same buffer to every socket:

ts

Related: permessage-deflate compression is enabled by default in some stacks and is CPU-expensive per message per client. For small frequent messages it can cost more than it saves; measure before enabling, and prefer compressing once at the application level if payloads are large.

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.