Ask the server “anything new?” on a timer — too often wastes calls, too rarely shows stale data.
Imagine refreshing your inbox by hand. You could spam the refresh button every second — you'd always see new mail instantly, but most refreshes show nothing new. Or you could check once an hour — cheap, but you'd miss things for a long time.
Polling is exactly that, automated. The client asks the server “any items newer than what I've seen?” on a fixed interval, carrying a since cursor so the server only returns the new stuff. The whole game is the trade-off between freshness and wasted calls.
The client loops on a timer. Each poll sends the since cursor (the id of the last item it saw) plus an If-None-Match header carrying the last ETag. If nothing changed, the server replies 304 Not Modified with an empty body — a cheap empty poll. When there is new data, it returns the items and a fresh ETag, and the client advances its cursor. A good client also backs the interval off while the feed is quiet and adds a little jitter so a thousand clients don't all fire on the same second.
let since = 0; // id of the newest item we've seen
let etag = null; // last ETag the server gave us
let interval = 2000; // start at 2s
const MAX_INTERVAL = 30000;
async function poll() {
const headers = {};
if (etag) headers['If-None-Match'] = etag;
const res = await fetch(`/feed?since=${since}`, { headers });
if (res.status === 304) {
// empty poll — nothing new. Back off so quiet feeds cost less.
interval = Math.min(interval * 1.5, MAX_INTERVAL);
} else if (res.ok) {
etag = res.headers.get('ETag');
const items = await res.json(); // only items newer than `since`
if (items.length) {
render(items);
since = items[items.length - 1].id; // advance the cursor
interval = 2000; // fresh data — poll eagerly again
}
}
// honour Retry-After if the server is asking us to slow down
const retry = Number(res.headers.get('Retry-After')) * 1000;
const wait = Math.max(interval, retry || 0);
// jitter: spread clients off the same wall-clock second
setTimeout(poll, wait + Math.random() * 400);
}
poll();
| Approach | Freshness | Cost |
|---|---|---|
| Short-interval polling | Fresh — seconds behind | High — most replies are “0 new” |
| Long-interval polling | Stale — items wait a whole interval | Low — few requests |
| Conditional polling (ETag / 304) | Same as the interval allows | Lower — empty polls are tiny 304s |
| Long-polling | Fresh — returns the instant data arrives | Low waste, but holds a connection open |
| SSE / WebSockets (push) | Instant — server pushes | Cheapest per update, but stateful & persistent |
since id means you refetch the entire feed every time, then diff it client-side — huge payloads for one new row.Retry-After.Say 1,000 clients each poll every 2s. That's 1000 / 2 = 500 requests per second, around the clock. During a quiet hour roughly 90% of those return “0 new” — about 450 wasted req/s doing real work for nothing.
Add ETag + 304 and those empty polls shrink to tiny header-only responses: the request count is the same but the bytes and serialization cost collapse. Now back the idle interval off to 10s and the quiet-period rate drops to 1000 / 10 = 100 req/s. Switch to long-polling and each client holds one open request that returns the instant an item appears — near-zero waste and near-zero staleness, at the cost of 1,000 connections parked on the server.
Most of your poll responses come back “0 new items.” You want to cut server cost without making the feed feel noticeably stale. What's the best first move?