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.
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;
};
// 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
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.onError and wait backoff(attempt) ms before retrying. attempt grows while polls keep failing and resets to 0 after any success.stop() 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().