Engineering Atlas › System Design Fundamentals

Near-real-time updates via long polling

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

From polling to long polling

Plain polling asks every few seconds whether anything changed. With a 5-second interval the average delay is 2.5 seconds, and most requests come back empty: 10,000 clients make 2,000 requests per second to learn, usually, that nothing happened.

Long polling moves the waiting to the server. The client sends GET /events?cursor=118. If event 119 already exists, the server answers at once. If not, it parks the request and answers the moment event 119 is published, or after, say, 25 seconds with an empty response. Either way the client sends the next request straight away. Delivery delay drops to about one network round trip, and empty responses drop to one per client per timeout period.

The cursor is the protocol

Between one response and the next request there is a gap of a round trip or more, and events published in that gap must not be lost. So the client never asks "what is new?" but "what is after the last thing I saw?". The server keeps a short, ordered log per channel and answers from it. A response carries the events plus the new cursor; a retry with the same cursor gets the same answer, so duplicates are harmless and nothing falls through the gap.

The cursor also bounds what the server must keep. If a client returns with a cursor older than the retained log, the server says so explicitly (etcd returns a "compacted" error; the Kubernetes API answers 410 Gone and the client must re-list (Kubernetes docs)) and the client reloads full state. That is the ordering and duplicates problem shape: the same cursor-and-log design appears in Kafka consumer offsets and in database replication slots.

Where you meet it

Long polling is common between servers, where it is often the best tool. SQS's ReceiveMessage with WaitTimeSeconds=20 (the maximum) is long polling, which is why idle consumers cost almost nothing; note that short polling is the default, so a consumer that never sets the wait time pays for a stream of empty receives (SQS docs). Consul's blocking queries pass ?index=<X-Consul-Index>&wait=5m and return when the watched data's index moves past it; the wait defaults to 5 minutes and is capped at 10 (Consul docs). Telegram's getUpdates takes offset and timeout. CI runners long-poll for jobs. In browsers, it was the core of Comet-era chat, and Socket.IO still opens every connection on HTTP long polling by default, then tries to upgrade to a WebSocket, so a blocked upgrade quietly leaves the client on polling (Socket.IO docs).

The same shape, other transports

Server-Sent Events keep one response open and stream many events through it; WebSockets upgrade the connection to a two-way message channel. Both avoid a request per event. Long polling pays a full request and response per batch, but needs nothing from the network except plain HTTP request-response, and each request is independent, so it can land on any server.

Down the stack

Layer 1The contract

A long-poll endpoint promises: given cursor c, return every event with sequence greater than c, in order, up to a batch limit, as soon as at least one exists; if none arrives within the wait time, return an empty result with the cursor unchanged. If c is older than the retained history, return a distinct error, never a silent partial answer. Delivery is at-least-once from the client's point of view, since a response lost in transit is simply re-fetched with the old cursor; idempotence comes from the cursor, not from the transport. Worst-case latency for an event is one round trip if a request is parked, and up to about two round trips if it lands in the gap between requests.

Layer 2The mechanism

The server keeps, per channel, an append-only buffer of (seq, payload) and a list of parked waiters (client, cursor, deadline). A request checks the buffer first, which is the step that closes the gap; only if nothing is newer does it park. A publish appends to the buffer and completes every waiter on that channel. A timer wheel or a min-heap keyed by deadline completes waiters that time out, with 200 and an empty list or 204 No Content.

Real systems differ in the details. Consul returns the index in the X-Consul-Index header and adds up to wait/16 of random jitter to each wait, so watchers do not all wake together. It also tells clients to reset to 0 if the index ever goes backwards. Since Consul 1.10 an optional streaming backend can replace long polling for some queries, the same move Kubernetes made. SQS checks all its storage hosts during a long poll rather than the weighted random sample a short poll reads, which is why it also returns fewer false empties (SQS docs). Kubernetes started with this pattern and evolved into a streaming watch keyed by resourceVersion, one response that never ends. RFC 6202 (2011) records the classic browser problems (RFC 6202). The old HTTP/1.1 advice of two connections per server had just been relaxed to 6 or 8 in browsers, and a parked request permanently uses one of them, shared across every tab on that origin. Caching must be suppressed with Cache-Control: no-cache (today no-store is the stricter choice). Over HTTP/2 the parked request is just one stream on a shared connection, so the per-host limit mostly stops mattering.

When the server is a fleet, a publish on node A must wake a waiter parked on node B. The usual answer is a pub/sub backbone (Redis pub/sub, NATS, Kafka) that every node subscribes to for the channels its parked clients care about, the same fan-out design used for WebSocket gateways.

Layer 3The machine

A parked request costs an open socket, its kernel buffers (a few KB while idle), the TLS session state (tens of KB in many libraries) and the server's own bookkeeping: a goroutine with a 2 to 8 KB stack in Go, a small promise and closure in Node, but a whole 1 MB-stack thread in a thread-per-request server, which is why long polling killed many early Java and PHP deployments. With epoll, 100,000 parked requests cost the CPU nothing until an event or deadline arrives. Each cycle re-sends HTTP headers, often 500 to 1,000 bytes in each direction, unless HTTP/2 HPACK compresses repeats.

Timeouts are set by the network, not the application. AWS ALB closes idle connections after 60 seconds by default (ALB docs), nginx's proxy_read_timeout is 60 seconds (nginx docs), and many corporate proxies and mobile carrier NATs drop idle flows after 30 to 120 seconds without telling anyone. A 25 to 30 second wait stays safely under all of them; RFC 6202 reports experiments with 120 seconds but calls 30 the safer value. On phones every completed poll can wake the cellular radio, which then stays in a high-power state for several seconds, so long waits are kinder to batteries.

Why it's built this way

  1. Plain HTTP is the one thing every network path supports, so push is emulated with requests the server answers late.
  2. Requests can be lost and there is a gap between them, so the client names its position with a cursor and the server keeps a short log.
  3. Intermediaries kill idle connections at 60 seconds or less, so the server answers empty well before that and the client re-asks.
  4. Many clients wake at once after an outage or a broadcast, so waits and reconnects get jitter and backoff.
  5. Each event costs a full HTTP exchange, so at high event rates the cost argues for a streaming transport instead.

Diagram

sequenceDiagram
  participant C as Client
  participant S as Server
  participant P as Publisher
  C->>S: GET /events?cursor=118
  S->>S: nothing after 118, park (deadline 25s)
  P->>S: publish event 119
  S-->>C: 200 [119], cursor=119
  C->>S: GET /events?cursor=119
  P->>S: publish event 120 (already buffered)
  S-->>C: 200 [120], cursor=120
  C->>S: GET /events?cursor=120
  S->>S: park
  S-->>C: 200 [] after 25s, cursor=120
  C->>S: GET /events?cursor=120

Worked example

A logistics dashboard shows live delivery status to 80,000 concurrent dispatchers. Each dispatcher's channel sees about one update per minute; a wait time of 25 seconds is used.

Plain polling every 5 seconds: 80,000 / 5 = 16,000 requests per second, of which about 98% are empty, and an average delay of 2.5 seconds.

Long polling: each client completes a request when an update arrives (1 per 60 seconds) or when the wait expires. With updates arriving on average every 60 seconds, about 2 of every 3 cycles end in a timeout, so each client makes roughly 3 requests per minute: 80,000 x 3 / 60 = 4,000 requests per second, and updates arrive within one round trip, about 80 ms. At about 1.5 KB of headers per exchange that is 6 MB/s, against 24 MB/s for plain polling.

Memory: 80,000 parked requests across 8 Go servers is 10,000 each; at roughly 40 KB per connection including TLS and buffers, that is 400 MB per server. Fine.

The failure case: a deploy restarts one server, dropping 10,000 connections at once. If clients reconnect immediately, the remaining 7 servers take 10,000 new TLS handshakes in under a second on top of normal load. Clients that wait a random 0 to 5 seconds first spread that to about 2,000 per second, and the cursor guarantees nobody misses an update while they wait.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Short pollingTrivial, cacheable, statelessWasted requests, delay of half the intervalData changes slowly or staleness is fine
Long pollingNear-instant, works on any HTTP path, each request independentA request per batch, parked connections, timeout tuningOccasional events, server-to-server consumers, restrictive networks
Server-Sent EventsOne stream, many events, auto-reconnect with Last-Event-IDOne direction only, some proxies buffer responsesLive feeds and notifications to browsers
WebSocketsTwo-way, low overhead per messageStateful connections, harder to balance and scaleChat, games, collaborative editing
WebhooksNo connection held at allReceiver must be reachable and verify signaturesServer-to-server notifications across companies

Pitfalls in production

Leadership lens

Check yourself

  1. Why does a long-poll client send a cursor instead of just asking for new events?

    Show answer

    Events can be published in the gap between one response and the next request, or a response can be lost. The cursor lets the server return everything after the last event the client actually saw.

  2. A load balancer has a 60-second idle timeout and the server waits 90 seconds. What do clients observe?

    Show answer

    Quiet channels see their requests cut at 60 seconds with a gateway error or reset, then retry, so every idle client generates errors and wasted reconnects instead of clean empty responses.

  3. Why is long polling a good fit for SQS consumers but a poor fit for a multiplayer game?

    Show answer

    Queue consumers receive occasional batches and need no persistent connection, so holding a request until work appears is ideal. A game sends many small messages per second in both directions, and paying a full HTTP exchange for each is too slow and expensive.

  4. What should the server do when a client's cursor points before the oldest retained event?

    Show answer

    Return an explicit error telling the client to reload full state, never a partial list that silently skips events.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →