skip to content

A public API has clients that poll an endpoint every minute looking for changes. How would you design that endpoint and its rate-limit policy so polls that find nothing new are cheap for both sides?

level: seniorimportance: should knowfreq 40%

answer

  1. cheap validator: change sequence, not a body hash
  2. short-circuit to 304 before building the response
  3. 304 costs no quota → aligns incentives
  4. advertise the poll interval; 429 + Retry-After
  5. change feed with ?since=cursor beats polling a list

basics

~20 s

Give the endpoint a stable, cheaply computed ETag, require clients to poll with If-None-Match, answer unchanged polls with 304, and do not charge those 304s against the rate limit. Publish the polling interval, and offer webhooks or a change-feed for clients that need lower latency.

solid answer

~50 s

Four moves. **1. Make an unchanged poll answerable without building the response.** Derive the validator from a cheap source — a per-account change sequence or `max(updated_at)+count` — and short-circuit to `304` before loading or serializing anything. **2. Make the validator stable.** Deterministic ordering, no `generatedAt` in the body, no request ids echoed back. Any volatile field means every poll is a full 200 and the design is worthless. **3. Reward good clients in the rate-limit policy.** The well-known model is to not count `304` responses against the quota, so a polite polling client with `If-None-Match` effectively polls for free, while an unconditional poller burns quota. Publish a recommended interval in a response header so clients back off when nothing is happening. **4. Give heavy clients something better.** Webhooks, a cursor-based change feed (`?since=<cursor>` returning only deltas), or server-sent events beat polling on both latency and cost. Polling with conditional GET is the fallback for clients that cannot receive callbacks.

code

http · 8 lines
http
GET /v1/notifications HTTP/1.1
Authorization: Bearer <token>
If-None-Match: "seq-91847"

HTTP/1.1 304 Not Modified
ETag: "seq-91847"
X-Poll-Interval: 60
X-RateLimit-Remaining: 4998

go deeper

for a junior

Say that clients should poll with If-None-Match so unchanged polls return 304 instead of the whole body.

for a middle

Add how to derive a cheap, stable validator and why volatile body fields defeat the whole scheme.

for a senior

Design the full policy: short-circuit before serialization, 304s free of quota, advertised poll interval, 429 with Retry-After, and a change-feed alternative.

for a principal

Reason about incentives and blast radius — quota policy as behaviour design, bounded 'free' requests as an abuse control, and choosing per-integration between webhooks, feeds and polling.

## The problem Polling is the default integration pattern because it needs no inbound connectivity from the client. Its cost profile is brutal: with N clients polling every 60 seconds, the origin pays N requests per minute forever, and the overwhelming majority discover nothing has changed. The design goal is to make the "nothing changed" answer as close to free as HTTP allows, and to make the cheap path the one clients are incentivised to take. ## Make the negative answer cheap on the origin A 304 saves bandwidth automatically. It only saves origin CPU and database work if the handler can decide "unchanged" without producing the representation. That requires a validator whose source is a single cheap read: - a per-account or per-tenant **change sequence** bumped by every mutation (a counter column, or the latest event id from an outbox); - or `max(updated_at)` plus a row count, which also catches deletions; - never a hash of the serialized payload, because computing it means you already did the expensive work. The handler flow becomes: authenticate → read the sequence → compare with `If-None-Match` → return 304, or else build the response. Now an unchanged poll is auth plus one indexed lookup. ## Make the validator stable A validator that churns turns every poll into a 200 and makes things worse than no validator at all, because you have added bytes and complexity. The usual culprits: a `generatedAt` or `serverTime` field in the body, a per-request trace id echoed into the payload, non-deterministic ordering from a set or a map, floating-point formatting differences between instances, and pagination cursors that encode a timestamp. Determinism of serialization is a hard requirement, and it deserves a test: fetch twice with no writes in between and assert the validator is identical, including across two different server instances. ## Align the rate-limit policy with the behaviour you want Quota is the lever that changes client behaviour. The widely copied model is: conditional requests that result in `304 Not Modified` do not consume quota. The economics then favour the well-behaved client — it can poll frequently without ever exhausting its limit — while a client that ignores validators pays for every fetch. Complementary signals: - a header advertising the recommended minimum polling interval, which the server can raise dynamically when a resource is quiet or the platform is under load; - standard quota headers so clients can self-throttle; - `429` with `Retry-After` for clients that ignore all of the above. One caution: making 304s free is an anti-abuse decision as well as an efficiency one. The request still costs TLS, routing and an authenticated database read, so "free" must be bounded — typically by a separate, much higher ceiling or by connection-level limits — or a client can poll in a tight loop at zero quota cost. ## Shape the resource for polling Polling a large collection to detect a small change is wasteful even with 304s, because the moment anything changes the client downloads everything. Better resource design for change detection: - a **change feed**: `GET /events?since=<cursor>` returning only what happened after the cursor, so the transferred volume is proportional to actual change, not to collection size; - a **lightweight summary resource** ("latest version" or counts only) that clients poll cheaply, fetching the full resource only when the summary moves; - stable, deterministic pagination so a changed page does not cascade into refetching every page. ## Offer an escape from polling For clients that can accept inbound calls, webhooks or server-sent events remove the poll entirely and cut change-detection latency from the polling interval to near zero. The realistic platform position is to support both: a change feed with conditional GET as the universal fallback, webhooks as the efficient path, and a documented, enforced polling etiquette in between. Publish the expected behaviour — send `If-None-Match`, respect the advertised interval, back off on 429 — as part of the API contract, because integration code written once will run unchanged for years.

  • If 304 responses cost no quota, what stops a client polling in a tight loop?
    Nothing in the quota rules, so you need a second boundary: a much higher absolute request ceiling, per-connection or per-IP limits at the edge, or an escalating enforced minimum interval. The point of free 304s is to reward conditional polling at a sane cadence, not to remove all limits, and the request still costs TLS, routing and an authenticated lookup.
  • When is a change feed a better answer than conditional GET on a collection?
    When the collection is large and changes are small. With conditional GET, the first change after a quiet period forces a full re-download of everything; with a cursor-based feed the client transfers only the delta. Feeds also let the client resume precisely from its cursor, whereas polling a list can miss intermediate states entirely.

saying these in an interview costs you the question

  • Computing the ETag by rendering the full response, so 304s save only bandwidth
  • Leaving a server-generated timestamp in the payload and expecting stable validators
  • Counting 304 responses against the rate limit and then complaining clients poll unconditionally
  • Answering polling load by raising rate limits instead of making the unchanged path cheap
  • Assuming webhooks make conditional GET unnecessary for every client

context