Polling a feed for new items

Ask the server “anything new?” on a timer — too often wastes calls, too rarely shows stale data.

The idea

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.

See it work

Press play, or step through it.

How it works

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();

Trade-offs

ApproachFreshnessCost
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

Watch out for

Worked example

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.

Check yourself

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?