Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close