Lesson 21 / 25
Polling Approaches
The simplest way for a client to learn about a change on the server: the request and empty-response cost of short polling, the connection-holding trade-off of long polling, measuring both against the same event, and closing the event gap with a version number.
Contents
The previous topic completed the job’s resilience on the server side: a job lands on a worker, fires on time, has its progress recorded, does not overflow the queue, and is rejected in a controlled way under overload. One end of the chain is still open. The client that started the job knows it was accepted, but does not know when the result will be ready.
The progress percentage of a long-running report, a change in position in a reservation queue, an update to a branch stock counter — all of these are ready on the server side but invisible on the client side. HTTP’s default direction makes this harder: the client opens the connection, the client sends the request. The server has no way to speak on its own.
This topic builds four ways for information to reach the client. We start with the simplest: the client asking again and again.
Polling
Polling is the client sending requests to the server at regular intervals to learn its state. It requires no additional protocol; no extra state is kept on the server side; every intermediary, firewall, and cache sees it as an ordinary HTTP request. This is why polling is the default answer to the real-time data problem, and in many cases it is the right answer.
It has two forms, and the difference between them comes down to a single decision: what does the server do when it has nothing to give.
In short polling, the server responds immediately. If there is no change, it says “no change” and the connection closes; the client waits a while and asks again.
In long polling, the server delays the response. It keeps the connection open until a change occurs; if a change arrives, it writes the response at that moment. If nothing happens for a set duration, it sends an empty response and the client reconnects.
Setting Up Both Approaches on One Server
The server below serves the progress recorded in the job_status table from the
previous topic. The status has a version number; the client attaches the version it
last saw to the request.
// polling.mjs — serves the status of the report job in two forms: short polling and long polling import { createServer } from "node:http"; const HOLD_LIMIT = 1000; // long polling's connection-hold duration (ms) let version = 0, progress = 0; // status of the report-2026-01 job const counter = { status_request: 0, wait_request: 0, empty_response: 0 }; const pending = new Set(); // long polling responses kept open const body = () => JSON.stringify({ version, progress }) + "\n"; const write = (response, text, code = 200) => { response.writeHead(code, { "content-type": "application/json; charset=utf-8" }); response.end(text); }; const empty = (response) => { counter.empty_response++; response.writeHead(204).end(); }; createServer((request, response) => { response.sendDate = false; const url = new URL(request.url, "http://local"); const seen = Number(url.searchParams.get("version") ?? 0); if (url.pathname === "/status") { // short polling: respond immediately counter.status_request++; return version > seen ? write(response, body()) : empty(response); } if (url.pathname === "/wait") { // long polling: hold until it changes counter.wait_request++; if (version > seen) return write(response, body()); const record = { response }; record.timer = setTimeout(() => { pending.delete(record); empty(response); }, HOLD_LIMIT); return void pending.add(record); } if (url.pathname === "/advance") { // worker reported progress version++; progress += 10; for (const p of pending) { clearTimeout(p.timer); write(p.response, body()); } pending.clear(); return write(response, body()); } if (url.pathname === "/metrics") return write(response, JSON.stringify(counter) + "\n"); write(response, JSON.stringify({ error: "route not found" }) + "\n", 404); }).listen(8361, "127.0.0.1", () => console.log("listening: 127.0.0.1:8361"));
The only structural difference between the two endpoints is the pending set. In short
polling, the server handles the request and forgets it; in long polling, it stores the
response object and writes to those stored objects when /advance arrives. In other
words, long polling adds state to the server.
Measurement
The script below meets and counts the same single event — the report advancing one step — with both approaches. Port 8361 is arbitrary and must be free.
#!/usr/bin/env bash # Starts polling.mjs, measures the same single event with both approaches, stops it. A=http://127.0.0.1:8361 B='%{http_code} %{size_download} B\n' node polling.mjs > /dev/null & server=$! sleep 1 echo "--- short polling: six requests ---" for i in 1 2 3 4 5; do curl -sS -w "$B" "$A/status?version=0"; done curl -sS -o /dev/null -X POST "$A/advance" # the job just advanced curl -sS -w "$B" "$A/status?version=0" curl -sS "$A/metrics" kill "$server"; sleep 0.3 node polling.mjs > /dev/null & server=$! sleep 1 echo "--- long polling: two requests ---" curl -sS -w "$B" "$A/wait?version=0" & waiting=$! sleep 0.3 curl -sS -o /dev/null -X POST "$A/advance" # the job just advanced wait $waiting curl -sS -w "$B" "$A/wait?version=1" # no change: time expires curl -sS "$A/metrics" kill "$server"
--- short polling: six requests ---
204 0 B
204 0 B
204 0 B
204 0 B
204 0 B
{"version":1,"progress":10}
200 28 B
{"status_request":6,"wait_request":0,"empty_response":5}
--- long polling: two requests ---
{"version":1,"progress":10}
200 28 B
204 0 B
{"status_request":0,"wait_request":2,"empty_response":1}
The same event was met with six requests in short polling and two requests in long polling. Five responses in short polling were empty; since the body of an empty response is zero bytes, the data transferred is small, but the request count stayed the same. Every request means a connection setup, a routing decision, an authentication check, and a log line; an empty body does not make any of these cheaper.
In long polling, the second request stayed open for one second and closed with a 204 because nothing changed. This is long polling’s unavoidable cost: a connection cannot be held forever, because intermediaries and load balancers have their own timeouts.
The Cost, Computed
The cost of the two approaches can be computed from the observation window and the event frequency. The table below is derived assuming a single event within a sixty-second window.
| Approach | Requests | Empty responses | Worst-case latency |
|---|---|---|---|
| Short polling, 1 s interval | 60 | 59 | 1 s |
| Short polling, 5 s interval | 12 | 11 | 5 s |
| Long polling, 30 s hold | 3 | 1 | network latency |
In short polling, the request count is set by window / interval, and the worst-case
latency is the interval itself. The two are inversely proportional, and this is short
polling’s one real trade-off: halving the latency doubles the request count.
In long polling, the request count is set by window / hold time + event count, and the
latency does not depend on the interval. This is why long polling is markedly cheaper than
short polling for events that are infrequent but latency-sensitive.
The direction of this computation reverses as event frequency rises. For a source that produces more than one event per second, long polling opens a connection for every event, while short polling carries several changes in a single request. Data that changes often and where intermediate values do not matter — a branch stock counter, for instance — is served more cheaply with short polling.
The Server-Side Price
Long polling adds state to the server, and that state has a limit. Every client held open keeps a socket, a file descriptor, and a response object. A thousand clients means a thousand open connections; even if they are doing no work, they consume resources.
Three consequences follow. The server’s concurrent-connection limit is now a different quantity from its concurrent-request limit and must be sized accordingly. The admission control from the previous lesson must not count these connections; a pending long-poll does not use the processor, so counting it as “running work” makes the server look needlessly saturated. And the hold time must be chosen shorter than the intermediaries’ timeout; otherwise a layer in between, not the server, cuts the connection, and the client cannot tell why.
The Event Gap
Long polling’s overlooked flaw is this: in the interval between the response being written and the client reconnecting, the server is not listening. An event that occurs during that gap cannot be written to any open connection.
In the code, the version number is what closes this gap. The client attaches the version
it last saw to the request; the server checks the condition version > seen before
holding the connection, and answers a client that has fallen behind immediately. This is
why the second long poll in the measurement was sent with version=1.
This is a shared requirement of every approach that serves real-time data: the client
must be able to say where it left off. A version number, an event ID, or a timestamp —
the name changes, the function does not. The next lesson shows the same job done by the
Last-Event-ID header.
Summary
- Polling is the client sending requests at regular intervals to learn its state; it is the default approach because it requires no additional protocol.
- In short polling, the server responds immediately and produces empty responses; the request count grows with
window / interval, and the worst-case latency is the interval itself. - In long polling, the server holds the connection until a change occurs; in the measurement, the same event was met with two requests instead of six, and the latency stopped depending on the interval.
- Long polling’s price is the state it adds to the server: every open connection consumes resources, and the hold time must be chosen shorter than intermediaries’ timeouts.
- The event gap between two polls is closed by the client attaching the version it last saw to the request.
Next Step
Long polling closes the connection after holding it and writing a single response. Opening a new connection for every event turns into an absurd arrangement once events come frequently: ten separate connections for ten events that should be written to the same client one after another. Yet a connection that is already open can have more than one event written onto it. The next lesson builds the standardized form of this: a one-way stream where the server writes events one after another onto a single open response body, and the browser side takes care of reconnecting on its own.
To keep your progress and take notes, Log in
My notes
Log in to take notes.