WebSockets, SSE and Long Polling

Topics Covered

The Problem With Request Response

What server-initiated means and why it is hard

Short polling, and the arithmetic that condemns it

Long polling, and what it actually buys

The gap in long polling that people forget

Why not just keep a connection open

Where the cost moves

Choosing by direction and shape

The case for polling that engineers resist

Conditional requests, the middle ground

The mechanism is not the architecture

HTTP is a client-initiated protocol: the server answers questions and cannot raise its hand. Every mechanism in this lesson exists to work around that one asymmetry, and each works around it differently, with different costs.

What server-initiated means and why it is hard

The requirement is simple to state. Something happens on the server, a trade fills, a message arrives, a build finishes, and the client needs to know within a short time without having asked at that moment.

HTTP offers no way to express this, because a response exists only in reply to a request and a server holds no addressable channel back to the client. Worse, the client is usually unreachable: it is behind NAT, behind a firewall, on a mobile network with no stable address, so the server could not open a connection to it even if the protocol allowed.

That constraint forces every solution into the same shape: the client must establish and hold something, and the server pushes through it. The question is only what that something is and how long it lives.

Short polling, and the arithmetic that condemns it

The simplest approach is to ask repeatedly. The client requests every few seconds and the server answers immediately with whatever it has.

The cost is easy to quantify and usually decisive. Average detection latency is half the interval, so a five-second poll means two and a half seconds of typical delay. Request rate is clients divided by interval, so ten thousand clients polling every five seconds is two thousand requests per second whether or not anything happened.

req/s=NclientsTinterval,E[latency]=Tinterval2\text{req/s} = \frac{N_{\text{clients}}}{T_{\text{interval}}}, \qquad E[\text{latency}] = \frac{T_{\text{interval}}}{2}

The two goals are in direct opposition: halving the latency doubles the load. And the overwhelming majority of those requests return nothing, each still paying request headers, a response, authentication, and whatever database work the handler does before discovering there is nothing to report.

Long polling, and what it actually buys

Long polling inverts the wait. The client issues a request and the server holds it open, not responding until there is something to send or a timeout expires. The client responds to each reply by immediately issuing another request.

This decouples latency from request rate, which is the whole point. Detection latency becomes the server's push latency rather than half an interval, while the request rate drops to roughly clients divided by the timeout, so a thirty-second timeout serves ten thousand clients with a few hundred requests per second.

What it costs is a held request per client on the server, which means the server needs an architecture where a waiting request is cheap. On a thread-per-request server, ten thousand held requests is ten thousand threads and the model collapses. On an event-driven or coroutine-based server it is ten thousand cheap suspended handlers, which is entirely feasible, and this is a large part of why asynchronous server frameworks became standard.

Short polling trades latency against load directly. Long polling breaks that link by holding the request. A persistent connection removes the per-event request entirely.

The gap in long polling that people forget

Between receiving a response and issuing the next request there is a window in which the client has no outstanding request, and anything the server wants to send during it has nowhere to go.

The window is short, a round trip plus whatever the client does to process the message, but it is not zero, and a server that publishes during it must hold the event rather than dropping it. That is why long polling needs a cursor: the client sends the identifier of the last event it saw, and the server returns everything after it, so events produced during the gap are delivered on the next request rather than lost.

This is the single most common long polling bug. It is invisible at low event rates, because the gap rarely coincides with a publish, and it becomes a steady trickle of lost messages as the event rate rises, which makes it look like an intermittent reliability problem rather than a design error.

Why not just keep a connection open

If a held request works, the obvious extension is to hold a connection and send many messages over it, which is what the rest of this lesson is about.

Two mechanisms do this over HTTP. Server-Sent Events uses a single HTTP response whose body never ends, with the server appending text-formatted events as they occur. WebSockets upgrades the connection out of HTTP entirely into a bidirectional message protocol.

Both remove the per-message request overhead, both deliver with minimal latency, and both introduce the real difficulty of this area, which is not the protocol but the operational consequence: a system holding a hundred thousand long-lived connections behaves quite differently from one serving a hundred thousand short requests.

Where the cost moves

It is worth being explicit about the trade, because it is the reason this is a design decision rather than an obvious upgrade.

Short requests are stateless and therefore easy. Any instance can serve any request, load balancing is trivial, a deploy drops connections nobody cared about, and capacity is a function of request rate alone.

Long-lived connections make the server stateful in a specific sense: each connection is pinned to one instance, that instance holds memory and state for it, the instance cannot be removed without disrupting those clients, and capacity becomes a function of concurrent connections as well as message rate. Every operational problem later in this lesson descends from that.

Choosing by direction and shape

A short decision procedure covers most cases, and it starts with direction rather than with the technology.

If data flows only server to client, Server-Sent Events is usually right: it is plain HTTP, it reconnects automatically with built-in resumption, and it needs no special framing. If both directions carry frequent messages, WebSockets is right, since SSE would need a separate request channel for the uplink. If updates are infrequent and latency tolerance is seconds, polling is right and is the cheapest thing to operate. If the client can only make ordinary requests, through a restrictive proxy or an environment without streaming support, long polling is the fallback that works almost everywhere.

The case for polling that engineers resist

Polling has a bad reputation it partly does not deserve, and defending it correctly is a useful instinct.

If an update is needed within a minute rather than a second, polling once a minute is trivially cheap, needs no new infrastructure, survives every proxy, costs nothing during a deploy, and has no connection state to manage. For a dashboard refreshed every thirty seconds, polling is the correct engineering answer and a WebSocket is over-engineering with real operational cost.

The argument for pushing gets strong when latency requirements fall below a few seconds, when the client count is high enough that empty polls dominate load, or when the semantics genuinely need a stream, such as a chat or a live collaborative document.

Conditional requests, the middle ground

Between naive polling and pushing there is a cheap improvement that is often enough: make the empty poll nearly free.

A poll that sends If-None-Match with the ETag it last saw, and gets back 304 Not Modified with no body, costs a round trip and a few hundred bytes, and the server can often answer it from a cached version token without touching its database. That removes most of the cost of polling while keeping all of its simplicity.

Combining that with a slightly shorter interval frequently lands in a better place than a push mechanism would, for a fraction of the operational work. The reason to reach past it is latency below a second, or a per-client event stream where no shared validator exists.

The mechanism is not the architecture

One framing worth carrying out of this section: the transport choice is the smallest decision in a push system, and teams spend most of their deliberation on it.

Whichever mechanism is chosen, the same questions have to be answered. Where does the event come from, and how does it reach the process holding this client's connection. What happens to an event produced while a client is away. How does a client know it has missed something. What happens when a client reads slower than the server writes. How does a deploy avoid disconnecting everyone at once.

Polling answers several of these for free, which is its real advantage: the client's next request is its own resumption mechanism, there is no fanout problem because every request finds its own data, and a deploy is invisible. Each push mechanism removes latency and hands those questions back to you.

So the useful order is to settle the latency requirement, then the resumption and fanout design, and only then the transport, which by that point is usually obvious.

Interview Tip

Before building any push mechanism, write down the actual latency requirement and the client count, then compute the polling cost. Ten thousand clients polling every thirty seconds is 333 requests per second, which is nothing, and a conditional request answered with 304 from a cached validator makes each one almost free. Push infrastructure is worth its operational cost below roughly a one-second requirement, or when per-client event streams make a shared validator impossible. Above that, polling is usually the correct answer and is rejected for aesthetic rather than engineering reasons.