Long-Polling ClientLoading saved progress…

Long-Polling Client

Long polling is a way to get near-real-time updates over plain HTTP: the client sends a request and the server holds it open — not answering — until it actually has new data (or an internal timeout is hit), at which point the client processes the response and immediately sends the next request. It sits between two other techniques. Short polling asks on a fixed timer and usually gets an empty answer, wasting round-trips and lagging behind new data by up to one interval; WebSockets keep a single connection open for two-way traffic but need protocol support. Long polling was the classic pre-WebSocket way to push updates and is still a common fallback.

You will implement longPollingClient(options). Because a real server that holds requests open cannot run in a test, the actual request is an injected poll(cursor) function — you build the loop around it. In production you pass a poll that wraps fetch; in a test you pass a fake you can resolve or reject by hand.

Signature

type PollResult = { data?: unknown; cursor?: unknown };

type Options = {
  poll: (cursor: unknown) => Promise<PollResult>; // injected request; server holds it open
  onData?: (data: unknown) => void;               // deliver new data to the app
  onError?: (err: unknown) => void;               // called when a poll rejects
  backoff?: (attempt: number) => number;          // ms to wait before retry N (0-based attempt)
  initialCursor?: unknown;                         // cursor sent on the very first poll
};

function longPollingClient(options: Options): {
  start(): void;        // begin the loop
  stop(): void;         // stop; ignore an in-flight response; clear a pending retry
  isRunning(): boolean;
};

Examples

// Production: poll wraps a real request the server holds open until it has data.
const client = longPollingClient({
  poll: (cursor) =>
    fetch(`/updates?since=${cursor ?? 0}`).then((r) => r.json()), // resolves { data, cursor }
  onData: (updates) => render(updates),
  onError: (err) => console.warn("poll failed; backing off", err),
});
client.start();
// ...later, when the view unmounts:
client.stop();
// Test: drive a fake poll by hand — no real server.
let resolvePoll;
const poll = jest.fn(() => new Promise((res) => { resolvePoll = res; }));
const onData = jest.fn();
const client = longPollingClient({ poll, onData, backoff: () => 10 });

client.start();
poll.mock.calls[0][0];                     // first cursor: initialCursor (null by default)
resolvePoll({ data: ["a"], cursor: 5 });   // deliver data + hand back the next cursor
// after a microtask: onData(["a"]) has run, and poll was called again with cursor 5

Notes

  • Injected poll — the loop never calls fetch itself; it calls options.poll(cursor). Each success resolves with { data, cursor }: the new data (if any) plus the cursor to send on the next request.
  • Immediate re-issue — on a successful poll, deliver the data, advance the cursor, then send the next request right away. There is no delay between successful polls; that continuous re-request is what keeps the stream live.
  • One in flight — at most one request is open at a time. The next poll starts only after the current one settles.
  • Advance the cursor — pass the cursor from the last response into the next poll, so the server returns only data newer than what you have seen. A response carrying no data still leaves the loop running.
  • Back off on error — if a poll rejects, call onError and wait backoff(attempt) ms before retrying. attempt grows while polls keep failing and resets to 0 after any success.
  • Stop cleanlystop() issues no further polls, ignores a response that arrives after it, and clears any pending backoff timer. start() does nothing while already running, and resumes the loop after a stop().

FAQ

How is long polling different from short polling and WebSockets?
Short polling fires a request on a fixed timer and usually gets an empty answer, wasting round-trips and lagging behind new data by up to one interval. Long polling holds each request open on the server until there is something to send, then the client immediately re-issues, so updates arrive with almost no delay over plain HTTP. WebSockets keep one bidirectional connection open instead of a request-per-message loop, which is more efficient but needs protocol support long polling does not.
Why does the client take an injected poll function instead of calling fetch?
A real long-polling server holds each request open, which is impossible to run in a unit test. Passing poll(cursor) as an option lets a test hand the loop a controllable fake it can resolve or reject by hand, so the re-issue, cursor advancement, and backoff logic are all driven deterministically. In production you pass a poll that wraps a real fetch.
What is the cursor for?
The cursor (an offset, id, or opaque token) marks the last update the client has already seen. Each response returns the next cursor, and the client sends it back on the following request so the server only returns data newer than that point. Advancing it correctly is what stops the client from missing updates or replaying ones it already processed.
Loading editor…