cf.completefrontend
My progress
FUNDAMENTALS · MODULE 5

Short polling

The browser asks the server "anything new?" on a timer: the simplest way to keep a page fresh, and the right answer more often than people think.

The problem: the server can't speak first

You order dinner from a food delivery app. The order page says "Restaurant is preparing your food". Twenty minutes later the driver is at your door, and the page still says "preparing". It only changes when you pull down to refresh.

Nothing is broken. The page loaded once, showed what the server said at that moment, and then stopped. That is how plain HTTP works: the browser asks, the server answers, and the conversation ends. The server cannot send a message to the browser on its own, because there is no open request for it to answer.

Many pages need to change while the user looks at them: an order status, a build log, a payment that is being confirmed, a count of unread messages, a file export that takes a minute. Each one needs some way for news on the server to reach a page that is already open.

Four ways to get news to the browser

There are four common techniques, and this module covers each of them. They differ in who starts the conversation and how long the connection stays open.

  • Short polling. The browser sends a normal request every few seconds and asks "anything new?". The server answers at once, even if the answer is "no".
  • Long polling. The browser asks, and the server holds the request open until it has news (or a timeout passes), then answers. The browser asks again straight away.
  • Server-sent events (SSE). The browser opens one HTTP response that never ends, and the server writes events into it as they happen.
  • WebSockets. The browser upgrades a connection to a two-way channel, and either side can send a message at any time.

Short polling is the oldest and the simplest. It needs no special server, no long-lived connections, no new protocol, and no change to your load balancers, proxies, or CDN. Any endpoint that returns JSON can be polled. That is why it is still everywhere: in payment pages, CI dashboards, admin panels, and the status pages of large APIs.

A sequence diagram between the browser and the server. The browser asks GET /orders/481/status and gets 200 preparing. It waits 10 seconds and asks again: 200 preparing, nothing new. Just after that, the status changes on the server to on the way. The browser only learns it at its third poll, 10 seconds later, which gets 200 on the way.BrowserServerGET /orders/481/status200 preparingwait 10 sGET /orders/481/status200 preparing (nothing new)status changeswait 10 sGET /orders/481/status200 on the way
Every poll gets an answer at once, and most say the same thing as last time. A change that happens between polls sits on the server until the next request asks for it.

What it costs

The price is a trade-off you set with one number, the interval (the wait between two requests). Ask often and most answers say "nothing new": wasted requests. Ask rarely and news reaches the user late. No interval gives you both, so a good polling design is mostly about two things: asking less often when it does not matter, and making each "nothing new" answer as cheap as possible.

This lesson takes you through both. You will size the load with real numbers, write a polling loop that does not pile up requests, slow down when nothing changes, stop in hidden tabs, use ETag and 304 to make empty answers almost free, spread clients out with jitter, and let the server tell clients when to come back. At the end you will know when polling is the right choice and how to defend it in an interview.

Check your understanding · Exercise 1

Why can't a plain HTTP server push a status change to a page that is already open? Pick one.

The mental model: delay against requests

Think of a child in the back seat asking "are we there yet?". The driver answers every time, even when nothing has changed. Ask every minute and the child learns quickly that you have arrived, but the driver says "no" sixty times an hour. Ask every half hour and the driver gets peace, but the child may sit in the parked car for 29 minutes before finding out.

Short polling is exactly that. Two numbers matter, and the interval sets both of them: how late the user sees a change (the delay), and how many requests your servers answer (the load). You can't improve one without paying in the other, so you need to be able to work out both quickly.

How late does a change arrive?

A change on the server happens at a random moment between two polls. If it lands just before a poll, the user sees it almost at once. If it lands just after one, it waits a whole interval. On average it waits half an interval, plus the time the request itself takes.

A timeline of 30 seconds with polls at 0, 10, 20, and 30 seconds. Change A happens on the server at 1 second, just after a poll, and the browser sees it at 10 seconds: it waited 9 seconds. Change B happens at 18 seconds and is seen at 20: it waited 2 seconds. On average a change waits half the interval, 5 seconds.serverpolls0s10s20s30schange Awaits 9 schange Bwaits 2 son average a change waits half the interval: 5 s
Where a change lands between two polls decides how long it waits. Just after a poll is the worst case, a whole interval; on average it is half the interval.

So a 10-second interval with 200 ms responses means a typical delay of about 5.2 seconds and a worst case of about 10.2 seconds. That is fine for an order status and far too slow for a chat message. Always quote both numbers: the average sets how the feature feels, the worst case sets what users complain about.

How many requests does it cost?

The load formula is one line: requests per second = users with the page open divided by the interval in seconds. Count users who have the page open at the same moment (concurrent users), not daily users.

Load scales with open pages and inversely with the interval. Halving the interval doubles every number in the row.
Open pagesIntervalRequests per secondRequests per day
110 s0.18,640
5,00010 s50043 million
20,00010 s2,000173 million
20,0002 s10,000864 million
20,00060 s33329 million

Now compare that with how often the data really changes. If an order status changes 6 times in a 40-minute delivery and the page polls every 10 seconds, that is 240 polls for 6 changes: more than 97% of answers say "nothing new". This ratio, useful answers to total answers, is the number that tells you whether polling is wasteful for a feature.

Requests are not free even when the answer is empty. Each one carries request headers and cookies (often 500 bytes to 2 KB), passes through your load balancer, authenticates the user, and usually reads a database or a cache. On a phone, each request also wakes the cellular radio, which then stays in a high-power state for several seconds, so frequent polling drains the battery even when almost no bytes move.

Pages open at once
Poll every
Savings
A bar chart for 20,000 open pages polling every 10 seconds. Requests: 2,000 per second with no savings, 2,000 per second with your setup. Data: 80.0 MB/s with no savings, 80.0 MB/s with your setup.requests, no savings2,000 per srequests, your setup2,000 per sdata, no savings80.0 MB/sdata, your setup80.0 MB/s
Each pair of bars is drawn to scale against its own "no savings" bar. Assumes 60% of open tabs are hidden, 9 changes an hour (6 in a 40-minute delivery), 40 KB per full answer, and 250 bytes per 304.

20,000 pages every 10 s is 2,000 requests per second before savings. A change waits 5 s on average and 10 s at worst. 97.5% of answers say "nothing new".

There is no magic interval

Every fixed interval is a compromise, so stop looking for the perfect one. Pick a starting interval from the delay the product can live with, then use the techniques in the rest of this lesson to cut the load without making active users wait: slow down when nothing changes, stop when nobody is looking, make empty answers cheap, and let the server steer.

Check your understanding · Exercise 2

50,000 users have a dashboard open and it polls every 5 seconds. Which statements are true? Select all that apply.

Writing the loop: setTimeout chains, not setInterval

The polling loop has four steps: send a request, use the answer, wait, repeat. The obvious way to write it is setInterval(check, 5000). It is also the most common polling bug.

What goes wrong with setInterval

setInterval fires on the clock, every 5 seconds, whether or not the last request has finished. On a good network that does not matter: each request takes 150 ms and is long done before the next one starts. On a slow train connection, or when the server is overloaded and answers take 7 seconds, the requests start to overlap.

  • They pile up. A new request starts every 5 seconds but each takes 7, so more and more are in flight at once. An overloaded server gets even more traffic from the very clients it is failing to serve, which makes it slower still.
  • They finish out of order. Request 2 may hit a slow database replica and request 3 a fast one. If request 3 answers first with "Out for delivery" and request 2 answers later with the older "Preparing", the page goes backwards.
  • They waste connection slots. Over HTTP/1.1 a browser opens at most 6 connections to one host. Several stuck polls can make the page's other requests wait in line.
Two timelines over 18 seconds when each request takes 9 seconds. Top, setInterval every 3 seconds: requests 1 to 6 start at 0, 3, 6, 9, 12, and 15 seconds, so three are in flight at once. Bottom, a setTimeout chain: one request from 0 to 9 seconds, a 3-second sleep, then the next request from 12 seconds.setInterval(check, 3000)#1#2#3#4#5#6a new request every 3 s, each takes 9 s: 3 in flightsetTimeout chain, 3 s after each answerrequest takes 9 ssleep 3 s0s3s6s9s12s15s18sone request at a time: a poll every 12 s
With setInterval, a slow server gets more requests at once, and they can answer out of order. The chain keeps one request in flight and slows down with the server.

The fix: schedule the next poll after the answer

A setTimeout chain waits for each answer, then schedules the next request. There is only ever one request in flight, so nothing piles up and nothing arrives out of order. When the server slows down, the clients slow down with it, which is exactly what an overloaded server needs.

The chain has a second benefit: the wait can change on every turn. That is what makes adaptive intervals, backoff, and server hints (later chapters) easy to add. With setInterval you would have to clear and recreate the timer each time.

ts
async function poll(signal: AbortSignal) {
  let wait = 5000;
  while (!signal.aborted) {
    try {
      const res = await fetch("/api/orders/481/status", {
        signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]),
      });
      if (res.ok) {
        const data = await res.json();
        render(data);
        if (data.status === "delivered") return; // final state: stop
      }
    } catch {
      if (signal.aborted) return; // stopped on purpose
    }
    await sleep(wait, signal);
  }
}

const controller = new AbortController();
poll(controller.signal); // controller.abort() on unmount stops it
One request at a time, each with an 8-second limit, and a clean stop. sleep is a setTimeout wrapped in a promise that also resolves early when the signal aborts.

Four details that matter

  • A time limit on every request. Without one, a request that hangs for minutes freezes the loop. AbortSignal.timeout(8000) cancels it after 8 seconds, and AbortSignal.any combines it with the signal that stops the whole loop.
  • A clean stop. In a React component, start the loop in an effect and call controller.abort() in its cleanup. Forgetting this leaves loops running after the user navigates away, and every visit to the page adds one more.
  • A final state. Most polled things end: the order is delivered, the build passed, the export is ready. Stop polling there. A loop that keeps asking about a finished job is pure waste.
  • Skip work when nothing changed. Compare a version number or updatedAt with the last answer and skip rendering if they match. This keeps the UI calm and avoids losing the user's scroll position or text selection.

If you use a data library, it already does most of this. TanStack Query takes a refetchInterval option (a number, or a function that returns the next interval or false to stop) and does not poll hidden tabs unless you set refetchIntervalInBackground. SWR has refreshInterval and only polls hidden tabs if refreshWhenHidden is on. Know what they do so you can explain it, but don't hand-roll a loop when the app already uses one of them.

Check your understanding · Exercise 3

A page uses setInterval to poll every 3 seconds. The server is under load and answers take 9 seconds. What happens? Select all that apply.

Adaptive intervals and backoff

A fixed interval treats every moment the same, but real data has rhythms. A delivery status hardly moves while the restaurant cooks, then changes often once the driver is close. A build log changes every second while the build runs, and never again after it ends. A fixed interval is too fast for the quiet parts and too slow for the busy parts.

Adaptive intervals: follow what is happening

An adaptive interval changes the wait based on what the last answers said. The usual rules are short enough to remember:

  1. Start short, say 2 seconds, because the user just arrived and is paying attention.
  2. Each time an answer says "nothing new", wait longer: multiply the wait by 1.5 or 2, up to a cap such as 30 or 60 seconds.
  3. When an answer brings a change, or the user clicks, types, or focuses the window, drop back to the shortest wait. Activity predicts more activity.
  4. When the thing reaches a final state, stop.

The effect on load is large. A page that polls every 2 seconds sends 1,800 requests an hour. With waits of 2, 4, 8, 16, then 30 seconds for the rest of a quiet hour, it sends about 125. Active moments still feel instant, because any change resets the wait to 2 seconds.

Strategy
User
Ten minutes of one order page with Adaptive 2 to 30 s. The status changes 7 times: twice in the first 10 seconds, four times between 3:20 and 3:50, and once at 8:02. The page sends 38 polls, 5 of which bring news. Changes wait 9.0 seconds on average, 22 at most.serverpollsclick02 min4 min6 min8 min10 min
Top row: changes on the server. Bottom row: polls, with runs of polls 4 s or less apart drawn as one band. Small squares under the line mark polls that brought news.

Adaptive 2 to 30 s: 38 polls in 10 minutes, 5 with news. A change waited 9.0 s on average and 22 s at most.

Backoff: when the server is in trouble

Adaptive intervals react to the data. Backoff reacts to errors. When a poll fails with a network error, a timeout, or a 5xx status, polling again at the normal rate is the worst thing to do: if the server is down because it is overloaded, thousands of clients retrying every 2 seconds keep it down.

Exponential backoff doubles the wait after each failure in a row: 2, 4, 8, 16, 32 seconds, up to a cap like 2 minutes. The first success resets it to normal. Add randomness to each wait (the jitter chapter explains why), so that clients that failed together don't all come back together.

ts
const MIN = 2_000;
const MAX = 30_000;
const ERROR_MAX = 120_000;

function nextWait(prev: number, outcome: "changed" | "same" | "error") {
  if (outcome === "changed") return MIN;
  if (outcome === "same") return Math.min(prev * 2, MAX);
  return Math.min(prev * 2, ERROR_MAX); // back off harder on errors
}

// user activity also resets the wait
window.addEventListener("focus", () => { wait = MIN; wakeNow(); });
Three outcomes, three rules. wakeNow cuts the current sleep short so the user gets a fresh answer at once.

Tell the user when data may be old

When polls keep failing, the page still shows the last good data, and the user can't tell it is stale. Show a quiet note such as "Last updated 2 minutes ago" with a retry button, and put it in a polite live region (aria-live="polite") so screen reader users hear it without being interrupted. Don't announce every successful poll: a screen reader that says "updated" every 5 seconds is unusable.

A state machine with four states. Polling fast waits 2 seconds. On nothing new it moves to slowing down, where the wait doubles up to 30 seconds. A change, a click, or focus moves it back to polling fast. From polling fast, an error, 429, or 5xx moves it to backing off, where the wait doubles up to 2 minutes with jitter; a success moves it back to polling fast. From slowing down, a final state, 401, or 404 moves it to stopped, with no more requests.polling fastwait 2 sslowing downwait x2, up to 30 sbacking offx2 up to 2 min, jitterstoppedno more requestsnothing newchange, click, focuserror, 429, 5xxsuccessfinal state, 401, 404
Data decides between fast and slow, errors send the poller into backoff, and a final answer stops it for good. Errors can happen in any state; the diagram shows one arrow to keep it readable.

Not every error deserves backoff. A 401 means the session expired: polling again will never work, so stop and send the user to sign in. A 404 means the thing was deleted: stop and say so. Backoff is for errors that might go away on their own: network failures, timeouts, 429, 502, 503, and 504.

Check your understanding · Exercise 4

A poll starts failing with 503 Service Unavailable. Which reactions are right? Select all that apply.

When nobody is looking: hidden tabs, many tabs, offline

A large share of polling traffic comes from pages nobody is looking at. People leave dozens of tabs open for days. A dashboard in a background tab, polling every 5 seconds, sends 17,280 requests a day to update numbers no one reads. Stopping that is the single cheapest saving in polling.

The Page Visibility API

The Page Visibility API tells a page whether it can be seen. document.visibilityState is "visible" or "hidden", and the visibilitychange event fires when it switches. A page becomes hidden when the user moves to another tab, minimizes the window, switches apps on a phone, or locks the screen.

The rule is simple: stop polling when hidden. When the page becomes visible again, poll once right away, then go back to the timer. The immediate poll is what makes pausing invisible: the user comes back and sees fresh data at once instead of waiting a whole interval.

ts
let controller: AbortController | null = null;

function start() {
  if (controller) return;
  controller = new AbortController();
  poll(controller.signal); // the first request goes out immediately
}

function stop() {
  controller?.abort(); // ends the loop and any request in flight
  controller = null;
}

function sync() {
  if (document.visibilityState === "visible" && navigator.onLine) start();
  else stop();
}

document.addEventListener("visibilitychange", sync);
window.addEventListener("online", sync);
window.addEventListener("offline", sync);
sync();
Hidden or offline means stop. Visible and online means ask now, then continue on the timer.

Browsers throttle hidden timers anyway

Browsers already slow down timers in hidden tabs to save battery, but not in a way you can plan around. In Chrome, timers in a hidden tab are checked once per second. After the page has been hidden for 5 minutes, Chrome checks chained timers only once a minute. A chained timer is one set inside another timer's callback, 5 or more in a row, on a page that has been silent for 30 seconds and has no WebRTC connection.

A polling loop usually sets its next timer after a fetch answer arrives, in a later network task, not inside a timer callback. By Chrome's definition that is not a chain, so the once-a-minute rule may never apply, and the tab keeps polling every 5 seconds for nobody. Firefox on desktop also checks hidden-tab timers at most once per second, and Firefox on Android waits at least 15 minutes. Safari on iOS, like most mobile browsers, often suspends a background tab completely.

Throttling rules differ by browser and change over time. Don't rely on them: pause on purpose and poll on return.
Page stateA 5 s polling timerWhat it means for polling
VisibleRuns every 5 s, as writtenNormal polling
Hidden, under 5 minutes (Chrome)Checked once a second, so still about every 5 sStill polling, for nobody
Hidden, over 5 minutes, timer set after a fetch (Chrome)Usually still about every 5 sPolling for nobody, for as long as the tab stays open
Hidden, over 5 minutes, chain of 5 or more timers (Chrome)Checked once a minuteData is up to a minute old when the user returns
Background tab on a phoneOften suspended entirelyNo polls at all, then a stale page on return

So hidden-tab polling is both wasteful and unreliable. Pausing on purpose makes the behavior predictable, and the poll on return fixes stale data whichever throttling the browser applied.

When to keep polling in the background

A few features genuinely need background updates: the tab title that shows "(3) Inbox", or an alert the user asked for, like "tell me when my export is ready". Keep polling for those, but at a slow rate such as once a minute, and accept that the browser may delay it further. If the alert truly matters when the tab is closed, use web push notifications instead.

Many tabs of the same app

A user with your app open in 5 visible windows, say on two monitors, polls 5 times as much. Pausing hidden tabs fixes most of this; for the rest, let one tab poll and share the results. A tab can take a lock with the Web Locks API (navigator.locks.request), become the leader, poll, and post each answer to the other tabs through a BroadcastChannel. When the leader tab closes, its lock is released and another tab takes over.

An architecture diagram of one browser with three tabs of the same app. Tab 1 holds the Web Lock named poller and is the leader: it sends GET every 10 seconds to the API. It posts each answer to a BroadcastChannel, which delivers it to tab 2 and tab 3. Tabs 2 and 3 wait for the lock. When tab 1 closes, the lock is freed and tab 2 becomes the poller. The API sees 1 poll instead of 3.one browser, one origintab 1: leaderholds the locktab 2waits for the locktab 3waits for the lockBroadcastChannel"orders"Web Lock"poller", held by tab 1API1 poll, not 3GET every 10 spost each answertab 1 closes:lock is freed,tab 2 polls
The lock picks exactly one leader; the channel shares its answers. If the leader closes, the browser frees the lock and a waiting tab takes over without any extra code.

Check your understanding · Exercise 5

A user switches back to a tab that paused its 10-second polling while hidden. What should happen? Select all that apply.

Making "nothing new" cheap: ETag, 304, and caches

Even after pausing hidden tabs and slowing down quiet pages, most polls still find nothing. So the next goal is to make the empty answer as cheap as possible for the network, the server, and the browser. HTTP already has the tool for this: conditional requests.

How ETag and If-None-Match work

  1. The server labels each version of the data with an ETag (entity tag), a short string in quotes such as "v42", sent in the ETag response header.
  2. The client keeps that tag with the data. On the next poll it sends it back: If-None-Match: "v42".
  3. If the data is still version 42, the server answers 304 Not Modified with no body. The client keeps what it has.
  4. If the data changed, the server answers 200 with the new body and a new tag, ETag: "v43".
Annotated HTTP messages in two columns. Left, the response to the first poll: 200 OK, ETag v42, and a body of 40 KB of JSON. The client stores the tag and sends it back. Right, the next poll sends If-None-Match v42 and gets 304 Not Modified with no body, about 250 bytes, and the client reuses its data.first poll: the response200 OKETag: "v42"body: 40 KB of JSONnext poll: request, responseIf-None-Match: "v42"304 Not Modifiedbody: noneclient stores the tagand sends it backonly headers: about 250 Bclient reuses its datasame answer: 40 KB on a 200, about 250 bytes on a 304
The tag from the first answer comes back in If-None-Match. While the data is unchanged, a poll costs about 250 bytes of headers instead of 40 KB.

What a 304 saves, and what it does not

A 304 still costs a full round trip, so it does not make updates faster. What it removes is the body. For a 40 KB order list, each empty poll drops from about 40 KB plus headers to a few hundred bytes of headers. At 2,000 polls per second that is about 80 MB per second of JSON that is never sent, never parsed, and never re-rendered.

The server saves real work only if it can check the version without building the response. Computing the ETag by hashing the full JSON still means loading everything from the database first: you save bandwidth but not CPU. Better is a version number or an updated_at timestamp on the record, which the server can read with one cheap query or from a cache such as Redis, and answer 304 before touching anything else.

Two details that trip people up

  • fetch and 304. When your code sets If-None-Match itself, the browser's HTTP cache steps aside and fetch hands you the raw 304 with an empty body. Your code must keep the last data and reuse it. The alternative is to let the browser do it: send Cache-Control: no-cache with an ETag, and the browser revalidates on its own and gives your code a normal 200 from its cache. That is simpler, but you can no longer tell "changed" from "not changed" by the status.
  • Cross-origin APIs. If-None-Match is not on the list of headers allowed without a CORS preflight, so polling another origin with it adds an OPTIONS request first. Send Access-Control-Max-Age so the browser caches the preflight (Chrome caps that at 2 hours). The server must also list ETag in Access-Control-Expose-Headers, or your code can't read the tag.
ts
let etag: string | null = null;
let last: OrderList | null = null;

async function check(signal: AbortSignal) {
  const res = await fetch("/api/orders", {
    headers: etag ? { "If-None-Match": etag } : {},
    signal,
  });
  if (res.status === 304) return { data: last, changed: false };
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  etag = res.headers.get("ETag");
  last = await res.json();
  return { data: last, changed: true };
}
Keep the tag and the data together. The changed flag feeds the adaptive interval from the previous chapter.

When everyone polls the same thing: let a CDN answer

Some polled data is the same for every user: a match score, a public status page, the price of a concert ticket tier. Then you don't need your servers to answer each poll at all. Send Cache-Control: public, max-age=5 and a CDN (a network of caches near users) answers every poll for 5 seconds from its copy.

A million fans polling a score every 5 seconds is 200,000 requests per second at the CDN, but your origin sees about one request per 5 seconds from each CDN location: a few dozen requests per second in total. The cost is up to 5 seconds of extra delay, which is usually fine for a score.

Pushing the same score over WebSockets would mean a million open connections to run. Personal data (my orders, my inbox) can't be shared this way: mark it private so shared caches never store it.

Check your understanding · Exercise 6

Your poll sends If-None-Match: "v42" and the data has not changed. What is true? Select all that apply.

Spreading the load: jitter and server hints

So far we have looked at one client. Your server sees all of them at once, and the way their polls line up in time matters as much as how many there are. 2,000 requests per second spread evenly is easy. The same requests arriving in one burst at the top of every 10 seconds is a spike that needs ten times the capacity.

How clients fall into step

Clients start polling at random times, so their polls are usually spread out. Then something lines them up, and plain fixed intervals keep them lined up forever. Common causes:

  • A deploy or outage. Every client's poll fails at once, every client backs off by the same 2, 4, 8 seconds, and they all come back in the same second. This is the thundering herd: the burst can knock the server over again just as it recovers.
  • A shared event. A match kicks off, a sale opens at 9:00, or a push notification brings 50,000 people to the same page in the same minute.
  • Clock-aligned code. Polling "on every full minute" (setTimeout until :00) makes every client in the world ask at the same instant.

Jitter: add randomness to every wait

Jitter is a small random change to each wait so that clients drift apart. Instead of waiting exactly 10 seconds, each client waits a random time between 8 and 12 seconds. The average interval, and so the average load, stays the same, but the polls spread across the whole window instead of stacking in one moment.

Each wait
A bar chart of requests per second for the 60 seconds after 10,000 clients, polling every 10 seconds on average, all failed at the same moment. Each wait is exactly 10 s. The busiest second gets 10,000 requests; the minute gets 50,000 in total.dashed line: 1,000 per secondbusiest second: 10,000 requests0s10s20s30s40s50s60s
All three settings send about the same number of requests in the minute. Only the shape changes: without jitter every client asks in the same second, forever.

No jitter: each client waits exactly 10 s. Busiest second: 10,000 requests. Whole minute: 50,000 requests.

For backoff after errors, use more randomness. A common choice is "full jitter": pick a random wait between zero and the current backoff limit. If the limit is 16 seconds, one client waits 3, another 11, another 15. The AWS Architecture Blog showed that full jitter finishes the work with far fewer total calls than backoff without jitter, because clients stop colliding.

ts
// polling: plus or minus 20% around the target
const withJitter = (ms: number) => ms * (0.8 + Math.random() * 0.4);

// backoff after errors: "full jitter", anywhere from 0 to the cap
const backoff = (attempt: number) =>
  Math.random() * Math.min(120_000, 2_000 * 2 ** attempt);

// first poll after page load: spread the herd too
await sleep(Math.random() * 1_000, signal);
Plus or minus jitter keeps the average the same and removes the spikes. Full jitter spreads retries over the whole backoff window. Each costs one line.

Server hints: let the server set the pace

The client guesses when to ask again, but the server often knows. It knows the restaurant just started a 20-minute dish, that the export job is 30% done, or that it is overloaded right now. A good polling API lets the server tell the client when to come back. There are three common ways.

  • A field in the body. { "status": "cooking", "pollAfterMs": 30000 }. The simplest and most flexible: the server can change it per resource, per user, and per moment, and the client just obeys it, with jitter and within sane bounds.
  • A header. GitHub's REST API sends X-Poll-Interval: 60 on its events endpoints, telling clients how many seconds to wait, and raises it when its servers are busy. A header works the same on every endpoint and keeps the body unchanged.
  • Retry-After. The standard HTTP header, sent with 429 Too Many Requests or 503 Service Unavailable. It holds either a number of seconds (Retry-After: 30) or an HTTP date (Retry-After: Wed, 21 Oct 2026 07:28:00 GMT), so parse both: try the number first, otherwise subtract Date.now() from Date.parse(value). When it is present, wait at least that long, whatever your own backoff says. Some APIs also send it with 202 Accepted, as in the job example later in this lesson.

A standard example of server-led polling is the OAuth device flow (RFC 8628), used when you sign in to a TV app with your phone. The TV polls the token endpoint every interval seconds (5 by default). If it polls too fast, the server answers slow_down, and the TV must add 5 seconds to its interval for all later polls.

Server hints turn the interval into a dial the backend team can turn during an incident. If the database is struggling, raise pollAfterMs from 5 to 30 seconds and the load drops by more than 80% within about half a minute, with no app release. Clamp the hint on the client (for example between 1 and 300 seconds) so a server bug can't make clients poll every millisecond or never again.

Check your understanding · Exercise 7

After a 2-minute outage, 40,000 clients that were backing off without jitter all retry in the same second. What helps? Select all that apply.

Polling for job status

The most common and least controversial use of polling is waiting for a slow job: exporting a 50 MB CSV, transcoding an uploaded video, generating a PDF invoice, running a CI build, or confirming a payment with a bank. The work takes from seconds to minutes, far longer than one HTTP request should stay open.

Holding the request open until the job ends is fragile. Proxies and load balancers cut idle requests after 30 to 120 seconds, a phone switching from Wi-Fi to 4G drops the connection, and the user can't refresh the page without losing the result. The answer is the asynchronous request-reply pattern: start the job in one request, then poll a status resource until it is done.

The flow, step by step

  1. The client asks for the work: POST /exports with the filters.
  2. The server puts the job on a queue and answers at once with 202 Accepted, a job id, and a Location: /exports/e_91 header pointing at the status resource.
  3. The client polls GET /exports/e_91. Each answer says the state (queued, running, succeeded, failed), often a progress number, and when to ask next.
  4. When the state is succeeded, the answer includes where to get the result, such as a short-lived download URL. When it is failed, it includes an error the UI can show. Either way, polling stops.
A sequence diagram with the browser, the API, and a worker. The browser sends POST /exports. The API enqueues job e_91 for the worker and answers 202 with a Location header. The browser waits 3 seconds and polls GET /exports/e_91; the API answers running, 40%. The worker tells the API the file is ready. The browser polls again and gets succeeded with a download URL.BrowserAPIWorkerPOST /exportsenqueue e_91202 + Locationwait 3 sGET /exports/e_91running, 40%file readywait 3 sGET /exports/e_91succeeded + URL
The slow work never holds an HTTP request open. The 202 hands back a status URL, and each poll is a quick read of the job record.
http
POST /exports HTTP/1.1
Content-Type: application/json

{ "range": "2026-Q3", "format": "csv" }

HTTP/1.1 202 Accepted
Location: /exports/e_91
Retry-After: 2

{ "id": "e_91", "status": "queued" }

GET /exports/e_91 HTTP/1.1

HTTP/1.1 200 OK

{ "id": "e_91", "status": "running", "progress": 0.4, "pollAfterMs": 3000 }
The 202 answer points at the status resource and says how soon to ask. Each status answer carries progress and the next wait.

Designing the job resource well

  • Make the job a real resource with an id. The id survives a page refresh: store it in the URL (/exports?job=e_91) or in localStorage, and the page can pick up polling where it left off.
  • Make starting the job safe to retry. If the POST times out, the client doesn't know whether the job started. Send an idempotency key (a unique id per click) so a retried POST returns the same job instead of starting a second export.
  • Give progress, not just a spinner. A progress field from 0 to 1 lets the UI show a real progress bar, which makes a 40-second wait feel much shorter. Announce milestones (25%, 50%, done) through a polite live region, not every poll.
  • Match the interval to the job. A payment check that usually finishes in 3 seconds can poll every second for the first 10 seconds. A video transcode that takes 10 minutes should poll every 10 to 30 seconds. The server knows the expected duration best, so let it send the wait.
  • Give up eventually. After a time limit (say 5 minutes for a payment), stop and show a clear message with a way to check later. Don't poll forever.

You will meet this pattern in many cloud APIs. The AWS SDKs ship "waiters" that poll a resource (for example, until a server is running) with growing, randomized delays and a maximum wait. Azure's long-running operations return a status URL to poll. Payment providers such as Stripe send the final result to your server through a webhook (an HTTP request from their server to yours), while the page polls your own server for the status.

Think first

A user clicks "Export" for a report that takes about 2 minutes, then refreshes the page after 30 seconds. How should your design handle that?

When polling is the right answer

Short polling has a reputation as the naive option you replace with WebSockets once you are serious. That is wrong more often than it is right. Every technique in this module pays for lower delay with more moving parts, and many features don't need the lower delay.

What polling gets for free

  • Plain, stateless HTTP. Any server can answer any poll. Deploys, autoscaling, and serverless functions just work, because no server holds a connection to a client. A deploy of a WebSocket server, by contrast, drops every connected user, who all reconnect at once.
  • Works everywhere. Corporate proxies, firewalls, and old load balancers that break long-lived connections pass polls without trouble.
  • Caching. Polls are ordinary GET requests, so ETag, the browser cache, and CDNs all apply. No streaming technique can serve a million viewers from a CDN this simply.
  • Easy to run. You debug it in the DevTools Network tab, test it with plain HTTP mocks, rate-limit it with existing tools, and monitor it with existing dashboards.
  • Recovery is built in. Every poll asks for the current state, so a client that missed updates catches up on its next poll. Streams need resume logic (like SSE's Last-Event-ID) to avoid gaps.

Decision Order tracking for a delivery app: how should the status page get updates?

Short polling

GET /orders/481/status every 10 s, adaptive, paused when hidden, with ETag and a pollAfterMs hint

Pros

  • Works on any backend, CDN, and proxy
  • Stateless servers; deploys drop nobody
  • Missed updates fix themselves on the next poll

Cons

  • Updates arrive about 5 s late on average
  • Most requests return nothing new

Long polling

The server holds each request until the status changes or 30 s pass, then the client asks again

Pros

  • Updates arrive almost at once
  • Still plain HTTP requests

Cons

  • Servers hold thousands of waiting requests
  • Needs a way to wake the right request when data changes
  • Easy to lose an update between two requests

Server-sent events

One EventSource stream per page; the server writes an event on each change

Pros

  • Updates within milliseconds
  • Built-in reconnect and resume
  • One direction, which is all this page needs

Cons

  • Long-lived connections to run, scale, and drain on deploy
  • Some proxies buffer or cut streams

WebSockets

A two-way socket per page, subscribed to the order's channel

Pros

  • Lowest delay, both directions

Cons

  • Most infrastructure of all: sticky connections, pub/sub fan-out, reconnects, auth on a socket
  • Two-way is wasted on a page that only listens

What we pick: Short polling for the status page: a few changes per order, a delay of a few seconds goes unnoticed, and the backend stays simple. Move the driver's live map position to SSE if product wants smooth movement, since that changes every second or two.

A decision tree. First question: must updates arrive in under a second? No: short polling, adaptive with ETag. Yes: does the client also send often? Yes: WebSockets, two-way. No: are streams blocked by proxies? Yes: long polling as a fallback. No: server-sent events, a one-way stream.need under 1 s?short pollingadaptive, ETagclient sends often?streams blocked?WebSocketstwo-waylong pollingfallbackSSEone-way streamnoyesnoyesyesno
The first question settles most features. Only when updates must feel instant do you pay for a long-lived connection, and then the direction of traffic picks which one.

When to move to something else

Switch away from polling when one of these is true, and say which one in your design:

  • The delay must be under about a second. Chat messages, typing indicators, live cursors, multiplayer games, trading screens. Polling every 500 ms to get there is the worst of both worlds.
  • Each user has their own fast-changing data. CDN caching can't help, and the empty-answer ratio is huge. SSE or WebSockets send only when something changes.
  • The client also sends often. Collaborative editing or a game sends many small messages up as well as down: that is what WebSockets are for.
  • Changes come faster than the interval. A value that changes 10 times a second, polled every 2 seconds, shows only 1 in 20 of the states. If each change matters (log lines, chat messages), polling for the latest state loses them unless the API returns everything since a cursor.
A comparison matrix of four techniques. Short polling: delay 1 to 30 seconds, empty polls when idle, CDN cache yes, the client sends with a fetch. Long polling: delay under a second, held GET requests when idle, no CDN cache, a fetch. SSE: milliseconds, heartbeats, no CDN cache, a fetch. WebSockets: milliseconds, pings, no CDN cache, sends on the same socket.delaywhen idleCDN cacheclient sendsshort polling1 to 30 sempty pollsyesa fetchlong pollingunder 1 sheld GETsnoa fetchSSEmsheartbeatsnoa fetchWebSocketsmspingsnosame socket
Short polling pays in delay and empty requests, and gets CDN caching that no stream has. The streams win on delay; only WebSockets carry messages both ways on one connection.

Mixing techniques in one product

Real products rarely pick one technique for everything. A chat app streams messages over a WebSocket but polls once a minute for the unread count on other workspaces. A CI dashboard streams the log of the build you are watching but polls the status badges of the 30 others in the list. A ride-hailing app might poll the trip status and stream the car's position.

Polling is also the standard fallback. When a WebSocket or SSE connection keeps failing (a strict corporate proxy, for example), many clients drop to polling the same data so the feature still works, just with more delay. If your data layer hides the transport behind one interface, such as a subscribe(orderId, onChange) function, you can start with polling and switch later without touching the UI.

Check your understanding · Exercise 8

Which features are a good fit for short polling? Select all that apply.

Using it in a system design interview

Polling comes up in almost every frontend system design interview that has data changing on the server: a news feed with "new posts", a notifications badge, an order or ride tracker, a file upload, a dashboard, a CI page. The interviewer wants to see that you pick the real-time technique on purpose, size its cost, and know how to make it cheap.

When to bring it up

Bring it up during the high-level design, when you list how each piece of data reaches the page. For each one, state how fresh it must be and how often it changes, then pick a technique. Saying "the feed polls for new posts every 30 seconds, the chat streams over a WebSocket" shows judgment that "we'll use WebSockets" for everything does not.

A strong answer in five steps

  1. Requirement. "An order changes state about six times in 40 minutes, and a delay of a few seconds is fine."
  2. Size it. "At peak, 20,000 open tracking pages polling every 10 seconds is 2,000 requests per second. Most return nothing new."
  3. Cut it. "Pause hidden tabs and poll on return, adapt the interval from 2 to 30 seconds, use ETag and 304, and add jitter."
  4. Let the server steer. "Each answer carries a pollAfterMs, and the client respects Retry-After on 429 or 503, so we can slow the fleet during an incident without a release."
  5. Name the exit. "If product wants the driver's position to move smoothly, that part moves to SSE behind the same subscribe interface."

How deep to go by level

LevelWhat a good answer shows
JuniorKnows polling, long polling, SSE, and WebSockets exist and what each does; picks polling for rare updates and says why; uses setTimeout, not setInterval.
MidDoes the requests-per-second math, pauses hidden tabs, handles errors with backoff, uses ETag and 304, stops at a final state, shows "last updated" when stale.
SeniorAdaptive intervals, jitter, server hints and Retry-After, CDN caching for shared data, the job-status pattern with idempotent start, a clean switch path to SSE.
StaffTreats the interval as an operational dial for incidents, plans the fleet's behavior after an outage, shares one poller across tabs, and mixes techniques per data type with a single client data layer.

Traps to avoid

  • Dismissing polling as naive. Jumping to WebSockets for an order status suggests you don't weigh cost. Say what you gain and what you pay.
  • Forgetting the math. "We'll poll every second" with no load estimate invites the question "for how many users?".
  • Polling for everything a stream should carry. Chat over 1-second polling is a red flag: the delay is still noticeable and the load is huge.
  • No plan for failure. An interviewer will ask "what if the API is down?". Have backoff with jitter, stale-data messaging, and Retry-After ready.

Think first

The interviewer pushes back: "Polling is wasteful, why not WebSockets?" How do you answer for a notifications badge that changes a few times a day per user?

Summary

  • Short polling asks the server "anything new?" on a timer. It is plain, stateless HTTP: any endpoint can be polled, and proxies, CDNs, and deploys just work.
  • The interval sets both costs: the average delay is half the interval, and the load is open pages divided by the interval (20,000 pages every 10 s is 2,000 requests per second).
  • Schedule each poll after the last answer with a setTimeout chain, with a time limit per request and a clean stop. setInterval overlaps requests on slow networks.
  • Adapt the interval: start short, slow down while nothing changes, reset on a change or user activity, stop at a final state. Back off harder on errors.
  • Pause in hidden tabs and poll at once on return; browser throttling of hidden timers is uneven and is not a plan. Stop while offline, and share one poller across tabs.
  • Make empty answers cheap with ETag, If-None-Match, and 304, from a version the server can check cheaply. Let a CDN answer polls for data everyone shares.
  • Add jitter to every wait so clients don't fall into step, and let the server set the pace with a next-poll field and Retry-After.
  • Poll when updates are rare and a few seconds is fine, and for job status. Move to SSE or WebSockets when updates must arrive in under a second or flow both ways.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

30,000 users keep your page open and it polls every 5 seconds. How many requests per second is that, and how would you cut it without making updates slower when they matter?

Question 2

Why is a setTimeout chain better than setInterval for polling?

Question 3

How does ETag-based polling work, and what does it save?

Question 4

Design the frontend for a report export that takes 1 to 3 minutes.

Question 5

When would you choose short polling over WebSockets, and what would make you switch?

Practice it

These design problems lean on short polling.

References

Read next