📒 problems 📖 glossary
Mock interview
46:30
ready
of 46:30
Lesson 44 · System design

Design a rate limiter

📖 Walk me through it — plain English

A rate limiter is a gatekeeper that caps how often someone can hit your service — say "at most 5 requests per second per user." If they go over, you reject the extra requests (usually with HTTP status 429 "Too Many Requests"). Why bother? To stop one noisy or malicious user from hogging the server, to fend off abuse, and to keep costs predictable. It's a "warm-up" system design question because almost every real API has one.

The lesson lists four ways to do it, but zooms in on the one used in industry: the token bucket. Picture an arcade where each game costs one token. You have a small bucket that holds at most B tokens (the "capacity" — this is also your allowed burst size). A machine drips fresh tokens into the bucket at a steady rate of R tokens per second, but the bucket never overflows past B. Every time you want to play (make a request) you must spend one token. If the bucket has a token, you play and the count drops by one. If it's empty, you're turned away (429) until the drip refills it. This neatly allows short bursts (you can spend a full bucket at once) while still limiting your long-run average to R per second.

The clever trick in the code: instead of running a background timer that adds tokens every tick, we compute refills lazily. Each time a request arrives we look at how much time passed since we last touched the bucket, and add that many tokens on the spot: elapsed seconds × R, capped at B. That's the line tokens = min(stored + (now − last) × RATE, CAPACITY). No background job needed.

Let's trace a tiny example. Say capacity B = 3 tokens and refill rate R = 1 token per second. We'll watch the bucket as requests come in.

Start (t=0s) · brand-new user, so the bucket starts full at 3 tokens.
Request 1 (t=0s) · bucket has 3 (≥ 1), so allow it and spend one token. 2 left.
Requests 2 & 3 (still t=0s) · a quick burst. Each spends a token. Now empty — this is the allowed burst of B=3.
Request 4 (still t=0s) · bucket is empty (tokens < 1). Rejected with 429. Nothing changes.
Request 5 (t=2s) · 2 seconds passed × R=1 = 2 tokens refilled (min with cap, still 2). Allow it, spend one. 1 left.

Why it works: the steady drip of R tokens per second is a hard ceiling on the long-run average rate, while the bucket size B lets occasional bursts through — exactly the behavior real APIs want. Each check is O(1) time and stores only two numbers per user (tokens and last_refill_ts, the timestamp of the last update), so it's cheap even for millions of users.

One last gotcha the lesson flags: read-modify-write. The code reads the token count, subtracts one, then writes it back. If two of the user's requests run that sequence at the exact same instant on different servers, both might read "1 token left" and both think they win — a race condition (two operations stepping on each other). The fix is to make the whole read-modify-write happen atomically (all-or-nothing, with no one else interleaving), which is why the notes say to wrap it in a Redis Lua script — Redis runs that script start-to-finish without interruption.

Limit requests per user/IP/route. Common warm-up design question and a foundation of every production API.

What "rate limiting" actually means

Rate limiting is the practice of restricting how many actions a single client may perform in a given time window. The "client" is whatever identity you decide to count by — a logged-in user, an API key, a source IP address, or a specific endpoint. The "action" is usually an HTTP request, but it could be anything: messages sent, emails dispatched, dollars spent. A rate limit is normally written as N per window, e.g. "100 requests per minute per API key."

A closely related word is throttling. People use the two loosely, but the useful distinction is: rate limiting typically means reject the excess (return an error and let the client retry later), while throttling often means slow down the excess (delay or queue it so it trickles through at the allowed pace). A rate limiter that drops over-limit traffic and a throttle that buffers it are two ends of the same idea — the leaky bucket below is the buffering kind.

When you reject, the standard signal is HTTP 429 "Too Many Requests". It tells the caller "you are healthy, the server is fine, but you have exceeded your share — back off." Polite servers add a Retry-After header saying how many seconds to wait, so a well-behaved client can pause exactly long enough instead of hammering blindly.

Why every interview opens here. A rate limiter touches the four themes interviewers love: a per-client counter (state), a time window (correctness under concurrency), a shared store across servers (distributed systems), and a clear failure response (API design). It is small enough to finish in 35 minutes yet deep enough to separate candidates. Nail the token bucket and you have a reusable template for the harder follow-ups.

Step 1 — clarify
  • Limit unit: requests / user / minute? Per IP? Per endpoint?
  • What happens on over-limit: drop (429) or queue?
  • Distributed or single-server? Strict (no over-shoot ever) or eventually consistent?

These three questions decide the whole design, so spell out why each matters. The limit unit (also called the limit key) is the identity you count against — per user is fair but needs auth; per IP works for anonymous traffic but lumps everyone behind a shared NAT or office gateway together; per endpoint lets you protect an expensive route harder than a cheap one. Drop vs queue is the rate-limiting-vs-throttling choice from above: dropping (429) is simplest and what most public APIs do; queuing smooths spikes but adds latency and memory. Strict vs eventually consistent asks whether occasionally letting one extra request through is acceptable — for an abuse guard, yes; for "you may withdraw money only once," no.

The four algorithms, in depth

Below is the same shortlist the interview answer uses, but with each algorithm explained from scratch: how it works, where it shines, and where it breaks. The collapsible summary further down keeps the original one-liners; this section is the long form.

1 · Fixed window

Chop time into fixed buckets — say every clock minute, 12:00:00–12:00:59, then 12:01:00–12:01:59. Keep one integer counter per client per bucket. On each request, increment the counter; if it exceeds the limit N, reject with 429. When the clock ticks into the next minute, the counter resets to zero.

Pros: trivially simple, one integer of memory per client, O(1) per request. Cons: the boundary burst. Because the counter snaps back to zero at the edge, a user can fire N requests in the last second of one window and N more in the first second of the next — 2N requests in a ~2-second span, double the intended rate. For loose limits that is fine; for tight ones it is a real leak.

2 · Sliding window log

Store the timestamp of every request in a list (the "log") per client. On each new request, drop all timestamps older than the window (e.g. older than 60 seconds ago), then count what remains; if that count is below N, accept and append the new timestamp, otherwise reject.

Pros: perfectly accurate — the window truly slides, so there is no boundary burst at all. Cons: memory grows with traffic. A client doing 1,000 req/min costs 1,000 stored timestamps, every minute, for every client. At scale that is expensive in both storage and the per-request work of pruning the log.

3 · Sliding window counter

A clever compromise that keeps fixed-window's tiny memory but smooths its boundary. Keep just two counters: the current window's count and the previous window's count. Then estimate the rolling count as a weighted blend — weight the previous window by how much of it still overlaps the rolling window. If you are 25% of the way into the current minute, the formula is roughly current + previous × 0.75. Compare that estimate to N.

Pros: O(1) memory (two numbers), no hard boundary burst, and the estimate is accurate enough for almost all real traffic. Cons: it is an approximation that assumes requests were spread evenly across the previous window; a pathological spike can be slightly mis-estimated. In practice this is the algorithm many production limiters (and CDNs) actually ship.

4 · Token bucket (the one to know)

Covered in full in the plain-English walkthrough above. To restate it precisely: a bucket holds at most B tokens (capacity = allowed burst). Tokens are added at R per second, capped at B. Each request consumes one token; if none are available, reject. Refills are computed lazily — on each request you add (now − last_refill_ts) × R tokens and cap at B, so no background timer is needed.

Pros: O(1) time and just two numbers of memory per client; naturally allows controlled bursts up to B while pinning the long-run average to R; the parameters R and B map cleanly onto product requirements. Cons: needs a careful atomic update (the read-modify-write race), and choosing B trades burst-friendliness against how much overshoot you tolerate.

Sibling worth a mention — leaky bucket. Imagine a bucket with a hole in the bottom that drains at a constant rate. Incoming requests pour in from the top into a queue; the bucket leaks them out at a steady R per second; if the bucket (queue) is full, new requests overflow and are dropped. This is the throttling flavour: instead of allowing bursts like token bucket, it smooths traffic into a perfectly even output stream — great when a downstream service can only handle a fixed steady rate. Token bucket favours bursts; leaky bucket favours a flat output. They are mirror images of the same picture.

Algorithms — 4 options
Fixed window: counter resets every minute. Simple but allows 2× burst at window boundary.
Sliding window log: store timestamp of every request. Accurate but memory-heavy.
Sliding window counter: weighted blend of current + previous window. Good accuracy, O(1) memory.
Token bucket: bucket fills at rate R, capped at burst B. Each request consumes a token. Allows bursts up to B. Industry standard.
Token bucket visual
refill rate R/sec ↓ capacity B request → takes 1 token no tokens? → 429

A fully traced token-bucket run

The visual above shows the idea; here is the arithmetic spelled out request by request so you can see the lazy refill formula fire. Same parameters as the walkthrough — capacity B = 3, rate R = 1 token/sec — but now tracking the exact stored values tokens and last (the last-refill timestamp). The core line each time is tokens = min(tokens + (now − last) × R, B).

# State: tokens (float), last (timestamp of last update). Start full.
# B = 3, R = 1 token/sec

# t=0.0  new user            tokens=3.0  last=0.0
# req @ t=0.0  refill: min(3.0 + (0.0-0.0)*1, 3) = 3.0  >=1 -> ALLOW, spend 1 -> tokens=2.0
# req @ t=0.0  refill: min(2.0 + 0*1, 3)        = 2.0  >=1 -> ALLOW, spend 1 -> tokens=1.0
# req @ t=0.0  refill: min(1.0 + 0*1, 3)        = 1.0  >=1 -> ALLOW, spend 1 -> tokens=0.0 (burst of B=3 used)
# req @ t=0.0  refill: min(0.0 + 0*1, 3)        = 0.0  <1  -> REJECT 429, state unchanged
# req @ t=2.0  refill: min(0.0 + (2.0-0.0)*1, 3)= 2.0  >=1 -> ALLOW, spend 1 -> tokens=1.0, last=2.0
# req @ t=10.0 refill: min(1.0 + (10.0-2.0)*1,3)= 3.0  >=1 -> ALLOW, spend 1 -> tokens=2.0  (capped at B, the long gap did NOT bank 9 tokens)

Two things to notice. First, the min(..., B) cap is what stops a client who went quiet for an hour from accumulating thousands of tokens and then unleashing them all at once — the bucket can never hold more than B. Second, every step is pure arithmetic on two stored numbers; there is no list to scan and no background process. That is why token bucket is O(1) in both time and memory per client.

Implementation sketch
# Per user/IP, stored in Redis as a hash
key = f"rl:{user_id}"
# Redis fields: tokens (float), last_refill_ts (float)
def allow(user_id, now):
    tokens, last = redis.hmget(key, "tokens", "ts")
    tokens = (tokens or CAPACITY) + (now - last) * RATE
    tokens = min(tokens, CAPACITY)
    if tokens >= 1:
        tokens -= 1
        redis.hmset(key, {"tokens": tokens, "ts": now})
        return True
    return False

Wrap in a Redis Lua script for atomicity (read-modify-write race-free).

Where to enforce it: gateway vs service

Independent of the algorithm, you must decide where in the request path the check runs. The two common homes:

At the API gateway / edge

A shared front door (load balancer, API gateway, CDN, or reverse proxy) checks the limit before traffic ever reaches your services. Upside: one place to configure, rejects abuse early so it never costs backend CPU, and protects every downstream service uniformly. Downside: the gateway only knows coarse identity (IP, API key, route) — it usually cannot apply business-specific limits like "5 password resets per account per day."

Inside the service

The service itself enforces the limit, using its own knowledge of the user and the action. Upside: rich, business-aware limits and fine control. Downside: the request has already traveled through your stack before being rejected, every service must re-implement the logic, and you risk inconsistency across teams.

The usual real-world answer is both: a coarse global limit at the gateway to absorb floods cheaply, plus targeted limits inside services for sensitive actions. In the interview, name the gateway as the default and mention service-level limits for business rules.

Distributed rate limiting

A single server can keep its counters in local memory. But real systems run many app servers behind a load balancer, and a user's requests may land on any of them. If each server kept its own private counter, a "100/min" limit across 10 servers would effectively become 1,000/min — the limit leaks by the fan-out factor. The fix is distributed rate limiting: every server reads and writes the same counter in a shared store, so they all see one consistent view.

That shared store is almost always Redis — an in-memory key-value database fast enough to sit in the hot path (sub-millisecond reads/writes) and equipped with two features rate limiters need: per-key TTL (time-to-live, so stale counters expire automatically) and atomic scripting via Lua. Each app server, on every request, calls Redis to do the token-bucket check against the shared key.

The race condition, concretely. A race condition is when the result depends on the unpredictable interleaving of concurrent operations. Here, two of the user's requests hit two different app servers at the same instant. Both servers run read-modify-write: both read "1 token left," both compute "1 ≥ 1, so allow and set to 0," both write back 0. Two requests were allowed when only one should have been — the bucket is overdrawn. The cure is atomicity: the read, the check, the decrement, and the write must happen as one indivisible unit. Redis runs a Lua script single-threaded, start to finish, with no other command interleaved — so wrapping the whole token-bucket update in one Lua script makes it race-free.

Distributed concerns
  • Single Redis = SPOF. Use clustering + replicas, accept eventual consistency.
  • For ultra-strict counts, use a single sharded counter per user (latency cost).
  • Local + sync: each app server has a local approximation, syncs to Redis periodically. Trade strictness for latency.

Unpacking those: SPOF means single point of failure — if your one Redis dies, the limiter (and possibly the whole API) goes down, so you replicate it; but replicas lag slightly, which is the eventual consistency trade-off (counts may be a hair stale). The single sharded counter approach pins each user's counter to one specific Redis node so all their requests serialize through one place (strict, but adds a network hop). The local + sync approach lets each server count locally for speed and reconcile with Redis periodically — fastest, least strict. The recurring theme: you trade strictness against latency and availability, and for an abuse guard, slightly loose is usually the right call.

Pitfalls to name

  • Clock skew. The lazy refill relies on timestamps. If different servers' clocks disagree, (now − last) can go negative or balloon, granting or denying tokens wrongly. Fix: compute time using the shared store's clock (e.g. Redis's TIME) rather than each app server's local clock.
  • Burst at window edges. The fixed-window boundary burst — 2N requests across two adjacent windows. If a strict cap matters, prefer sliding-window-counter or token bucket, which do not snap to zero at a boundary.
  • Distributed consistency. Replicated counters lag, so a hard "never exceed N" guarantee is hard to keep under failover. Decide up front whether your limit is a safety guard (loose is fine) or a correctness invariant (needs strict serialization).
  • Fail-open vs fail-closed. If Redis is unreachable, do you allow all requests (fail-open: stay available, lose protection) or block them (fail-closed: stay protected, risk an outage)? Most public APIs fail-open for resilience; pick deliberately and say so.
  • Choosing the limit key. Per-IP buckets punish whole offices/NATs sharing one address; per-user needs auth and can be gamed by creating accounts. State your key and its trade-off.

Go deeper (optional): the GCRA (Generic Cell Rate Algorithm) used by Redis's redis-cell module is a tidy single-value reformulation of the token/leaky bucket; Stripe and Cloudflare have published readable engineering posts on their production rate limiters if you want to see these trade-offs at scale.

Takeaway: a rate limiter caps actions-per-client-per-window and rejects the excess with 429. Know the four algorithms — fixed window (simple, boundary burst), sliding window log (exact, memory-heavy), sliding window counter (O(1), accurate enough), token bucket (industry default: rate R, burst B, lazy refill, O(1)). For many servers, share the counter in Redis and make the read-modify-write atomic with a Lua script to dodge the race. Enforce coarse limits at the gateway and business limits in the service, and be ready to name the pitfalls: clock skew, edge bursts, distributed consistency, and fail-open vs fail-closed.

→ Going deeper: Rate limiters bucket time into windows — the algorithmic cousin. See Intervals — merge, insert, schedule.
→ Going deeper: Token buckets and sliding windows are cache-like counters. See Caching.