---
title: 'Polling Approaches'
source: 'https://academia.sh/en/courses/asynchronous-processing/polling-approaches'
course: 'Caching, Queues and Asynchronous Processing'
language: en
updated: '2026-08-23T07:00:24+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```bash
#!/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.
