Back to Blog
System DesignInterview TipsDistributed SystemsFAANG

Design a Rate Limiter — System Design Interview Walkthrough

Amit Singh

Amit Singh

Author

June 25, 2026
18 min read

A rate limiter interview hinges on four answers: which algorithm you pick, how you keep the count correct across many servers, how you scale past a single Redis, and how it behaves when things break. Get those right and you have shown the depth they are probing. This applies the standard system design framework to a deceptively small problem — with the diagrams I draw on the whiteboard and a live token bucket you can poke at.

A rate limiter caps how many requests a client can make in a window (e.g., 100 requests/minute per user). It protects services from abuse, accidental overload, and runaway costs — and it is a favorite because it is small enough to finish yet rich enough to expose whether you understand distributed state. The naive single-server version is trivial; the interview is entirely about what happens when there are fifty gateways and a million requests a second.

1. Requirements

Functional: allow N requests per client per time window; reject excess with HTTP 429 Too Many Requests (and a Retry-After header); support different limits per scope (user, IP, API key, endpoint).

Non-functional, quantified:

  • Low latency — the limiter sits in front of every request, so its added cost must be small (target: a few milliseconds, ideally under ~10 ms at the 99th percentile).
  • High availability — if the limiter is down, the API should not go down with it.
  • Scale — assume a large API: on the order of 1 million requests/second at peak across all clients.
  • Accuracy — "approximately correct" is acceptable. Briefly allowing a few extra requests during a failover is fine; blocking legitimate traffic or crashing is not. This is an AP choice: we favor availability over perfect global consistency.

Clarify the key, and pick a failure mode

Two clarifying questions earn early points. First, the key: per user? per IP? per API key? per endpoint? It changes the whole design surface — and in production you often enforce several at once (more on that below). Second, the failure mode: if the limiter's store is down, do you fail-open (serve traffic, lose limiting) for availability, or fail-closed (reject) for protection? Say which and why — it is a product decision, not a default.

2. Capacity estimation (it decides the architecture)

Do the math, because it forces the hard part into the open. At 1M requests/second, every request needs a limiter check. A single Redis instance handles roughly 100,000–200,000 simple operations per second, and a token-bucket check is a few operations, so call it ~50,000–100,000 checks/second per Redis node.

1,000,000 checks/s ÷ ~75,000 checks/s per node ≈ ~13 Redis shards (plus replicas).

That single number is the whole reason the design cannot be "one Redis." It tells you immediately that you need to shard the keyspace and, ideally, to avoid a network hop on every single request. We will come back to both.

3. The algorithm (the crux)

AlgorithmHow it worksTrade-off
Fixed window counterCount requests per fixed clock window (e.g., per minute)Simple, but allows a 2x burst at the window boundary
Sliding window logStore a timestamp per request; count those within the last windowExact, but memory-heavy (one entry per request)
Sliding window counterWeighted blend of current + previous fixed windowSmooths the boundary burst; small memory; a great default
Token bucketTokens refill at a fixed rate; each request spends one; empty = rejectAllows controlled bursts; O(1) memory; the usual interview answer
Leaky bucketRequests queue and drain at a constant rateSmooths output, but adds queuing/latency

Lead with token bucket for most APIs: it is O(1) state per client (just tokens + lastRefillTime), naturally allows short bursts, and is the safest default in an interview. The token bucket is easiest to feel by using one — click fast to drain the bucket and watch the 429s; pause and watch it refill:

Token bucket — 10 burst, +1 / 1.5s

interactive
idle10/10 tokens · 0 allowed · 0 rejected

Click fast and the bucket drains — extra requests get a 429. Wait, and it refills at the steady rate. That is the controlled-burst behavior interviewers want to hear.

A small burst is absorbed (the bucket starts full), but the sustained rate can never exceed the refill rate. Reach for the sliding window counter when bursts must be forbidden. Its formula is worth knowing, because it is exact enough without storing every request:

estimated = current_window_count
          + previous_window_count × (overlap fraction of the previous window)

# Example: limit 7/min, 30s into the current minute, previous minute saw 5, current saw 3:
3 + 5 × 0.5 = 5.5  ≤ 7  → allow

Cloudflare measured this approximation at about 0.003% error over ~400 million requests — close enough that it runs their production edge limiter. Knowing why fixed-window is flawed (the boundary burst) and that the sliding-window counter fixes it cheaply is the detail that signals depth.

The memory math is what actually decides between the sliding variants

The two sliding approaches differ by an order of magnitude in memory, and that — not "smoothness" — is the real reason to pick the counter:

  • Sliding window log keeps a timestamp per request, naturally a Redis sorted set per client (ZADD on arrival, ZREMRANGEBYSCORE to drop entries older than the window, ZCARD to count what's left). Exact, but heavy: a 500-request window is ~500 entries per client, and a set that large drops Redis into its skiplist+hashtable encoding at ~100 bytes/entry (the raw timestamp is ~24 bytes; the structure is the rest) — so realistically tens of GB per million clients.
  • Sliding window counter keeps a handful of sub-window counters instead — e.g. 60 one-minute buckets for an hourly limit, summed per request. That's ~60 small entries, under Redis's compact-listpack threshold, so on the order of ~2 GB per million clients: roughly an order of magnitude less, for a tiny bounded error. (The plain 2-window blend from the table above is cheaper still — two counters per client regardless of traffic; the 60-bucket form is what you reach for when you want multi-granularity limits like 500/hour and 20/min at once.)

A token bucket is cheaper still — tokens + a timestamp, ~tens of bytes per client. The lesson is that the algorithm choice is partly a memory decision, and quantifying it is exactly the estimation that should drive an architecture rather than decorate it.

4. The API response

When a request is allowed, the limiter is invisible. When it is rejected, return a clear, machine-readable response so good clients back off instead of retrying blindly:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1719500000

Return the X-RateLimit-* headers on every response, not just 429s, so clients can self-throttle before they hit the wall — the convention GitHub and Stripe follow.

5. Where it lives

Run the limiter at the edge / API gateway or as middleware before your application logic — you want to reject excess traffic before it consumes real resources. Frame the placement as a progression:

  • In-process per server (bad at scale): each server limits independently, so with N servers a client effectively gets N× the limit. Fine for one box; wrong for a fleet.
  • Dedicated limiter service / shared gateway (good): all gateways consult one shared store, giving a single global limit.
  • Gateway + local fast-path (great): the gateway enforces, but keeps a local allowance to avoid a round trip on most requests (Section 7).
High-level architecture
ClientrequestAPI Gatewayrate-limit checkRedistoken bucketBackend200 OK429Too Many Requestsrequestrefill + consumeallowdeny
The gateway runs one atomic refill-and-consume against the Redis token bucket on every request: allow → forward to the backend; deny → 429 back to the client.

6. Single-node correctness: the atomic Redis answer

A single server can rate-limit in memory trivially. The interview's first real question is: with 50 app servers, how do you enforce one global limit per user?

The answer is a centralized store (Redis) holding each client's token state, updated with one atomic operation per request. A naive read-modify-write (read tokens, decide, write back) races under concurrency — two servers both read "1 token left" and both allow. The fix is to refill and consume in a single atomic step, a small Lua script that Redis runs without interleaving:

-- KEYS[1] = bucket key; ARGV = now, refill_rate, capacity, cost
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or tonumber(ARGV[3])   -- start full
local ts     = tonumber(state[2]) or tonumber(ARGV[1])

local refill = (tonumber(ARGV[1]) - ts) * tonumber(ARGV[2])
tokens = math.min(tonumber(ARGV[3]), tokens + refill)     -- refill, capped

if tokens >= tonumber(ARGV[4]) then
  tokens = tokens - tonumber(ARGV[4])
  redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', ARGV[1])
  redis.call('PEXPIRE', KEYS[1], 3600000)  -- TTL so idle buckets don't leak
  return 1            -- allowed
end
return 0              -- denied

One round trip, no race. Here is the full request path the gateway runs on every call:

One request, decided atomically
if allowedrequestrefill + consume (Lua)tokens left?allow → forward200 OKresponsedenied → 429 Too Many Requests (no backend call)ClientGatewayRedisBackend
One atomic Redis call decides each request: refill the bucket and try to consume a token; on allow, forward to the backend, otherwise return 429.

7. Scaling past one Redis (where the interview is won)

Section 2 told us one Redis tops out around ~75k checks/s, so at 1M req/s we need to scale the store and, ideally, stop hitting it on every request. Two moves:

Shard the keyspace. Partition clients across a Redis Cluster (16,384 hash slots) by a hash of the client key, so each client's bucket lives on exactly one shard and the load spreads evenly. Adding shards is consistent-hashing-style rebalancing; a client never needs two shards because its limit is independent of everyone else's. Give each shard a replica for failover.

Cut the per-request hop. Even sharded, a network round trip per request adds latency and load. The production answer is a local-first design: each gateway keeps a small local token allowance and reconciles with Redis periodically (e.g., lease 10 tokens at a time, sync every second). Most requests are decided in-memory with no Redis call; Redis becomes the coordinator, not the hot path. Pair it with sticky routing (hash the client to a gateway at the load balancer) so a given client mostly lands on one gateway, making its local count meaningful. The cost is a little accuracy — a client might briefly exceed the global limit during a sync window — which our requirements already said is acceptable. This is how edge limiters like Cloudflare's run: enforce locally, reconcile globally. (A simpler alternative many APIs ship first is a single sharded Redis with the atomic check above — Stripe's published limiter is centralized like that; reach for local allowances only when the per-request hop actually hurts.)

Distributed enforcement
Gateway fleetClientsLoad balancersticky by clientGateway 1local bucketGateway 2local bucketGateway 3local bucketRedis Clustersharded + replicasrequeststickylease + sync
At scale a client sticky-routes to one of N gateways, each holding a local token allowance that periodically leases from a sharded Redis Cluster. Most checks are local; Redis is the coordinator, not the per-request hot path.

8. Multi-tier limits and what you're really protecting against

Real limiters enforce several limits at once and reject if any is exceeded — for example per user (fairness), per IP (abuse), per API key (billing tier), and a global ceiling (capacity protection). Run each check and apply the most restrictive result.

It helps to name the two different jobs, because they pull the design in different directions:

  • Abuse / DDoS protection → key on IP (or coarser), lean toward fail-closed at the edge, and push enforcement as far out (L3/L4, CDN) as possible.
  • Fairness / cost control → key on user or API key, lean toward fail-open (a paying customer should not be blocked by a cache blip), and enforce at the application gateway.

Saying which problem you are solving — and that a serious system solves both with layered limits — is a strong senior signal.

The keying pitfalls worth naming. Each key has a sharp edge, and calling them out is rare-enough to stand out:

  • Per IP is blunt. A corporate NAT, a university, or a coffee shop hides thousands of users behind one address, so one bad actor throttles them all. And the reverse bites too: an attacker rotating through the vast IPv6 space balloons your key cardinality and can exhaust the store's memory — so cap tracked IPs or limit on a /64 prefix, not a full address.
  • Per user is precise but only exists after authentication — and the login endpoint itself is the trap. Limit failed logins per account too aggressively and an attacker can lock a victim out on purpose by burning their quota. There, limit on IP + account together and prefer slowdowns/CAPTCHAs over a hard lockout.
  • Hybrid (both at once) is what real systems run; the cost is more keys and more memory per request — which the multi-tier check above is already paying for.

9. Availability and durability

The limiter is on the critical path, so it must not be a single point of failure:

  • Redis replication + automatic failover (Redis Sentinel or Cluster) so a node loss does not take limiting offline. Losing a few token counts on failover is acceptable — we only need approximate limits.
  • Connection pooling from gateways to Redis, so you are not paying a TCP/TLS handshake (tens of milliseconds) per request.
  • Multi-region: run an independent limiter stack per region. Global limits across regions are expensive and rarely worth it; most designs accept per-region limits and say so.
  • Fail-open as the default when Redis is unreachable (with fail-closed reserved for sensitive endpoints), so a store outage degrades limiting rather than the whole API.

10. Failure modes interviewers probe

Race on the shared counter

Read-then-write on the token count lets concurrent requests over-spend. Always use one atomic operation — INCR/EXPIRE for plain counters, or the Lua script above for token buckets. (Covered in Section 6; the takeaway is never read, decide in app code, then write back.)

Hot key (one abusive client)

A single client hammering one key concentrates load on one Redis shard. The local-allowance fast-path (Section 7) absorbs most of it in-memory; for a truly abusive key, drop it at the edge (an IP block or a CDN rule) before it reaches the limiter at all.

Clock skew across servers

Per-server wall clocks drift. Prefer the store's time or token-bucket math (elapsed since lastRefillTime) over comparing timestamps set by different machines, so the limit does not wobble with skew.

Thundering reset (synchronized windows)

Fixed windows that all reset on the minute boundary create a traffic spike at :00. Token buckets and sliding windows smooth this naturally; if you must use fixed windows, add per-client jitter to the window start.

11. Follow-ups a senior interviewer asks next

  • Dynamic rule configuration: limits change without a redeploy. Store rules in a config service; gateways either poll every ~30s (simple, slightly stale) or get pushed updates via something like ZooKeeper/etcd (fast, more moving parts). Name the propagation-delay trade-off.
  • Concurrent-request limiting: rate is requests-per-window; some systems also cap in-flight requests (e.g., "at most 20 concurrent per user") — a different limiter (a semaphore) that Stripe runs alongside the rate limiter.
  • Hard vs soft vs elastic limits: the default is a hard limit (reject at the cap). Some systems allow a soft overage — a small burst credit above the cap — or an elastic one: let a client exceed its limit while the system has spare capacity and claw it back under load, which is really load-shedding wearing the limiter's clothes. A useful lever to name; don't build it unless the problem asks.
  • Load shedding & prioritization: under genuine overload, shed low-priority traffic first (background/test requests before user-critical writes). Rate limiting protects against one noisy client; load shedding protects the system as a whole.

12. Trade-offs and what to say at each level

  • Boundary burst (fixed window) → sliding window counter or token bucket.
  • Race conditions on shared state → one atomic Lua script, never read-then-write.
  • One Redis ceiling → shard by client key; local allowance + sticky routing to cut the hop.
  • Accuracy vs latency → approximate local limits buy speed; name it, don't pretend it's free.

Mid-level: token bucket + a centralized Redis with an atomic check + return 429 with Retry-After. Senior: the capacity math (throughput and the per-algorithm memory argument), sharding, multi-tier limits with the IP-vs-user keying pitfalls, fail-open vs fail-closed, and the hot-key and clock-skew failure modes. Staff+: the local-allowance/sticky-routing fast path, multi-region, dynamic rule propagation, load shedding (and elastic limits), and the explicit AP trade-off — driven without prompting.

Why it shows up so often

It is compact but it forces a real algorithm choice and a genuine distributed-systems problem: atomic shared state, then scaling that state past one node. Master the token-bucket-on-Redis answer and the sharded, local-first scaling story, and you have covered the version asked in the vast majority of interviews. For the broader method, see the framework; for a read-heavy counterpart, the URL shortener.


Built and battle-tested in production by engineers who have run limiters at scale — written by Amit Singh, Senior SDE at Amazon, Claude Certified Architect, and the instructor at AlgoEngineer. We drill distributed-systems questions like this with live mock interviews in our System Design course.

Design a rate limiter in a system design interview

The moves for walking through a distributed rate limiter, from algorithm to multi-region scale.

  1. 1

    Clarify the key, limits, and scale

    Pin what you are limiting (per user, per IP, per API key, per endpoint), the limit (e.g. 100 req/min), the target added latency, and whether the store may fail open or closed.

  2. 2

    Pick the algorithm

    Lead with token bucket; mention the sliding window counter as the burst-free alternative and explain why fixed-window is flawed (boundary burst).

  3. 3

    Place it at the edge

    Run the limiter at the API gateway or as middleware before application logic, so excess traffic is rejected before it consumes real resources.

  4. 4

    Share state atomically in Redis

    Keep per-client token state in Redis and refill-and-consume with one atomic Lua script, never a read-then-write that races.

  5. 5

    Scale past one Redis

    Estimate the ops/sec, shard the keyspace across a Redis Cluster by client key, and use local allowances or sticky routing to cut the per-request hop.

  6. 6

    Name the trade-offs

    Latency vs accuracy, multi-tier limits, HA and failover, and returning 429 with Retry-After and rate-limit headers.

Frequently asked questions

Which rate-limiting algorithm should I pick in an interview?
Lead with token bucket for most APIs. It keeps O(1) state per client (just a token count and a last-refill timestamp), naturally allows short controlled bursts, and is what most production limiters use. Offer the sliding window counter as the alternative when bursts must be forbidden, and be ready to explain why the fixed-window counter is flawed (it allows a 2x burst across the window boundary).
Why is the fixed-window counter flawed?
It counts requests per fixed clock window (say, per minute). A client can send the full quota in the last second of one window and the full quota again in the first second of the next, producing up to a 2x burst across the boundary. The sliding window counter fixes this by blending the current and previous windows; the token bucket fixes it by smoothing refills over time.
How do you keep the count correct across many servers?
Keep each client's counter or token state in a centralized store (Redis) so all gateway servers share one source of truth, and update it with a single atomic operation — INCR plus EXPIRE for simple counters, or a small Lua script that refills and consumes a token in one round trip. A naive read-modify-write races under concurrency and lets requests slip through.
How does a rate limiter scale past a single Redis instance?
Shard the keyspace by client key across a Redis Cluster (each client lives on one shard), since a single Redis handles only ~100k operations per second. To remove the per-request network hop at extreme scale, give each gateway a local token allowance that syncs with Redis periodically, and/or use sticky routing so one client always hits the same gateway. You trade a little global accuracy for a lot of latency and throughput.
Should the limiter fail open or fail closed if Redis is down?
Usually fail-open: serve traffic and temporarily lose limiting, rather than failing closed and turning a cache outage into a full outage. But say explicitly that it is a product decision — a payment or login endpoint may prefer fail-closed to stay protected. Pair it with Redis replication and automatic failover so the store rarely disappears in the first place.
Should you rate limit by IP or by user?
Both, for different jobs. Key on IP for abuse/DDoS protection (it works before authentication) but know it is blunt — a NAT or coffee shop hides many users behind one address, so one bad actor throttles them all, and an attacker rotating through the huge IPv6 space can balloon your key cardinality, so limit on a /64 prefix and cap tracked IPs. Key on user (or API key) for fairness and billing, but only after authentication — and watch the login endpoint, where limiting failed attempts per account too aggressively lets an attacker lock a victim out on purpose, so limit on IP plus account there and prefer slowdowns or CAPTCHAs over hard lockouts. Real systems run a hybrid and apply the most restrictive limit.
What should the API return when a request is rate-limited?
HTTP 429 Too Many Requests with a Retry-After header, plus the standard X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers so well-behaved clients can self-throttle instead of hammering you. Returning those headers on every response (not just 429s) is what production APIs like GitHub and Stripe do.

Ready to Ace Your Interviews?

Live, cohort-based interview prep taught by a working FAANG engineer — weekly mock interviews, lifetime access, and a 7-day money-back guarantee.