See It Flow

Core Concepts

Polling

The simplest real-time strategy: periodically ask the server for updates. Trades efficiency for simplicity.

Mangesh MandlikLast updated:

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

Polling
Empty Polls
0 / 0
⏱️Up nextDelayed Discovery

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

One poll on the wire
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”.

6 s
Polling timeline: freshness depends on when the change landsA browser polls the order API every 5 seconds, at 0, 5, 10, 15, 20 and 25 seconds. The order changes from PREPARING version 18 to DISPATCHED version 19 at 6 seconds. The first poll that starts after the change is at 10 seconds, so the user sees the new state 4 seconds after it happened. Polls that find the same version are answered with 304 Not Modified and no body.BrowserOrder (DB)API reply0 s5 s10 s15 s20 s25 sPREPARING · v18DISPATCHED · v19change200 · v18304200 · v19304304304stale for 4 s
Polling timeline: freshness depends on when the change lands. Change at 6 s, seen at 10 s: 4 s of staleness. A change just after a poll waits almost a full interval; one just before a poll waits almost nothing. Averaged over uniformly random change times the wait is half the interval, plus network and processing latency. A change at exactly a poll instant is treated as landing just after it.
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.

Active clients (N)
Poll interval (T, seconds)
Each client's order changes every
Average request rate falls as 1/T and does not depend on whether anything changedWith 100,000 clients the average arrival rate is N divided by T. At T = 1 s it is 100,000 requests per second; at T = 5 s it is 20,000 requests per second.02550751001102030405060poll interval T (seconds)thousand req/s
  • requests per second (thousands) = N / T
Average request rate falls as 1/T and does not depend on whether anything changed. Average arrival rate for 100,000 always-on clients, assuming requests are evenly spread. The dot marks the selected interval. The curve ignores retries, start-up bursts and other endpoints.
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.

Client scheduling
Requests per second in half-second buckets, 1,000 clients, T = 5 sAll 1,000 clients start within 100 ms and poll on a fixed 5 s timer, so every poll lands in the same half-second bucket and the buckets between are empty. Dashed line: the average of 200 requests per second. Peak in this run: 2,000 requests per second.05001000150020000 s5 s10 s15 s20 stime since the first pollrequests / s
Requests per second in half-second buckets, 1,000 clients, T = 5 s. All 1,000 clients start within 100 ms and poll on a fixed 5 s timer, so every poll lands in the same half-second bucket and the buckets between are empty. Seeded simulation: the numbers are reproducible but illustrative. The dashed line is the average N / T = 200 requests per second; red bars exceed twice that.
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.

setInterval overlaps slow requests; scheduling after completion does notThe server takes 7 seconds per response while the interval is 5 seconds. With setInterval a new request starts at 0, 5 and 10 seconds, so request 2 starts while request 1 is still in flight and request 3 starts while 2 is. With the next poll scheduled after the previous one completes, request 1 runs 0 to 7 seconds, the client waits 5 seconds, and request 2 runs 12 to 19 seconds. There is never more than one request in flight.05101520secondssetInterval · request 1setInterval · request 2overlaps 1setInterval · request 3overlaps 2chained · request 1chained · wait 5 schained · request 2
setInterval overlaps slow requests; scheduling after completion does not. Seven-second responses against a five-second interval. setInterval stacks requests on a server that is already slow, which is exactly when it can least afford more. Chaining trades a longer effective period for bounded load: the period becomes latency plus wait.

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 polling loop with jitter, backoff and a stop condition
// 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.

What the poll loop does with each outcomeOne request starts the cycle. A 200 response renders the state if its version is newer and resets the delay to 5 seconds. A 304 keeps the current state and also resets the delay. A 429 or 503 doubles the delay up to 60 seconds and honors Retry-After. A network error or other 5xx response also doubles the delay. A 401, 403 or 404 stops polling and hands over to the authentication flow or the not-found view. Every path that continues schedules the next poll after the delay multiplied by a jitter factor between 0.8 and 1.2, then fetches again.fetch statusone request in flight200 OKrender if version newer · delay = 5 s304 Not Modifiedkeep state · delay = 5 s429 / 503double delay · obey Retry-Afternetwork error / other 5xxdouble delay, cap 60 s401 / 403 / 404stop, hand off to auth or UIsleep delay × jitterjitter 0.8–1.2, then fetch againstoppedno timer, no request
What the poll loop does with each outcome. Every continuing outcome ends in the same step: sleep for the delay times jitter, then fetch again. Only authentication and not-found failures end the loop.

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.

Conditional Requests with ETags

Figure 6: what a 304 saves, and what it does not

Downloading identical JSON on every poll wastes bandwidth. If the server supports validators, the client sends If-None-Match with the last ETag it saw. When the selected representation has not changed, the server can answer 304 Not Modified with no body. That reduces transfer size, but it does not eliminate the HTTP request, and it does not eliminate the database or cache lookup the server needs in order to know the validator is still current.

The conditional poll
GET /orders/847/status HTTP/1.1
If-None-Match: "order-847-v19"

HTTP/1.1 304 Not Modified
ETag: "order-847-v19"
Conditional polling: 200 with an ETag, then 304, then 200 againThree polls of the same order endpoint. The first has no validator, so the API reads the order row and answers 200 with the body and the ETag order-847-v19. The second sends If-None-Match with that ETag; the API still reads the current version, finds it unchanged and answers 304 Not Modified with no body. After the order is delivered, the third poll sends the same validator, the API reads version 20, and answers 200 with a new ETag and the new body. Every poll still reaches the database or cache.BrowserpollerAPIGET /orders/:id/statusOrders storeDB or cache1. GET /orders/847/status2. SELECT status, version3. DISPATCHED, v194. 200 + ETag "order-847-v19" + body5. GET + If-None-Match: "order-847-v19"6. SELECT status, version7. unchanged, v198. 304 Not Modified · no body9. GET + If-None-Match: "order-847-v19"10. SELECT status, version11. DELIVERED, v2012. 200 + ETag "order-847-v20" + body
Conditional polling: 200 with an ETag, then 304, then 200 again. A 304 saves the body, not the request. The API still terminated TLS, authenticated the caller and looked up the version before it could decide that nothing changed.

Read Figure 6 from the database lane. Every poll, conditional or not, ends in a lookup. A 304 only removes the serialization and the body bytes from the return trip. The page is a good place for the following arithmetic. Assume the 53-byte body from the earlier trace and, for illustration, about 700 bytes of request and response headers once cookies and tracking headers are counted. At 20,000 requests per second that is roughly 15 MB/s, about 120 Mbit/s, with bodies; a 304 on every poll removes only about 1 MB/s of it, around 7%. Conditional requests pay off when bodies are large, not when they are tiny, and the saving never touches request termination, authentication or the lookup.

Making the validator honest

A meaningful validator changes whenever the response representation changes. The simplest way to guarantee that is a version column that every write increments in the same statement, and an ETag built from it. If the body also includes a derived field such as an estimated arrival time, that field must be covered by the version or by the validator, otherwise clients will keep receiving 304 for a body that would now differ. Two more details: If-None-Match uses weak comparison, so a W/ prefix must not break the match, and a proxy that recompresses responses may turn a strong ETag into a weak one.

Server: validator from a cheap point read
// Node/Express. requireAuth sets req.user; db is a node-postgres style pool.
const express = require('express');
const app = express();
app.set('etag', false); // we compute the validator ourselves from the version column

// Weak comparison, as If-None-Match requires: ignore a W/ prefix, accept "*" and comma-separated lists.
function matchesIfNoneMatch(header, etag) {
  if (!header) return false;
  if (header.trim() === '*') return true;
  return header.split(',').some((tag) => tag.trim().replace(/^W\//, '') === etag);
}

app.get('/orders/:id/status', requireAuth, async (req, res) => {
  const id = Number(req.params.id);
  if (!Number.isSafeInteger(id)) return res.status(400).end();

  // Cheap point read that also enforces ownership: someone else's order looks like a missing one.
  const { rows } = await db.query(
    'SELECT status, version FROM orders WHERE id = $1 AND customer_id = $2',
    [id, req.user.id],
  );
  if (rows.length === 0) return res.status(404).end();

  const etag = `"order-${id}-v${rows[0].version}"`;
  res.set({ ETag: etag, 'Cache-Control': 'private, no-cache' });
  if (matchesIfNoneMatch(req.get('If-None-Match'), etag)) return res.status(304).end();

  res.json({ order_id: String(id), status: rows[0].status, version: rows[0].version });
});
Write path: bump the version with the change
-- PostgreSQL. Every write that changes the representation bumps the version, in the same statement.
UPDATE orders
   SET status = 'DISPATCHED', version = version + 1, updated_at = now()
 WHERE id = 847 AND customer_id = 1203
RETURNING status, version;

-- Optional hot-path cache (redis-cli). Write it after the commit; the TTL bounds how long a missed
-- update can keep an old validator alive. The cached value must still be checked against the caller.
SET order:847:status '{"status":"DISPATCHED","version":19,"customer_id":1203}' EX 60

The handler reads two columns by primary key and an owner id, which keeps ownership enforcement and validation in one cheap query. If computing the ETag required the same expensive aggregation that builds the body, the saving would be mostly network bytes, and the better fix would be a precomputed version or a cache in front of the aggregation.

Caching headers and who may store the response

The cache policy has to respect user-specific authorization, and a shared cache must never be allowed to hand one customer's order to another. Cache-Control: private, no-cache allows the browser's own cache to store the response but requires revalidation before each reuse. no-store forbids storing it at all and is the right choice for sensitive data. Conditional requests help only if both sides implement validator handling correctly.

The browser adds a wrinkle. With the default fetch cache mode and a response that carries an ETag and no-cache, the browser revalidates for you: it sends If-None-Match, receives the 304, and hands your script a normal 200 built from the cached body. The network panel shows a 304; your code never does. That is convenient, and it also means you cannot count 304s from JavaScript or react to them. If you set If-None-Match yourself, per the Fetch standard the browser stops managing the cache for that request and the 304 reaches your code. The client below does that on purpose, so it can skip rendering and tune the delay.

Client: carrying the validator
// Three edits to pollOrder() above. Merge them; they are fragments, not a second program.

// 1. State, next to lastVersion:
let etag = null;

// 2. Send the validator. Setting If-None-Match yourself means the response reaches your code as a real 304.
const response = await fetch('/orders/847/status', {
  headers: etag ? { 'If-None-Match': etag } : {},
  signal: mine.signal,
  credentials: 'include',
  cache: 'no-store',
});

// 3a. Before the 429/503 branch: unchanged, nothing to render, back to the base interval.
if (response.status === 304) {
  delayMs = BASE_MS;
  waitMs = delayMs;
} else if (response.status === 429 || response.status === 503) {
  /* ...as before... */
}

// 3b. Where a newer body is applied, keep its validator with it:
if (next.version > lastVersion) {
  lastVersion = next.version;
  etag = response.headers.get('ETag'); // cross-origin: the API must send Access-Control-Expose-Headers: ETag
  render(next);
}

These are edits to the loop above, not a standalone script. Tested merged: the loop renders each new version once, sends the previous ETag, treats 304 as no change and stops on the terminal state.

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.

Bounded server-suggested delay
// 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 polling while the tab is hidden
// 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.

2
Delay before the next poll after k consecutive failuresThe base delay is 5 seconds doubled for each consecutive failure and capped at 60 seconds: 5, 10, 20, 40, then 60 from the fourth failure on. A jitter factor of 0.8 to 1.2 is applied after the cap, so a capped delay lands between 48 and 72 seconds. With 2 consecutive failures the base delay is 20 seconds.02040605 s010 s120 s240 s360 s460 s560 s660 s760 s8consecutive failuresdelay (s)
Delay before the next poll after k consecutive failures. After 2 consecutive failures the base delay is 20 s and the actual wait is anywhere from 16 to 24 s. The cap protects freshness once the outage ends; the jitter keeps a crowd that failed together from retrying together. One success resets the delay to 5 s.
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.

Out-of-order responses: compare versions, not arrival orderThe page polls and the user also clicks refresh, so two requests are in flight. The API answers the second request first with version 20; the browser renders it and records version 20 as the newest seen. The response to the first request, version 19, arrives late. Because 19 is lower than 20 the browser discards it instead of rendering an older state over a newer one.BrowserUI stateAPIstatus endpoint1. request 1 (timer poll)2. request 2 (user clicks refresh)3. reply to 2: DELIVERED, v204. render v20 · lastVersion = 205. reply to 1: DISPATCHED, v19 (late)6. 19 < 20: discard
Out-of-order responses: compare versions, not arrival order. Overlapping requests, several tabs or a retry can deliver an old answer after a new one. A monotonic version in the payload makes the discard rule trivial; arrival order alone 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.

Cost layers of one poll, and what a 304 skipsA single poll passes through the edge and load balancer, authentication, the API handler, a version lookup against a cache or database, and finally body serialization and transmission. When the validator matches, the first four layers are still paid on every poll; only serializing and sending the body is skipped.Edge / LBTLS, routingpaid on every poll200 or 304Authenticationtoken or sessionpaid on every poll200 or 304API handlerauthorize the orderpaid on every poll200 or 304Version lookupcache or point readpaid on every poll200 or 304Body and bytesserialize, transmitskipped on 304only 200 carries it
Cost layers of one poll, and what a 304 skips. Conditional requests remove the last layer only. If computing the ETag needs the same expensive query as building the body, the fourth layer is where most of the cost sits and a 304 saves little more than bytes.

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 / T you 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.