See It Flow

Core Concepts

Long Polling

Ask once. The server waits for change, then responds immediately. Eliminates empty responses while staying within standard HTTP.

Mangesh MandlikLast updated:

What Is Long Polling?

One request, held open until there is something to say

Long polling is a client-driven update mechanism in which the server keeps an HTTP request open for a bounded period while it waits for a change. When new information appears, the server answers; the client handles the response and immediately sends another request. If nothing changes before the timeout, the server sends an empty or unchanged response and the client reconnects. Unlike ordinary polling, it does not need a new request every few seconds while the system is idle.

Picture a support dashboard waiting for a new ticket. With five-second polling it keeps asking “anything new?” and mostly hears “no.” With long polling it asks once, and the server waits up to, say, 25 seconds before replying. If a ticket arrives after three seconds, the response goes out at once. That is near-immediate notification, but it is not a permanently open streaming response: each HTTP response completes one request, and the client has to ask again. Everything hard about the technique lives in the seams between those requests: the gap, the timeouts and the number of requests parked at any moment.

The comparison below uses one minute of traffic. Two events happen, at 3 s and 41 s. Polling every five seconds sends twelve requests and sees them at 5 s and 45 s, which is the average-case cost of an interval: a delay of anywhere from zero up to one full interval. The long poll needs four requests, and each event ends the request that was waiting for it.

Five-second polling versus a 25-second long poll over one minuteOver 60 seconds, polling every 5 seconds issues 12 requests and delivers event A (created at 3 s) at 5 s and event B (created at 41 s) at 45 s. A 25-second long poll issues 4 requests in the same window and delivers A at about 3 s and B at about 41 s. Green requests carried an event; grey requests ended empty or are still waiting.Polling, every 5 sLong poll, 25 s hold0102030405060secondsA seen at 5 sB seen at 45 sAtimeout, emptyBnext holdA created 3 sB created 41 s
Five-second polling versus a 25-second long poll over one minute. Same two events, same minute. Polling delivers them 2 s and 4 s late and spends 12 requests; the long poll delivers them as they happen and spends 4. Reconnect gaps are drawn as zero width; in practice each is one network round-trip.

Two things in the figure matter later. The grey request that ends empty at 28 s is not an error: it is the server saying “still nothing” so that the connection can be renewed before a proxy or a client timer decides to end it. And the request count only stays low while events are rare. In a busy stream each event still costs one request, so a long poll under heavy traffic behaves like back-to-back polling.

Visual Story

Watch a held request, a response and the reconnect

Long Polling

Long Polling
Request Held
0 / 0
📨Up nextData Arrives

⤢ Expand for synchronized narration timeline

The Protocol Flow and Reconnect Gap

What actually crosses the wire, and what can fall between two requests

A practical long-poll request carries a cursor that identifies the last event the client has seen. The request is an ordinary GET; the response is an ordinary 200. Nothing in HTTP knows that this one is slow on purpose.

Event available: answered immediately
GET /ticket-events?after=ev_104 HTTP/1.1
Host: api.example.com
Authorization: Bearer <access token>
Accept: application/json

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

{"events":[{"id":"ev_105","type":"ticket_created","ticket_id":"T9"}],"next_cursor":"ev_105"}

The cursor travels as a query parameter, so every poll has a distinct URL. Cache-Control: no-store stops shared caches and the browser from replaying an old answer to a later request.

The server first checks for events after ev_104. If any exist it returns them immediately, with no waiting at all. Otherwise it registers a bounded wait, wakes when a new event is committed, rechecks durable state and responds. On timeout it returns an empty result carrying the same cursor (or an appropriate no-content response; a 200 with an empty list is easier for clients to handle uniformly). The client reconnects using the latest successfully processed cursor.

Nothing happened: bounded wait ends
GET /ticket-events?after=ev_105 HTTP/1.1
Host: api.example.com
Authorization: Bearer <access token>

   … 25 seconds pass, no new event, no bytes sent …

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

{"events":[],"next_cursor":"ev_105"}

A successful empty response means 'no event observed within this wait', not 'no event will ever come'. The client reconnects with the same cursor.

Why a long poll can miss events: the reconnect gap

There is a gap between receiving one response and establishing the next request. During that gap the server holds no request for this client. If the design relies only on transient in-memory notifications (“push to whoever is waiting right now”), an event arriving in the gap has nobody to be pushed to and is lost. A durable event log or a queryable change table closes the gap: every request checks for events after the cursor before it waits. The wakeup signal is an optimisation that makes delivery fast; the durable query is the source of truth that makes it correct.

Long-poll request sequence across a reconnect gapThe browser asks for events after ev_104. The gateway registers a wakeup subscription, queries the event store and finds nothing, then waits. Event 105 commits, the notifier wakes the gateway, which re-reads the store and returns 105 with next_cursor ev_105. The browser processes 105. While it reconnects, event 106 commits with nobody waiting. The next request with after=ev_105 finds 106 in the store and returns immediately. The cursor and durable catch-up query prevent missed updates across reconnects.BrowserclientGatewayapp instanceEvent storedurableNotifierwakeup channel1. GET /ticket-events?after=ev_1042. subscribe (register wait)3. readAfter(104)4. [] nothing yet5. event 105 committed6. wakeup7. readAfter(104)8. [105]9. 200 {events:[105], next_cursor:ev_105}10. process 105, save cursor11. event 106 commits in the gap12. GET /ticket-events?after=ev_10513. readAfter(105)14. [106] already there15. 200 {events:[106], next_cursor:ev_106}
Long-poll request sequence across a reconnect gap. The cursor and durable catch-up query prevent missed updates across reconnects. Event 106 was announced to nobody, yet the next request still receives it because every request reads the store before it waits. Dashed arrows are replies.

Follow event 106 in the sequence. It commits while the browser is between requests, so no wakeup reaches anyone. When the browser comes back with after=ev_105, the very first thing the gateway does is read the store, and 106 is sitting there. The gap lasted one network round trip plus whatever the client spent processing, and the cursor made its length irrelevant to correctness. It still matters to latency: a client that spends two seconds applying a heavy batch before reconnecting delays the next event by about two seconds even though nothing is lost.

What the cursor must guarantee

The store's cursor needs a documented ordering scope. An opaque, sortable event ID works if the backend guarantees the order. A random UUID is not inherently an ordered cursor, so “everything after this UUID” has no meaning. If several partitions are involved, a single scalar cursor may not be enough: use a merged stream with a defined order, or a vector of per-partition offsets.

Even a simple auto-increment column has a trap. In PostgreSQL, sequence values are handed out when a row is inserted, not when its transaction commits, so a transaction holding seq=105 can commit after another that holds seq=106. A reader that sees 106, saves it as its cursor and asks for seq > 106 will skip 105 forever. Serialising writers (as below), assigning the sequence inside a single writer process, or reading only up to a watermark that every older transaction has passed are all ways to make “after this cursor” safe.

Event table, writer and catch-up query (PostgreSQL)
-- PostgreSQL. Runnable as written.
CREATE TABLE ticket_event (
  seq        bigserial   PRIMARY KEY,          -- the cursor: ev_<seq> on the wire
  tenant_id  text        NOT NULL,
  type       text        NOT NULL,
  payload    jsonb       NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ticket_event_tenant_seq ON ticket_event (tenant_id, seq);

-- Writer: one transaction. The advisory lock makes seq order equal commit order,
-- so a reader can never see seq 106 while seq 105 is still uncommitted.
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('ticket_event_writer'));
INSERT INTO ticket_event (tenant_id, type, payload)
VALUES ('acme', 'ticket_created', '{"ticket_id":"T9"}');
SELECT pg_notify('ticket_events', 'acme');   -- delivered to listeners only if/when this commits
COMMIT;

-- Reader: the durable catch-up query, scoped to the caller's tenant.
SELECT seq, type, payload
  FROM ticket_event
 WHERE tenant_id = 'acme' AND seq > 104
 ORDER BY seq
 LIMIT 100;

The advisory lock costs write concurrency on this table. If that matters, use a log system with per-partition offsets, or a watermark reader instead.

A browser limit that shapes the design

A parked request occupies a connection for its whole life. Over HTTP/1.1, browsers typically cap concurrent connections per origin (commonly six), and that cap is shared by every tab on the same origin. Two long-poll endpoints in each of three tabs can starve ordinary API calls. HTTP/2 and HTTP/3 multiplex many streams over one connection, which removes most of that pressure, but the server still pays for each parked stream, as the capacity section below shows. In practice, fold every update the page needs into one long-poll endpoint with one cursor, rather than one per widget.

Avoid the Check-Then-Wait Race

Order matters: subscribe first, then read

A subtle race occurs when a handler checks the database, finds no new events and only then subscribes to a notification. An event can arrive between those two steps. If no later event follows, the request waits until timeout even though data was available for most of that time. It is invisible in light testing, because the window is microseconds wide, and shows up in production as sporadic delays of a whole timeout period.

One safe strategy is to register the wakeup mechanism first and recheck the durable store before sleeping. Another is to use a transactionally coordinated change feed or a sequence-based wait primitive. On any wakeup, read from the durable source again. Notification delivery may be duplicated, reordered or dropped; correctness comes from the cursor-based catch-up query, not from the notification. Pick an ordering below to see where each failing or passing event lands.

Wakeup ordering: check, then subscribe (broken)The handler reads the store and finds nothing. Before it subscribes, the writer commits event 105 and publishes a wakeup that nobody receives. The handler then subscribes and sleeps. No further event arrives, so the request waits the full timeout although event 105 has been in the store the whole time.Request handlerafter=104Event storesource of truthNotifierwakeupsWritercreates events1. readAfter(104)2. [] empty3. commit event 1054. publish wakeup (no subscriber)5. subscribe6. sleep until deadline7. ✘ 105 delivered ~25 s late
Wakeup ordering: check, then subscribe (broken). The handler reads the store and finds nothing. Before it subscribes, the writer commits event 105 and publishes a wakeup that nobody receives. The handler then subscribes and sleeps. No further event arrives, so the request waits the full timeout although event 105 has been in the store the whole time.

Subscribing first turns every interleaving into a harmless one: an event committed before the read is found by the read, and an event committed after the subscription produces a wakeup that is either consumed or ignored. The cost is occasional spurious wakeups, where the handler reads, finds nothing for its own cursor and goes back to sleep. That is why the loop in the pseudocode re-checks instead of assuming a wakeup means data.

Handler pseudocode
function longPoll(after, deadline):
    verifyAuthorization()
    watcher = subscribeToWakeups()       # register before final check
    try:
        while now() < deadline:
            events = durableStore.readAfter(after, limit=100)
            if events not empty:
                return 200 {events, next_cursor: events.last.id}
            await watcher.waitUntil(deadline)  # may wake spuriously
        return 200 {events: [], next_cursor: after}
    finally:
        watcher.close()

Note limit=100. A client returning after a long absence can have thousands of events pending. Returning a bounded batch with a next_cursor lets it come back immediately for the next batch without building a huge response. The waiting period only begins once a read returns nothing.

A Credible First Implementation

Server handler, durable wakeups and the client loop

The server below is a complete Node program with no dependencies. Its in-memory array is a stand-in for the durable store; everything around it (cursor parsing, expiry, register-then-read, timeout, cleanup on disconnect, graceful drain) is the real shape of the handler. The event-driven structure is the same algorithm as the pseudocode: wakeups.on registers the watcher, then check() reads the store, and each later wakeup calls check() again.

server.mjs: bounded wait, register then read
// Node 18+, ES modules, no dependencies. Run: node server.mjs
import http from 'node:http';
import { EventEmitter } from 'node:events';

const WAIT_MS = 25_000;

// Stand-in for the durable store: an append-only log, seq starts at 1.
// In production this is a table, a stream or a topic; the handler only needs readAfter().
const log = [];
let nextSeq = 1;
const wakeups = new EventEmitter();   // best-effort signal, not a source of truth
wakeups.setMaxListeners(0);

export function append(type, data) {
  const ev = { seq: nextSeq, id: `ev_${nextSeq}`, type, data };
  nextSeq += 1;
  log.push(ev);                       // commit first ...
  wakeups.emit('append');             // ... then wake waiters
  return ev;
}

function parseCursor(raw) {           // 'ev_104' -> 104; missing -> 0; garbage -> NaN
  if (raw === null || raw === '') return 0;
  const m = /^ev_(\d+)$/.exec(raw);
  return m ? Number(m[1]) : NaN;
}

function readAfter(seq, limit = 100) {
  if (log.length === 0) return [];
  const i = Math.max(0, seq + 1 - log[0].seq);
  return log.slice(i, i + limit);
}

// Older than retention, or ahead of anything this store ever issued.
const cursorExpired = (seq) =>
  seq > nextSeq - 1 || (log.length > 0 && seq + 1 < log[0].seq);

function send(res, status, body) {
  res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
  res.end(JSON.stringify(body));
}

function longPoll(res, after) {
  let finished = false;
  const finish = (status, body) => {
    if (finished) return;
    finished = true;
    clearTimeout(timer);
    wakeups.off('append', check);
    wakeups.off('drain', onTimeout);
    if (body && !res.writableEnded && !res.destroyed) send(res, status, body);
  };
  const check = () => {
    const events = readAfter(after);
    if (events.length > 0) finish(200, { events, next_cursor: events.at(-1).id });
  };
  const onTimeout = () => finish(200, { events: [], next_cursor: `ev_${after}` });

  wakeups.on('append', check);        // 1. register for wakeups
  wakeups.on('drain', onTimeout);     //    (also used for graceful shutdown)
  const timer = setTimeout(onTimeout, WAIT_MS + Math.random() * 2000);  // jittered deadline
  res.on('close', () => finish());    // client went away: release the waiter
  check();                            // 2. then read the durable store
}

export const server = http.createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost');
  if (url.pathname !== '/ticket-events') return send(res, 404, { error: 'not_found' });
  if (req.headers.authorization !== 'Bearer demo-token') return send(res, 401, { error: 'unauthorized' });

  const after = parseCursor(url.searchParams.get('after'));
  if (Number.isNaN(after)) return send(res, 400, { error: 'bad_cursor' });
  if (cursorExpired(after)) return send(res, 410, { error: 'cursor_expired', snapshot_url: '/tickets/snapshot' });
  longPoll(res, after);
});

process.on('SIGTERM', () => {         // answer every waiter now so clients reconnect elsewhere
  wakeups.emit('drain');
  server.close();
});

server.listen(3000);
setInterval(() => append('ticket_created', { ticket_id: `T${nextSeq}` }), 8000);  // demo traffic

Verified against Node 18+. Try it: curl -H 'Authorization: Bearer demo-token' 'localhost:3000/ticket-events?after=ev_0'. The wait is 25 s plus up to 2 s of random jitter so that servers restarted together do not time out together.

Three details deserve attention. The handler removes its listeners in one place, finish, which is called on response, timeout, drain and client disconnect; forgetting one path leaks a listener per request and shows up as a slow memory climb. The res.on('close') hook matters because a client that navigates away does not tell the server, and without it the waiter lives until its timeout. And wakeups.setMaxListeners(0) is required because the default limit of ten would print warnings with ten waiters.

Authorization is checked before any waiting, on every request, and the read is scoped to what the caller may see (the SQL above filters by tenant). A cursor is a position, never a permission: handing someone ev_104 must not let them read another tenant's events after it.

Wakeups that survive more than one process

With a database as the store, a natural wakeup channel is the database's own notification feature. In PostgreSQL, NOTIFY issued inside the writing transaction is delivered to listeners only after commit, which is exactly the ordering this design wants. Use one LISTEN connection per instance and fan out in memory instead of one per waiter, or the connection count becomes the bottleneck.

Replacing the in-process emitter with LISTEN/NOTIFY
// One LISTEN connection per instance, fanned out locally to every waiter on that instance.
import pg from 'pg';
import { EventEmitter } from 'node:events';

export const wakeups = new EventEmitter();
wakeups.setMaxListeners(0);

const listener = new pg.Client({ connectionString: process.env.DATABASE_URL });
await listener.connect();
await listener.query('LISTEN ticket_events');
listener.on('notification', () => wakeups.emit('append'));
listener.on('error', () => process.exit(1));   // supervisor restarts; waiters then fall back to timeouts + reads

Notifications are not stored. If this listener disconnects, wakeups are lost; waiters still return correctly at their next timeout because every request starts with a durable read.

The client loop

The browser has no built-in reconnect for long polling; the loop is yours to write, and it is where most bugs live. The client timer is deliberately longer than the server hold so the normal outcome is a server response, not a client abort. The cursor moves only after the events were applied and the new position was saved. A guard against instant empty answers prevents a misconfigured cache or a proxy returning a fixed 200 from turning the loop into a tight spin. Failures use exponential backoff with full jitter, the policy described in Retry & Backoff.

Client loop with cursor, timeout, resync and backoff
// Browser or Node 18+. The cursor is saved only after the events are applied.
async function longPollForever({ url, token, signal, applyEvent, loadCursor, saveCursor, resync }) {
  const CLIENT_TIMEOUT_MS = 30_000;   // a little longer than the ~25-27 s server hold
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  let cursor = (await loadCursor()) ?? 'ev_0';
  let failures = 0;

  while (!signal.aborted) {
    const startedAt = Date.now();
    const ctl = new AbortController();
    const timer = setTimeout(() => ctl.abort(), CLIENT_TIMEOUT_MS);
    const stop = () => ctl.abort();
    signal.addEventListener('abort', stop, { once: true });

    try {
      const res = await fetch(`${url}?after=${encodeURIComponent(cursor)}`, {
        headers: { Authorization: `Bearer ${token()}` },
        cache: 'no-store',
        signal: ctl.signal,
      });
      if (res.status === 410) { cursor = await resync(); await saveCursor(cursor); continue; }
      if (res.status === 401) throw new Error('unauthorized');   // refresh credentials in a real app
      if (!res.ok) throw new Error(`HTTP ${res.status}`);

      const body = await res.json();
      for (const ev of body.events) await applyEvent(ev);   // must be idempotent per ev.id
      cursor = body.next_cursor;
      await saveCursor(cursor);                             // advance only after applying
      failures = 0;

      // An empty answer that came back instantly is not a normal timeout: do not spin.
      if (body.events.length === 0 && Date.now() - startedAt < 1000) await sleep(1000);
    } catch (err) {
      if (signal.aborted) return;
      failures += 1;
      const cap = Math.min(30_000, 500 * 2 ** failures);
      await sleep(Math.random() * cap);                     // full jitter
    } finally {
      clearTimeout(timer);
      signal.removeEventListener('abort', stop);
    }
  }
}

Runs as written in a browser or Node 18+ once url, token, applyEvent, loadCursor, saveCursor and resync are supplied. applyEvent must tolerate repeats of the same event ID.

Client long-poll loop as a state machineThe client loop sits in Waiting while a request is in flight. A 200 with events moves to Processing, then Cursor saved, then straight back to Waiting. An empty 200 from a timeout saves the cursor it carries and then returns to Waiting. A network error or client abort goes through a jittered Backoff. A 410 triggers Resync with a fresh snapshot and cursor, and a 401 triggers Re-authenticate. Backoff, Resync and Re-authenticate each lead back to Waiting once they succeed, drawn as dashed return arrows.Waitingrequest in flightProcessingapply eventsCursor saveddurable on clientBackoffjittered sleep, then retryResyncsnapshot + new cursor, retryRe-authenticaterefresh credentials, retry200 + eventsreconnecterror / abort410401
Client long-poll loop as a state machine. The cursor advances only in the Cursor saved state, after processing succeeds. An empty 200 re-enters Waiting immediately; errors go through Backoff so a gateway restart does not turn into a synchronised reconnect surge.

The state machine has one invariant worth stating: the only edge that changes the cursor runs from Processing to Cursor saved. Everything else either retries with the old cursor or replaces the whole state through Resync. If a code review ever finds a place where the cursor is updated while parsing the response, that is the bug that eventually loses an event.

Timeouts Must Align Across the Stack

Several layers can end a quiet request; make sure the application is the first to do so

Long polling involves several timeout layers: the application wait duration, the server's request timeout, the load balancer idle timeout, any reverse proxy timeout, the client's abort timer and the behaviour of mobile networks, where a radio sleeping through an idle period can silently drop a connection. If the application waits 60 seconds but the gateway ends idle requests at 30, clients see avoidable disconnects. A common approach is to choose an application wait shorter than the relevant infrastructure limits, with a client timeout slightly longer than the application wait. Move the sliders to see which layer wins.

25 s
60 s
30 s
Which timeout ends an idle long poll firstWith an application wait of 25 seconds, a gateway idle timeout of 60 seconds and a client abort timer of 30 seconds, the request is ended first by: Application. Clean: the application answers first, with 5 s of headroom before the next layer acts.0306090120seconds since the request was sentApplication wait25 sGateway idle timeout60 sClient abort timer30 s
Which timeout ends an idle long poll first. Each bar is the moment a layer would end a request that has had no event and sent no bytes. The earliest bar wins; only an application-first ending is a normal empty response. Values are examples, not recommendations.
Ends the request
Application
at 25 s
Outcome
normal empty 200

Reading the result. Clean: the application answers first, with 5 s of headroom before the next layer acts.

Do not prescribe one universal timeout. The right value depends on the provider, the intermediaries, the client SDK and connection reuse behaviour. Some infrastructure supports long-held requests; other infrastructure imposes strict limits. Verify the real deployment settings and monitor which component closes the request: a rising count of gateway-generated 504s or resets, as opposed to application 200s with empty bodies, is the signature of a mismatched layer. Idle timeouts are also usually measured from the last byte, so if your infrastructure only counts activity, an occasional keep-alive byte can extend a hold, but a bounded wait that returns a clean empty response is simpler to reason about.

A timeout is not a data error. A successful empty response means “no event observed within this wait,” not “there will never be an event.” Clients should reconnect promptly after a success, but use jitter after repeated network failures so a gateway restart does not create a synchronised surge. The same logic applies to the server side: the random extra wait in the handler spreads timeouts out, and a rolling deploy should answer parked requests during shutdown (the drain event above) rather than leave clients hanging until their own timers fire.

Connection Capacity and Memory

Fewer requests per second, many more requests alive at once

Long polling reduces the frequency of empty requests but holds many requests concurrently. If 100,000 clients are waiting, the service may need to maintain roughly 100,000 active request contexts, sockets or multiplexed streams. Asynchronous event-loop servers handle many waiting requests more efficiently than a thread-per-request model, which would need one blocked thread per waiter, but there is still memory, file-descriptor, gateway and load-balancer state to pay for. Operating-system limits such as the per-process file-descriptor cap and ephemeral port ranges on proxies are often the first walls you hit.

Little's Law gives a useful sizing intuition: concurrent_requests ≈ arrival_rate × average_request_duration under stable conditions. With an average hold of 20 seconds and 5,000 new requests per second, approximately 100,000 requests are in flight. Real sizing also depends on event arrival rates (an event ends a hold early, so average duration falls and the arrival rate for the same client count rises), reconnect overhead and infrastructure limits. An HTTP/2 connection may multiplex streams, but it does not eliminate per-request server state.

5,000
20 s
16 KB
Concurrent requests and request rate for a long-poll fleetAt 5,000 new requests per second held for an average of 20 seconds, about 100,000 requests are in flight, which at 16 KB each is roughly 1563 MB of per-request state. If each of those 100,000 clients polled every 5 seconds instead, the arrival rate would be about 20,000 requests per second.Long poll5,000 req/sPolling, 5 s20,000 req/sidle-state arrival rate for the same client population
Concurrent requests and request rate for a long-poll fleet. Little's Law: in-flight requests ≈ arrival rate × average duration. The bars compare the long-poll arrival rate with the rate the same population would generate polling every 5 s while idle; once events flow, long-poll requests end sooner and the arrival rate rises.
In-flight requests
100,000
rate × hold
Per-request state
1.5 GB
at 16 KB each
Same clients, 5 s polling
20,000 req/s
Long-poll rate
5,000 req/s

Reading the result. The default, 5,000 requests per second held for 20 s, gives 100,000 in flight. Per-request memory is a placeholder: measure your own server, because buffers, TLS state and framework objects dominate and vary widely.

The same arithmetic shows why the technique is attractive: 100,000 idle clients on a 20-second hold arrive at 5,000 requests per second, against 20,000 per second for the same clients polling every five seconds. It also shows the price: the state for 100,000 live requests. Whether that is cheap or expensive is a matter of measurement on your stack, including TLS session state, buffers and any per-request framework objects, and of whether a load-shedding or backpressure policy exists for the moment all clients reconnect together.

Waiters across instances

Distribute waiting clients across instances. If wakeups are local to one process, events handled on another process must still reach the correct waiters through a broker or a shared notification mechanism. Sticky sessions are not a substitute for a durable event source: they make failover harder, they pile the waiters of a restarted instance onto its neighbours at once, and they do nothing to prevent missed events.

Distributing waiting requests across instancesClients reach a load balancer, which spreads waiting requests over several application instances. Writers commit each event to the durable event store and then publish a wakeup to a broker. The broker fans wakeups out to every instance holding waiters; each woken instance re-reads the durable store using each waiter's cursor. Solid arrows are data and queries, dashed arrows are best-effort wakeups.Clients100k waitingLoad balanceridle timeout > holdInstance Aholds local waitersInstance Bholds local waitersBroker / notifierwakeups, may dropDurable event storeordered, cursor-addressableWriterscommit, then publish
Distributing waiting requests across instances. A wakeup published on one instance must reach waiters parked on every other instance, so the notifier is a broker or shared channel rather than an in-process emitter. Dashed arrows may be lost or duplicated; the solid read path to the event store keeps the answer correct.

Notice what is deliberately weak in this picture. The broker links are dashed because the design assumes they can drop or repeat messages. Each instance, on a wakeup, re-reads the store for every waiter it holds, so for hot streams consider batching: one store read per wakeup tick per distinct cursor, rather than per request, and shedding waiters that exceed per-user or per-tenant limits before they consume capacity. Queue-based fan-out is covered in Queues & Workers and Pub/Sub.

Delivery Guarantees and Client Recovery

At-least-once on the wire; exactly-once effects are the application's job

Long polling alone does not guarantee exactly-once event delivery. A server can send an event and crash, or the connection can break, before it knows whether the client processed it. The client may reconnect with an older cursor and receive the same event again. Event IDs, deduplication and idempotent handling therefore matter. Advance the cursor after successful processing or durable local acceptance, not merely after bytes arrive. This is the distinction between a protocol guarantee (HTTP delivered a response body) and an application guarantee (the effect was applied once).

When a cursor is too old because retention has expired, return an explicit resynchronisation signal and have the client fetch a fresh snapshot. Silently starting from “now” may conceal missing changes; silently starting from the oldest retained event conceals them just as well. The status code is a convention (410 Gone here; a 409 or a typed error body works too), but it must be distinct from a plain empty result, so that clients cannot mistake data loss for quiet.

Expired cursor: explicit resync signal
GET /ticket-events?after=ev_0040 HTTP/1.1
Host: api.example.com
Authorization: Bearer <access token>

HTTP/1.1 410 Gone
Content-Type: application/json
Cache-Control: no-store

{"error":"cursor_expired","snapshot_url":"/tickets/snapshot"}
Client recovery: crash before saving the cursorThe server returns event 105 and the client applies it, but the client crashes before persisting the cursor. After restart it still holds cursor 104, asks again and receives 105 a second time. Because the handler is keyed by the event ID, the replay is recognised as already applied and only the cursor moves.Clientbrowser / workerServerlong-poll endpointEvent storeretention window1. GET after=1042. 200 {events:[105]}3. apply 105 … crash4. restart, load cursor 1045. GET after=1046. 200 {events:[105]} again7. ev_105 already applied: skip8. save cursor 105
Client recovery: crash before saving the cursor. The server returns event 105 and the client applies it, but the client crashes before persisting the cursor. After restart it still holds cursor 104, asks again and receives 105 a second time. Because the handler is keyed by the event ID, the replay is recognised as already applied and only the cursor moves.

The two scenarios map to the two ways an event stream can fail the client. A replay after a crash is benign when handlers are idempotent, and costs a duplicate response. An expired cursor is not benign: no amount of retrying will produce the missing events, so the protocol must say so and the client must replace its state from a snapshot. Retention length is therefore a product decision: it defines the longest offline period a client can recover from incrementally. Choose it from real reconnect data, not guesswork.

Authorization must be checked on every request and again when events are read; a cursor must not grant access to another user's events. Tokens expire while requests are parked, so decide whether a hold may outlive its token (usually not: the hold length should be shorter than the minimum token lifetime you accept, or the handler should re-verify on wakeup) and have the client answer a 401 by refreshing and retrying with the same cursor.

When Long Polling Is Appropriate

Choose it when its operational shape matches the environment

Long polling works when low-latency notification is useful but WebSockets or SSE are unavailable, when the existing HTTP request/response infrastructure is preferred, or when clients need a broadly compatible fallback: corporate proxies that mishandle upgrades, constrained clients with only a plain HTTP stack, or an API consumed by simple scripts that can loop on a GET. Typical payloads are notifications, job and order status, and low-volume dashboards, where events are sparse enough that one request per event is cheap.

  • If updates are frequent and flow in one direction, prefer Server-Sent Events: a single long response with browser-managed reconnection and Last-Event-ID, no per-event request cost.
  • If the client also sends frequent messages, WebSockets give a bidirectional channel, at the price of a separate protocol to operate and secure.
  • If freshness of a few seconds or minutes is acceptable, plain polling with conditional requests is simpler and holds no connections.

Long polling remains valuable when its operational characteristics match the environment: it adds almost no new infrastructure beyond a wakeup channel and an ordered store, it degrades into ordinary polling when things go wrong, and every part of it can be tested with curl. The costs are the ones this lesson walked through: reconnect handling, aligned timeouts, parked-request capacity and application-level deduplication. Whichever transport you choose, the cursor-and-catch-up pattern here carries over unchanged.

Signals that it is time to move to a persistent channel: direction (server-only news points to SSE, frequent client-to-server messages on the same session point to WebSockets), update frequency (if events arrive faster than the reconnect round trip, every wakeup costs a full request, response and re-parking, and bursts leave a reconnect gap to cover with the cursor), client count (a parked request holds a connection for the whole wait, so per-client cost is already similar to a stream, and switching to a stream mainly saves the per-event request overhead) and proxy constraints (if intermediaries allow long responses but block or buffer streaming or upgrades, long polling may be the only thing that works). The first two usually decide it.