Core Concepts
Polling
The simplest real-time strategy: periodically ask the server for updates. Trades efficiency for simplicity.
What Is Polling?
A client asks, the server answers with what it has, the client asks again later
Polling is a client-driven way to discover changes: the client sends a request at intervals, the server answers with the state it has at that moment, and the client repeats the request later. It is not a push channel. The server never needs to remember that a particular browser is waiting, so each request can be served by an ordinary stateless HTTP endpoint behind the gateways, load balancers, authentication layers and monitoring you already run.
The mental model is a person refreshing a page on a timer. Nothing about the exchange is special: request, response, wait, request again. Every property of polling follows from the wait. The wait sets how stale the screen can be, the number of clients divided by the wait sets how much traffic the service absorbs, and the fact that the server holds no per-client state is why polling survives deploys, load balancer reshuffles and flaky networks so well.
Take an order tracking screen that shows PREPARING, DISPATCHED and DELIVERED. The browser requests GET /orders/847/status every five seconds. If the status changes just after a poll, the user will not see it for almost five seconds. That is not a defect; it is the freshness bound the interval imposes, assuming timely responses and no failed requests. Polling trades request volume for an acceptable delay, and the whole design problem is choosing that trade deliberately.
One more consequence is easy to miss. A poll returns the current state, not the history. If the order goes PREPARING → DISPATCHED → DELIVERED between two polls, the client sees only DELIVERED. That is fine for a status badge and wrong for something like a notification feed, where every item matters; there the poll should carry a cursor (for example GET /notifications?after=8841) so that one response returns everything missed since the last one. The cursor-based catch-up is the same idea that makes Server-Sent Events recoverable.
Polling is a good fit for administrative dashboards, job status pages, inventory summaries and notifications that can tolerate seconds of delay. It turns wasteful when millions of clients poll often for data that rarely changes, and it is the wrong tool when the product needs sub-second updates.
Visual Story
Request, response, wait, repeat: see freshness and load trade against each other
Polling
⤢ Expand for synchronized narration timeline
How an HTTP Poll Works, Request by Request
Figure 1: the response contract and the timeline of a change that lands between polls
A useful implementation starts with the server's response contract. The endpoint returns a stable identifier, the current state, and a version or update timestamp the client can compare. The version matters more than it looks: it is what lets the client ignore stale responses and what lets the server issue a cheap validator later.
GET /orders/847/status HTTP/1.1
Host: api.example.com
Cookie: session=<opaque-session-id>
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "order-847-v19"
Cache-Control: private, no-cache
{"order_id":"847","status":"DISPATCHED","version":19}The 53-byte JSON body carries the identifier, the state and a monotonic version. The ETag is derived from the same version, and Cache-Control: private, no-cache lets the client's own cache keep the response but forces revalidation before every reuse.
The client stores the state and its version, waits for the next interval and asks again. Around that simple loop it still has to handle network failures, authentication expiry, tab suspension and overlapping responses. The server, for its part, authorizes every request. A session that was valid for the first poll is not a standing permission: the order may have been reassigned, the token may have expired, the user may have signed out in another tab. Treat each poll as an independent authenticated request, because that is what it is.
The sequence is request → response → wait → next request. It is not request → wait while the server watches for a change; that second shape is long polling, which holds the request open and has its own failure modes. In plain polling the server answers immediately, even if the answer is “nothing new”.
- Change lands at
- 6 s
- Seen by the poll at
- 10 s
- Staleness this time
- 4 s
- Average / worst (T = 5 s)
- 2.5 s / ≈5 s
- before latency
Slide the change time in Figure 1 and watch the response lane. Polls that find the same version are answered with 304 Not Modified (covered below); the first poll after the change is the only one after the first that carries a body. With a change at 6 seconds the poll at 10 seconds sees it, four seconds late. A change at 5.1 seconds would wait almost the full five, and one at 9.9 seconds would be seen almost at once. Freshness depends on when the change occurs relative to the next successful poll, which is why a failed request quietly adds a whole interval to the delay.
Choosing the Interval: Freshness Against Load
Figure 2: work the numbers before picking a timer
Let N be the number of active clients and T the interval in seconds. If clients poll continuously and their requests are spread evenly, the average request rate is approximately:
requests_per_second ≈ N / T
For 100,000 clients polling every five seconds the service receives roughly 20,000 requests per second even if no order changes. At a 30-second interval that drops to about 3,333 requests per second. The estimate leaves out retries, start-up bursts and any other endpoints the same page polls, and it assumes every client is active. It is an arrival rate, not a CPU or database cost: a cached status read may be nearly free, while a join-heavy query behind the same endpoint can saturate a database at a tenth of that rate.
Arrival rate also determines concurrency. By Little's law the average number of requests in flight is the arrival rate times the average latency, so 20,000 requests per second at 50 ms is about 1,000 requests in flight, and the same rate at 500 ms is about 10,000. Latency that creeps up under load therefore raises concurrency, which is one reason a polling fleet can tip from healthy to overloaded quickly.
The delay side is simpler. If changes arrive at times that are uniform relative to a fixed poll schedule, the expected detection delay is about T / 2 and the worst case is just under T, both before network and processing latency. Real traffic is not uniform: background tabs throttle timers, reconnect storms bunch requests together, and failed requests stretch the effective interval, so measured delay is usually worse than the formula.
- requests per second (thousands) = N / T
- Average requests / s
- 20,000
- N / T, evenly spread
- Average detection delay
- 2.5 s
- T / 2, plus latency
- Worst-case delay
- ≈ 5 s
- T, plus latency
- Polls that find news
- 1.7 %
- 1 in 60 polls
The last readout in Figure 2 is the one that tends to change decisions. If a client's order changes every five minutes (300 seconds) and it polls every five seconds, it makes 60 requests per change, so 59 of every 60 polls (about 98%) return the state it already has. Polling more often makes that ratio worse while shaving only a couple of seconds off the average delay. Polling less often than changes occur does the opposite: several changes collapse into one poll and the client never sees the intermediate states.
Do not pick the interval from a vague “real-time” label. Start from acceptable staleness: is 30 seconds fine for a batch export, five seconds fine for a delivery update, or is 100 milliseconds genuinely needed for collaborative cursor movement? Then compute the request volume and what it costs. If the answer at the required freshness is more traffic than the backend can sustain, that is the signal to look at long polling or SSE, not to squeeze the interval.
Avoiding Synchronized Request Spikes
Jitter, scheduling after completion, and a poll loop that survives failures
Why a fixed timer creates traffic waves
A fixed five-second timer looks harmless until a release or a live event brings hundreds of thousands of clients to the page at once. They all poll at seconds 0, 5, 10 and 15, and the server sees a wall of requests followed by silence. Averages hide this completely: the mean is still N / T, but capacity has to be sized for the peak.
- Peak requests / s
- 2,000
- busiest half second
- Average requests / s
- 200
- N / T
- Peak ÷ average
- 10×
- Idle buckets
- 36 of 40
Two mechanisms fix it, and they do different jobs. A random initial delay spreads a crowd that starts together across the whole period (the middle option in Figure 3). Per-cycle jitter randomizes each wait so that clients that happened to align drift apart; on its own it does not remove the first spike (the last option), it only dissolves the later ones. Use both. Neither lowers the long-run average rate: jitter with a mean factor of 1.0 leaves it at N / T, and it only changes if you also change the mean interval.
Schedule the next poll after the previous one finishes
For a single browser, schedule the next poll after the previous request completes. A naïve setInterval() starts a new request on every tick whether or not the last one returned, so when latency exceeds the interval you get overlapping requests, extra work on a server that is already slow, and responses that arrive out of order.
Chaining has a cost worth stating: the effective period becomes latency plus wait, so a 7-second response and a 5-second wait give a poll every 12 seconds. That is the right failure mode. Under strain the client polls slower rather than piling on.
A poll loop that handles failure
The code below is the loop most pages need. Each branch of the response handling decides two things: what to do with the result, and how long to wait. The diagram below shows the same decisions.
// Browser JavaScript. Call startPolling() once the page has rendered its first state.
const BASE_MS = 5000;
const MAX_MS = 60000;
const MAX_RETRY_AFTER_MS = 300000;
const TERMINAL = new Set(['DELIVERED', 'CANCELLED']);
let running = false;
let finished = false; // terminal state, auth failure or unmount: never restart
let controller = null;
let timer = null;
let delayMs = BASE_MS;
let lastVersion = -1;
function schedule(ms) {
if (!running) return;
clearTimeout(timer);
const jitter = 0.8 + Math.random() * 0.4; // +/-20 %, mean 1.0
timer = setTimeout(pollOrder, ms * jitter);
}
function parseRetryAfter(value) {
if (!value) return null;
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds) * 1000;
const at = Date.parse(value); // HTTP-date form
return Number.isNaN(at) ? null : Math.max(0, at - Date.now());
}
function backOff() {
delayMs = Math.min(delayMs * 2, MAX_MS);
}
async function pollOrder() {
if (!running) return;
const mine = (controller = new AbortController());
let waitMs = delayMs;
try {
const response = await fetch('/orders/847/status', {
signal: mine.signal,
credentials: 'include',
cache: 'no-store',
});
if ([401, 403, 404].includes(response.status)) {
stopPolling(); // hand control to the auth flow or the "not found" UI
return;
}
if (response.status === 429 || response.status === 503) {
backOff();
const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
waitMs = Math.min(Math.max(delayMs, retryAfterMs ?? 0), MAX_RETRY_AFTER_MS);
} else if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
} else {
const next = await response.json();
if (next.version > lastVersion) { // compare versions, not arrival order
lastVersion = next.version;
render(next);
}
if (TERMINAL.has(next.status)) {
stopPolling();
return;
}
delayMs = BASE_MS;
waitMs = delayMs;
}
} catch (error) {
if (error.name === 'AbortError') return;
backOff();
waitMs = delayMs;
} finally {
if (controller === mine) schedule(waitMs); // a newer request owns the timer
}
}
function startPolling() {
running = true;
finished = false;
delayMs = BASE_MS;
timer = setTimeout(pollOrder, Math.random() * BASE_MS); // spread first polls
}
function stopPolling() {
running = false;
finished = true;
clearTimeout(timer);
controller?.abort();
}Illustrative browser code: it runs as written once render() exists and the endpoint is real, but a framework needs the same cleanup in its unmount hook. 4xx handling is deliberately narrow; adapt the status list to your API.
A few details in the loop are deliberate. The jitter is applied when scheduling, so every path through the loop gets it, including backoff. A transient failure doubles the delay up to a cap of 60 seconds, and any success resets it to the base interval. 401, 403 and 404 end the loop because retrying a rejected credential or a missing resource every few seconds only adds load and noise; control passes to the sign-in flow or the not-found UI, which restarts polling after it succeeds. Retry-After is honored when present and is allowed to exceed the 60-second cap, within a sanity limit of five minutes. The mine handle makes sure that a request aborted by pausing cannot schedule a second timer behind the one that replaced it. And a failed fetch is never evidence that the state did not change; it only means the client does not know.
This is still a first version. A production client adds the initial render, cleanup when the component unmounts, tab visibility handling (below) and, if the server sends one, a bounded use of the suggested delay. For the general shape of retry delays and why the jitter is multiplicative, see Retry & Backoff.
Adaptive Polling, Visibility and Retry Behavior
Figures 7–8: poll when it is useful, back off when it is not, discard what is stale
Match the interval to what the user is doing
A job progress page can poll every two seconds while the job runs and stop as soon as the state is terminal, which the loop above already does for DELIVERED. A dashboard can poll every five seconds while visible and once a minute while backgrounded, or suspend entirely and refresh on focus. Browser background timers are throttled (Chromium-based browsers, for example, align hidden-tab timers to roughly once per second and may limit them further after a tab has been hidden for a while, with details that vary by browser and version), so never promise exact intervals.
The server can take part too. If the response includes a suggested poll_after_ms, the client uses it within bounds it controls. This gives the backend a knob: a long-running job might advertise 2,000, an idle dashboard 30,000, and an overloaded service a larger value that sheds load without rejecting anyone. The bounds matter, because a buggy or hostile value must not turn a poller into a flood or silence it for a day.
// Replaces "delayMs = BASE_MS" in the 200 branch of pollOrder().
const hint = Number(next.poll_after_ms);
delayMs = Number.isFinite(hint) ? Math.min(Math.max(hint, 1000), MAX_MS) : BASE_MS;
waitMs = delayMs;// Pause while the tab is hidden, refresh soon after it returns. Uses the variables from the loop above.
function onVisibilityChange() {
if (document.hidden) {
running = false; // no timer, no request in flight
clearTimeout(timer);
controller?.abort();
} else if (!running && !finished) {
running = true;
delayMs = BASE_MS;
timer = setTimeout(pollOrder, Math.random() * 1000);
}
}
document.addEventListener('visibilitychange', onVisibilityChange);
// On unmount: stopPolling(); document.removeEventListener('visibilitychange', onVisibilityChange);When the tab returns, the client refreshes within a second instead of replaying every poll it missed.
That last point generalizes. If the user closes the page, cancel outstanding work. When the page reopens, fetch the current state; do not try to reconstruct the polls that would have happened in between.
Backoff for failures
Transient network failures should produce bounded exponential backoff with jitter, as in Figure 7. 429 and 503 may carry Retry-After, either as a number of seconds or as an HTTP date, and a polite client honors it when feasible. Permanent 401/403 failures should not be retried on a timer indefinitely. The cap is a trade: too high and the page stays stale long after the outage ends, too low and a recovering service meets the whole fleet at once. Jitter applied after the cap is what stops clients that failed together from retrying together. Rate limits on the server side are the counterpart of this discipline; see Rate Limiting.
- Base delay
- 20 s
- Jittered wait
- 16–24 s
- × 0.8 to × 1.2
- Requests / min (one client)
- 3
- Load vs healthy 5 s poll
- 25 %
Discard older responses
If the response is versioned, reject an older response that arrives after a newer one. This matters when more than one component, tab or in-flight request fetches the same resource: a refresh button, a retry, or a service worker can each produce an answer that was correct when sent and is wrong by the time it lands. Comparing versions makes the rule trivial. Comparing arrival order cannot.
The version must be monotonic per resource and come from the server; a client-side timestamp or a response counter is not a substitute. With a single serialized loop per tab the situation is rarer, but a user interaction that triggers an out-of-band refresh recreates it immediately.
Polling at Scale: Where the Cost Actually Comes From
Figure 9: every layer a poll passes through, and what to measure
Total cost has layers: request termination at the edge, authentication checks, API execution, database reads, serialization and network bytes. Figure 9 marks which of those a 304 avoids. At 20,000 requests per second the expensive part is often a database query run on every unchanged poll, so most of the work is in making the unchanged case cheap.
Cache stable status fields, prefer point lookups by primary key, avoid joins on the hot path, and consider read replicas where the freshness budget allows; the replica lag is then part of staleness, and the version check must come from the same source as the body or the ETag will lie. Coalesce identical work only when authorization and cache keys are correct, because the most expensive polling bugs are the ones that serve one user's data to another.
Where the data is not personalized (a public job page, a status board), a short Cache-Control: public, max-age=2 lets a CDN absorb most polls, subject to your CDN's behavior and how it collapses simultaneous misses. For personalized endpoints that option is off the table, so the lookup must be cheap by design. If the service is overloaded despite all of this, shed work at the edge in a controlled way (load shedding) and let Retry-After or poll_after_ms slow the clients down.
What to monitor
Track both the load and the experience; a dashboard with healthy API latency can still feel stale because the interval is long.
- Active polling clients, and requests per second against the
N / Tyou expect. - Conditional-hit ratio (304 over total) and average response size.
- p95 and p99 latency of the status endpoint, and the database or cache query rate behind it.
- Retry rate, plus the rate of 429 and 503 responses, which tell you clients are already being refused.
- Observed staleness: the age of the state at the moment a client renders it, measured from the server-side update time so client clock skew does not distort it.
When Polling Is the Better Design
Infrequent updates, simple infrastructure, a few seconds of tolerance
Polling is attractive when updates are infrequent, clients are heterogeneous, operational simplicity matters and a few seconds of staleness are acceptable. It is also a robust fallback when long-lived connections are blocked or cut by intermediaries, which is why many real-time stacks keep a polling path even when they prefer another transport. An order status screen that changes every few minutes is the textbook case: the polling endpoint is a plain cached read, it is trivial to debug with curl, and it needs no connection state in the server.
Resist upgrading to WebSockets merely because a UI contains changing numbers. The more useful questions are whether the product needs server-initiated delivery, how many clients stay connected at once, and what recovery has to look like after a disconnect. If the answer is that updates must arrive within a second or two for a large audience, hold the request open with long polling or stream one-way events with SSE. If it is that a few seconds is fine, make the unchanged poll cheap, add jitter and backoff, and spend the engineering time elsewhere.
Four signals indicate that a persistent channel is worth its operating cost. Direction: if only the server has news, use SSE; if the client also sends frequent messages over the same session, a WebSocket is the two-way option. Frequency against interval: when changes arrive faster than you would be willing to poll, or most polls come back unchanged, you are paying for requests that carry nothing. Client count: a persistent connection costs memory and a file descriptor per client while idle, whereas a poll costs nothing between requests, so the saving only appears when the request volume N / T is large enough to outweigh that. Network constraints: proxies and load balancers with short idle timeouts or response buffering can break long-lived connections, and if you cannot change them, polling stays as the dependable path.
A "last seen" or online badge is often implemented by polling, either by the client asking for a status or by a heartbeat that refreshes a timestamp or expiring key. Remember that presence is a state model (who counts as online, for how long after the last signal, what happens on disconnect), not a transport: polling, SSE or WebSockets can all carry it, and a polled "last seen" value is only as fresh as the interval.