HotShard
Traffic control

Rate Limiting

How an API decides, request by request, who gets served and who gets a 429, and how to keep that decision correct across a fleet of servers.

Any service with finite capacity needs a rule for what happens when traffic goes past it. Rate limiting is that rule: each client may make at most N requests in some span of time, and the server checks it cheaply on every request. The contract is simple. The bookkeeping is where the interview questions live, and so is the part that breaks in production: enforcing one limit across many servers.

~6 min read

Start here: why count requests at all?#

TL;DRthe 30-second version
  • A rate limiter decides per request, accept or reject. It caps each client (by API key, IP, or user id) to a budget so one bad actor can't starve everyone. Over-budget requests get a fast HTTP 429.
  • Five classic algorithms trade accuracy against cost. Token bucket and leaky bucket are O(1) per client. Fixed window is one counter but bursts at boundaries. Sliding-window log is exact but O(limit) memory. Sliding-window counter is approximate and O(1), and it's what Cloudflare ships.
  • Token and leaky bucket shape when a budget is spent (all at once, or smoothed). The window algorithms measure how much was spent over a span. Interviewers like it when you separate those two axes.
  • The hard part is many servers. Per-node limits don't add up to a global limit, so production limiters keep counters in a shared store (usually Redis) and choose between exact-but-central and approximate-but-local.

A server has finite CPU, memory, and downstream capacity. Without a limit, one misbehaving client can starve everyone else: a retry loop, a scraper, a bug. Retry storms make it worse, because a downstream blip makes every client retry at once, and those retries pile onto the system that's already struggling. Rate limiting caps each client to a budget and rejects the rest, so the fleet stays healthy for everyone else.

Limiting vs. queuing vs. load-sheddingRate limiting rejects one client's requests over its budget, with a fast, cheap 429. Queuing buffers the extra work for later, which is fine until the queue is unbounded and latency climbs until the system falls over. Load-shedding is the whole-system version: when the server itself is overloaded, drop a fraction of all traffic, often by priority, regardless of who sent it. They're complementary tools.

Token bucket first, then the window counters#

A token bucket holds up to `capacity` tokens. Tokens refill continuously at `refillRate` tokens per second. Every request costs one token: if the bucket has one, the request is allowed and a token is removed; otherwise it's denied. Because refill is continuous, a client that's been idle banks tokens up to the cap and can spend them all in one burst. That burst tolerance is the defining trait of a token bucket.

  1. On each request, compute the time since the last refill and add elapsed Γ— refillRate tokens, capped at capacity.
  2. If tokens β‰₯ 1, subtract 1 and allow.
  3. Otherwise deny, and tell the client roughly how long until the next token (1/refillRate seconds).

capacity = 5 tokens Β· refill = 1 token / second Β· β˜… = a token, Β· = empty slot

t=0sβ˜…β˜…β˜…β˜…β˜…full β€” an idle client banked 5; a burst of 5 is allowed
t=0sΒ·Β·Β·Β·Β·empty β€” the 6th request is denied β†’ 429
t=1sβ˜…Β·Β·Β·Β·+1 token refilled
t=2sβ˜…β˜…Β·Β·Β·now limited to ~1 req/sec
t=5sβ˜…β˜…β˜…β˜…β˜…back to full if it stayed idle
Token bucket: idle time banks a burst, then it's a steady drip

There's no background timer adding tokens. Refill is computed lazily, from the time since the last touch. So the whole state for a client is two numbers: a token count and a timestamp. That O(1) footprint is why token bucket scales to millions of keys.

This is what most public APIs useStripe, GitHub, and AWS all expose token-bucket-shaped limits: a steady rate plus burst headroom. It's cheap, and it matches how real traffic behaves, bursty with idle gaps. The two parameters map to a published policy: refillRate is the sustained rate, capacity is the burst allowance.

A leaky bucket flips the control to the output side. Requests fill a fixed-size queue that drains at a constant `leakRate`. If the queue has room, the request is queued and allowed. If it's full, the request overflows and is denied. Where a token bucket lets an idle client burst, a leaky bucket forces every request through at the leak rate. That smoothing is what you want in front of a downstream system that can't handle spikes at all.

A fixed window counter is the simplest thing that works. Divide time into windows of `windowMs`, say one per minute. Keep one counter per window: increment on each request, reset to zero when the clock crosses into a new window, allow while the counter is under `limit`. One integer, one comparison. It has a real flaw.

PredictLimit is 100 requests per minute with a fixed window. A client fires 100 requests at 11:00:59 and 100 more at 11:01:00. Did the limiter do its job?

Hint: Count per fixed window vs. count over any trailing 60 seconds.

Each burst lands in a separate window, so the fixed-window limiter allows both. That's 200 requests in about one second, double the intended rate. This boundary burst is the whole reason sliding windows exist.

A sliding window log closes that gap exactly. Keep a timestamp for every request instead of a count. On each new request, drop every timestamp older than `now - windowMs`, then allow only if fewer than `limit` remain. The window is measured from now, not from a fixed grid, so there's no boundary to burst across. The cost is memory: O(limit) timestamps per client instead of one integer, plus the pruning work on every request.

A sliding window counter approximates the log with two integers: the current window's count and the previous window's count. It estimates the trailing-window count as `prevCount Γ— overlapWeight + currCount`, where `overlapWeight` is how much of the previous window still falls inside the trailing span.

limit = 100 / 60s Β· now = 15s into the current window

prev100 reqsthe full 60s windowΓ— overlapWeight 0.75
curr40 reqsthe first 15s so farcounted in full
Weighting the previous window by how much it still overlaps

This assumes requests were spread evenly across the previous window, so it's an approximation. In exchange it costs O(1) per client. Cloudflare measured it on 400 million requests from 270,000 sources: only about 0.003% were wrongly allowed or limited versus an exact log. That's why it's the one production limiters at scale actually ship.

The cost that decides everything: per-client state#

A rate limiter keeps state for every active key, and there can be millions of them: one per API key, per IP, per user, sometimes per route. So the question that picks the algorithm in practice is how many bytes, and how much work, per key per request.

AlgorithmState per keyWork per request
Token bucketO(1) β€” count + timestampO(1)
Leaky bucketO(1) β€” level + timestampO(1)
Fixed windowO(1) β€” one counter (+ window id)O(1)
Sliding window counterO(1) β€” two countersO(1)
Sliding window logO(limit) β€” a timestamp per request in windowO(limit) to prune

Only the sliding log grows with the limit itself. Picture 10 million keys, each limited to 1,000 requests a minute. The four O(1) algorithms need a couple of words per key, about 16 bytes, so around 160 MB in total, which fits in RAM. The sliding log can hold up to 1,000 timestamps per key: gigabytes, plus pruning on every request. That memory cliff is why the approximate counter exists and why exact limiting at scale is rare.

Don't forget expiryIdle keys must be reclaimed or memory grows without bound. In-memory limiters use an LRU or TTL sweep. Redis-based ones lean on EXPIRE, so a key for a client that stops sending traffic simply disappears. A limiter that never forgets keys is a memory leak with extra steps.
One limit across many servers, and the trade-offs it forces

Everything above assumed a single process holding the counter. Real APIs run behind a load balancer across dozens or hundreds of nodes, and any node might handle any client's request. Now it's hard, because per-node limits don't compose.

Why per-node limits don't composeSay the policy is 100 req/min and you have 10 nodes. Give each node a local limit of 10/min. A client whose traffic spreads evenly gets exactly 100. A client pinned to one node gets only 10. A burst the load balancer fans out across all 10 nodes can pass up to 100 at once, though no single node saw more than its share. Local counters can't see the global total, so the global limit is too strict, too loose, or both at different moments.
  • Shared store (central, exact): every node reads and updates one counter in a fast shared store, almost always Redis. Exact global enforcement, at the cost of a network round-trip per request and a dependency that must stay up.
  • Local + async reconciliation (distributed, approximate): each node keeps a local counter and periodically syncs totals. No per-request round-trip, but the global count lags, so the limit is approximate near the edge.
  • Sticky routing: hash each client to the same node at the load balancer, so its counter lives in one place and a plain in-memory limiter is correct for that client. Simple and fast, but it fights load balancing and breaks when nodes are added, removed, or fail.

In Redis, a fixed window is INCR on the window's key plus EXPIRE on the first increment. An exact sliding log is a sorted set per key: ZREMRANGEBYSCORE to prune, ZCARD to count, ZADD to record. A token bucket is a small Lua script that reads {tokens, lastRefill}, refills, and decrements atomically, so two nodes can't both spend the last token. GCRA stores one value instead, the Theoretical Arrival Time: the earliest instant the next request is allowed. Compare now to it, push it forward on success, and the gap to it is exactly the Retry-After. The redis-cell module ships GCRA as one atomic command, CL.THROTTLE.

Fail-open vs. fail-closedWhen the limiter store is down, every request is undecidable, and you must have chosen a policy in advance. Fail-open allows all traffic: the API stays up but is briefly unprotected. Fail-closed rejects all traffic: a limiter outage becomes a full outage. Most public APIs fail open with a short local fallback limit, so a Redis blip degrades to approximate per-node limiting instead of a hard 503. Security-critical limits (login, payments) often fail closed. Decide deliberately.
  • Accuracy vs. memory: the sliding log is exact but O(limit) per key; the sliding counter is approximate but O(1). At millions of keys, approximate almost always wins.
  • Local vs. central: a per-node in-memory limiter is fast and has no dependency but can't enforce a global limit; a shared Redis counter is globally exact but adds a round-trip and a hard dependency on every request.
  • Burst tolerance vs. smoothness: token bucket forgives idle clients a burst; leaky bucket and GCRA force a steady rate. Choose by what's downstream.
The pragmatic defaultFor most public APIs: token bucket (or GCRA) per API key, counters in Redis with EXPIRE, fail-open to a small local limit if Redis is unavailable, and return 429 + Retry-After + the RateLimit headers so well-behaved clients slow down before you have to reject them.
The five algorithms side by side
AlgorithmAccuracyMemory / keyBurst behaviorBest for
Token bucketGoodO(1)Allows bursts up to capacityPublic APIs, burst-friendly clients
Leaky bucketGoodO(1)Smooths to a steady output rateProtecting a spike-intolerant backend
Fixed windowPoor (boundary burst)O(1)Up to 2Γ— at boundariesSimple internal limits, coarse caps
Sliding window logExactO(limit)No boundary burstLow-volume, high-stakes limits
Sliding window counterNear-exact (~0.003% err)O(1)No meaningful boundary burstHigh-scale limiting (Cloudflare)
Where rate limiting runs in the wild
  • Stripe β€” token bucket as the core, with four layered strategies (request rate, concurrent requests, a fleet-usage load shedder, and a worker-utilization limiter) so one customer's surge can't take down a payments API.
  • GitHub β€” token-bucket-shaped REST limits (5,000 req/hour authenticated), advertised via X-RateLimit-Limit / -Remaining / -Reset headers, with Retry-After when exhausted.
  • Cloudflare β€” the approximate sliding-window counter, running at the edge across millions of domains, chosen for O(1) memory and ~0.003% error.
  • nginx limit_req β€” a leaky bucket: requests queue and drain at a fixed rate, with an optional burst parameter and nodelay mode.
  • AWS API Gateway β€” token bucket with a steady rate and a burst capacity per route, returning 429 on exceed. Envoy offers both a local token-bucket filter and a global rate-limit service backed by a shared store.
The response contract: 429, Retry-After, RateLimit headersOver budget returns HTTP 429 Too Many Requests, ideally with Retry-After so the client knows when to come back. The IETF RateLimit header fields draft standardizes proactive signaling: RateLimit (remaining quota and reset) and RateLimit-Policy (the limits in force), so clients can slow down before they're rejected. Signaling the limit is as much a part of the design as enforcing it.
What breaks at the edges
  • Thundering herd at window reset: with a fixed window, every client's budget refreshes at the same instant, so every throttled client retries together the moment the window flips. Token bucket and GCRA avoid this because each client refills on its own continuous schedule.
  • Clock skew across nodes: window and timestamp math depends on time. If two nodes disagree on 'now' by tens of milliseconds, a request can be double-counted or missed. Use the store's clock (Redis TIME) rather than each node's.
  • INCR then EXPIRE as two commands: if the process dies between them the key never expires and leaks. Do both atomically, in a MULTI transaction or a Lua script.
  • Several Redis commands per decision without a Lua script or MULTI: two nodes can interleave the read-modify-write and both spend the last token.
  • Giving 10 nodes 10 identical local counters of 100/min: round-robin lets one client through at roughly 1,000/min, and a window reset can nearly double that again.
In an interview

Lead with the trade-off, not the list. Every window algorithm trades accuracy against memory. Token bucket and leaky bucket are a different axis: they shape when a budget can be spent. Say which you'd pick and why, then expect the follow-up that separates juniors from seniors: 'now make it work across 50 servers.'

Token bucket or leaky bucket, which one allows bursts?

Token bucket. A client that's been idle banks tokens up to the cap and can spend them at once. A leaky bucket drains at a fixed rate no matter what, so it smooths bursts into a steady trickle. Use it in front of something that can't handle spikes.

Why reject over-budget requests instead of queuing them?

An unbounded queue hides the overload: latency climbs until the system falls over. A fast 429 fails cheaply and hands the decision back to the client, which backs off and retries later. Limiting and queuing are different tools.

Which algorithm would you pick?

Token bucket for a public API that should tolerate bursts. Leaky bucket in front of something that can't handle spikes. Sliding window counter when you need window-boundary correctness at scale. Sliding log only for low-volume, high-stakes limits like login attempts.

How do you rate-limit across many servers?

Per-node counters can't see the global total. Keep the counter in a shared store, usually Redis: INCR+EXPIRE for a fixed window, a sorted set for an exact sliding log, or GCRA via redis-cell, wrapped in Lua so the read-modify-write is atomic. High-scale systems often go approximate instead: local counters with periodic reconciliation, or sticky routing. Then say what happens when Redis is down: fail open to a local limit, or fail closed for login and payments.

What does the client get back?

HTTP 429 Too Many Requests, with a Retry-After header saying how long to wait, and increasingly the standardized RateLimit headers describing remaining quota so clients can slow down before being rejected.

References & further reading
References

Feedback on this topic β†’