Polling: Building Realtime with Repeated Requests
The frontend fetches the status of an operation:
GET /orders/42
HTTP/1.1 200 OK
Content-Type: application/json
{"status":"processing"}
The request ends.
Five seconds later the backend changes the state:
processing -> completed
HTTP has no mechanism that would tell a finished request about it.
The simplest solution is to make another one.
Polling
const timer = setInterval(async () => {
const response = await fetch("/orders/42");
const order = await response.json();
render(order);
}, 5000);
That produces a sequence:
t=0 GET -> processing
t=5 GET -> processing
t=10 GET -> processing
t=15 GET -> completed
This is still ordinary request/response.
"Realtime" comes solely from repeating the request often.
Latency is a function of the interval
If polling happens every T, a change can be noticed anywhere between almost immediately and almost T later.
With changes spread evenly over time the average extra delay is roughly:
T / 2
So polling every 10 seconds means about 5 seconds of delay on average, coming purely from the client's schedule.
Lowering T improves freshness but raises the cost.
The cost exists even without changes
Suppose:
100 000 clients
poll every 5 s
That gives on average:
100 000 / 5 = 20 000 requests/s
even if for most of the time the backend answers:
{"status":"processing"}
The problem is not the size of a single response.
The problem is the ratio:
number of requests
------------------
number of actual changes
If the state changes once every few minutes, most of the traffic exists only to confirm that nothing changed.
Clients can synchronize themselves
If thousands of UI instances start polling at a similar moment:
00s ███████████████
01s
02s
03s
04s
05s ███████████████
the backend gets periodic spikes.
That is why the interval is often worth spreading with jitter:
next_poll = base_interval + random_jitter
The same reason jitter shows up in retry/backoff.
Polling does not have to return full data
HTTP semantics can help here.
An example with ETag:
GET /orders/42
If-None-Match: "v17"
If the representation has not changed:
HTTP/1.1 304 Not Modified
ETag: "v17"
That reduces the body transferred, but not the number of requests.
It is also possible to ask for changes since a given point:
GET /events?after=1842
That avoids fetching the whole state, but the client still decides when to ask.
Polling has one important advantage
It is operationally simple.
Every request is independent:
client
|
v
load balancer
|
v
any backend instance
There is no multi-minute relationship between a client and one particular connection.
The ordinary things keep working:
- timeouts,
- HTTP caching,
- autoscaling,
- stateless backend instances,
- classic reverse proxies.
That is why polling is sometimes the right choice.
If a dashboard can show data 30 seconds old, a WebSocket may be a more expensive answer to a problem we do not have.
Where is the real limitation?
The client asks:
anything new?
anything new?
anything new?
anything new?
For most of the time the server answers:
no
no
no
yes
But the server knows when the yes appears.
So one thing can change:
instead of answering "nothing new" immediately, do not answer until something happens.
That is long polling.