open atlas
↑ Back to track
System Design Foundations SD · 08 · 01

Rate limiter

A rate limiter caps how fast a client may hit you. Token bucket and leaky bucket smooth bursts; fixed windows are simple but spike on the boundary; sliding windows fix that. At scale the counter lives in shared storage, and the read-modify-write race is the whole problem.

SD Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

A customer’s nightly script had a retry loop with no backoff. One bad deploy on their side and it hammered a checkout endpoint at 9,000 requests per second — for one account. The endpoint was CPU-heavy, the worker pool saturated, queueing delay went vertical, and every other customer’s checkout timed out. The fix wasn’t more servers; one tenant can always outpace any fixed fleet. The fix was a rate limiter: a small, cheap gate that says “you, specifically, get N per second” before the request ever reaches the expensive code. The interesting part is not the idea — it’s that the counter has to be correct across every server in the fleet at once, and that’s where it gets hard.

Why limit at all

A rate limiter protects a shared resource from being monopolised — accidentally (a runaway retry loop) or deliberately (abuse, credential stuffing, scraping). Without one, a single client’s spike becomes everyone’s outage: the expensive endpoint saturates, the queueing curve goes vertical, and well-behaved clients eat the latency caused by one misbehaving one. Stripe describes exactly this — a user’s “misbehaving script accidentally sending a lot of requests” — and treats the request rate limiter as the single most important one to build first.

A limiter is a gate, not capacity. If clients genuinely need that throughput and spacing requests out changes their result (real-time events), a limiter is the wrong tool — you need more capacity or a different protocol. The limiter’s job is to reject the load you can’t serve cheaply, with a clear signal, before it costs you the expensive work.

Four algorithms, two behaviours

The algorithms split into two families: those that smooth bursts and those that count over a window.

Token bucket. A bucket holds up to B tokens and refills at R tokens/second. Each request takes one token; if the bucket is empty, reject. The bucket lets a client save up unused allowance and spend it in a burst up to B, then settle to the sustained rate R. This is the algorithm Stripe uses in production (a centralised bucket host, tokens dripping in). It is the default for APIs because it matches how real traffic behaves: bursty, but bounded.

Leaky bucket. Requests enter a queue (the bucket) and drain at a fixed rate, like water through a hole. Overflow is dropped. It produces a perfectly smooth output rate — good for protecting a downstream that hates bursts — but it adds queueing latency and, unlike token bucket, does not allow bursts through. Token bucket allows bursts up to B; leaky bucket flattens them.

Fixed window. Count requests per fixed clock interval (e.g. per minute); reset the counter at the boundary. Trivial — one integer per client — but it has a sharp flaw covered below.

Sliding window. Two cheaper-than-perfect variants fix the fixed-window flaw. The sliding log keeps a timestamp per request and counts those within the trailing window — exact, but stores every request. The sliding window counter blends the current and previous fixed-window counts by how far you are into the window — approximate, but O(1) memory. Cloudflare popularised the counter approximation precisely because storing a log per client doesn’t scale to millions of keys.

Edge cases

The fixed-window boundary spike. A limit of “100 requests/minute” with fixed windows lets a client send 100 at 11:00:59 and another 100 at 11:01:00 — 200 requests in one second, straddling the boundary, while never technically breaking the per-minute rule. Each minute’s counter is innocent; the burst lives in the seam between them. That 2× spike is exactly the load you bought the limiter to prevent. Sliding window (log or counter) removes the seam by measuring a window that moves with now instead of snapping to the clock. If you only remember one reason fixed windows are dangerous, it’s this one.

Where to enforce it

The limiter belongs as far out and as early as possible — ideally at the edge (CDN/WAF) or the API gateway, before the request consumes a connection slot, a worker, or a database query on the protected service. Enforcing inside the service still protects the database but wastes the cheap resources (the TLS handshake, the worker thread) on requests you’re going to reject anyway. The general rule: reject as close to the client as you can, so the rejection is as cheap as possible.

But the outermost layer often doesn’t have the context to limit by the right key (user ID, API key, tenant), so real systems layer limiters: a coarse per-IP limit at the edge to absorb floods, then a precise per-API-key limit at the gateway, then sometimes a concurrency limiter at the service for expensive endpoints. Stripe runs four kinds in concert for exactly this reason.

The distributed counter and the race

One server with one in-memory counter is easy. A fleet of fifty servers behind a load balancer is the real problem: each request for one API key can land on any server, so the count must be shared, or fifty servers each enforce “100/min” and the client actually gets 5,000/min. The standard answer is a shared store — usually Redis — holding one counter per key.

Now the race. The naïve sequence is read-modify-write:

n = GET key          # server A reads 99
n = GET key          # server B also reads 99 (concurrent)
if n < 100:          # both see 99 < 100
    SET key n+1      # both write 100 — one increment is LOST
    allow

Two concurrent requests both read 99, both decide they’re under the limit, both allow — the client got 101. Under high concurrency this leaks badly. The fix is atomicity: make the increment-and-check a single indivisible operation. INCR in Redis is atomic, so the common pattern is INCR then compare, or a small Lua script that does the whole token-bucket refill-and-decrement server-side in one round trip (Redis executes a script atomically). The race is the entire reason distributed rate limiting is harder than it looks — the algorithm is trivial; doing it correctly without a lost update is not.

Common mistake

What happens when the rate-limit store goes down? If your limiter does INCR against Redis on the critical path and Redis is unreachable, a naïve implementation either errors every request (you took yourself down to enforce a limit) or hangs on the timeout (worse). Stripe’s rule: fail open. Wrap the limiter so that if the backing store errors or times out, the request is allowed, not blocked — a limiter is a safety belt, not a load-bearing wall, and it should never be the reason your API is down. Pair that with a kill switch (a feature flag to disable a limiter that’s misfiring) and a short timeout so a slow Redis can’t add latency to every call.

Pick the best fit

A public REST API serves mobile clients that send bursty traffic — a user opens the app and fires 12 requests in the first second, then sends 1–2 per minute. The API must allow this burst but enforce a sustained cap of 60 req/min. Which algorithm fits best?

Telling the client: 429 and Retry-After

When you reject, say so precisely. The standard is HTTP 429 Too Many Requests, with a Retry-After header telling the client how many seconds to wait before trying again (some APIs also send X-RateLimit-Remaining / -Reset so a well-behaved client can self-throttle before hitting the wall). This matters because the alternative — a bare error with no guidance — pushes clients into tight retry loops that add load exactly when you’re already overloaded, turning a brownout into an outage. A precise 429 with Retry-After lets a good client back off cleanly; combined with client-side exponential backoff and jitter, it’s what keeps a limited system stable instead of thrashing.

Quiz

A limit of 100 requests/minute uses a fixed window that resets on the clock minute. What is the worst-case burst a client can legitimately send, and over what span?

Quiz

Across a 50-server fleet, each server reads the shared counter, checks it's below the limit, then writes the incremented value. Under concurrency the client exceeds its limit. What is the fix?

Complete the analogy

A _______ holds up to B tokens and refills at R per second; each request spends one token and is rejected if the bucket is empty — which is why a client can burst up to B after a quiet period but only sustains R over time.

Recall before you leave
  1. 01
    Contrast token bucket, leaky bucket, fixed window, and sliding window.
  2. 02
    Why is distributed rate limiting hard, and what's the fix?
  3. 03
    How should a limiter behave when its backing store is down, and how should it reject?
Recap

A rate limiter is a cheap gate that caps each client’s request rate before the request reaches expensive work, protecting a shared resource from one tenant’s spike — accidental or hostile. Four algorithms, two behaviours: token bucket (capacity B = max burst, refill R = sustained rate; the production default) and leaky bucket smooth bursts; fixed window is trivial but allows a 2× boundary spike (100 just before the reset + 100 just after), which the sliding window (log or counter) closes. Enforce it as far out and as early as possible — coarse per-IP at the edge, precise per-key at the gateway, concurrency at the service — so a rejection is cheap. At scale the counter must be shared (one Redis counter per key), and the naïve read-modify-write races into lost updates, so increment-and-check must be atomic (INCR or an atomic Lua script). Finally, fail open when the store is down, and reject with 429 + Retry-After so good clients back off instead of thrashing. Now when you see a service behaving as if a single client is causing everyone’s slowdown — check for a missing rate limiter first, then check whether the counter is actually shared across the fleet.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.