Course contents

Design a rate limiter

Design a rate limiter, built one requirement at a time: limiter middleware in the API gateway, a token bucket per key in a sharded Redis Cluster checked by one atomic Lua script, 429 with Retry-After, rules pushed from etcd by watch, fail-soft fallbacks, safe rule rollout with shadow mode, and per-region shares.

FreeMust know for interviewsMediumDesign problem28 min readBeginner → StaffReviewed Oct 2026
On this page
  1. 1. Understand the problem
  2. 2. The set up
  3. 3. High-level design
  4. 4. Deep dives
  5. 5. The final design
  6. 6. What interviewers look for by level
  7. 7. Follow-up questions
  8. 8. How to say it in the interview

🚦 What is a rate limiter?

A rate limiter decides, for every incoming request, whether this caller still has allowance left. If they do, the request goes through; if not, it's turned away with 429 Too Many Requests before it costs your servers anything. It acts like the turnstile at a metro station: it doesn't care who you are, only whether you have a valid tap left, and it says no instantly instead of letting the platform overflow.

For this problem, we'll design a distributed, server-side rate limiter in front of a public API, enforcing rules like "10 requests a second per API key, bursts of 20" across a fleet of 50 gateways. We'll leave network-layer DDoS protection and monthly billing quotas for later. Interviewers like this one because the single-machine version fits in ten lines, and then every real constraint (50 gateways, one shared count, a 10 ms budget, a Redis node that can die) turns a choice into a trade-off you have to defend.

The 30-second answer

"I'd put the limiter in the API gateway, so every request passes one choke point and a 429 costs the services nothing. Each request maps to a key: API key for partners, user ID for logged-in users, IP only for anonymous routes. Each key has a token bucket in a Redis Cluster sharded by key, and the gateway runs one Lua script per request that refills from elapsed time and takes a token atomically, so two gateways can't spend the last token twice. An empty bucket gets 429 with Retry-After. Rules live in etcd and reach every gateway by watch. If Redis is slow or down, most routes fall back to a local bucket so the API stays up, while login and OTP fail closed."

What you’ll learn

  • Build the design one requirement at a time, from the first shared count to rule pushes
  • Pick an algorithm from what it remembers: fixed window, sliding log, sliding counter, token bucket
  • Make check-and-take atomic with one Lua script, and size it: ~1M checks/s on ~20 Redis primaries
  • Choose a failure mode per rule: fail soft to a local bucket, fail closed for login
  • Roll out rules safely with watch, validation, shadow mode and kill switches, and split limits across regions

Interview radar

What they probe: where the check runs, which algorithm and why, how the count stays correct across many servers, how you stay inside the latency budget, and what happens when the counter store fails. Where candidates slip: local counters per app server, read-then-write without atomicity, a distributed lock per request, no answer for Redis going down, and a 429 that doesn't say when to retry.

011. Understand the problem

Functional requirements

Here's the scope I'd agree on in the first five minutes. The core requirements are:

  1. The system should identify each caller (API key, user ID or IP) and allow or reject each request against a shared count.
  2. The system should reject over-limit requests with 429 and tell the client when to retry.
  3. The system should support different limits per caller and per endpoint (login stricter than reads, paid plans looser than free).
  4. Operators should be able to change limits without redeploying the gateways.

Below the line

  • Volumetric DDoS at the network layer (the CDN and edge handle that before traffic reaches us).
  • Monthly billing quotas and metered invoicing.
  • Throttling our own calls to third-party APIs (a client-side limiter).
  • Load shedding based on overall system health.

At this point, ask your interviewer: "Is this protecting our API, or pacing us toward someone else's? Who calls it: anonymous browsers, logged-in users, partners with keys? How bad is 5% over the limit?" A limiter guarding a login form against password guessing and one metering a partner API have very different answers for accuracy and failure.

Before reading on: what are the non-functional requirements? Think about the latency every request pays, the request rate, what matters more when the limiter is sick, and how exact the count must be. Put a number on each.

Non-functional requirements

  1. Low latency. The check adds under 10 ms at p99, ideally 1 to 2 ms. It's on the path of every single request, so this number shapes the design.
  2. Scale. About 1 million requests a second at peak, from 100 million daily users.
  3. Availability over perfect accuracy. If the limiter is unhealthy, the API keeps serving. A few extra requests getting through is better than turning everyone away.
  4. Close is good enough. A user who briefly gets 105 requests instead of 100 is not an incident. Login is the exception: there, exact matters more than available.

Below the line

  • Strict global accuracy across regions.
  • Per-request audit logs kept for years.
The interesting tension is NFR 3 against NFR 4's exception. Say it out loud: "For most routes I'd rather be available than exact; for login and OTP I'd rather be exact than available. I'll make the failure mode a property of the rule." That sentence tells the interviewer you'll have a real answer when they ask what happens when Redis dies.

022. The set up

Plan the approach

We'll build the design one functional requirement at a time, starting with where the check runs. Then we'll use the non-functional requirements to drive the deep dives: the algorithm, correctness across gateways, latency at a million checks a second, failure, rule changes, and multiple regions.

Core entities

Three entities do all the work:

  • Rule: what it matches (plan, path), what it counts by (API key, user, IP) and the allowance (rate and burst).
  • Client key: the identity we count against, such as user:42, key:k_live_9, or an IPv6 /64 prefix.
  • Bucket: the live state for one client key under one rule: tokens left and the time of the last refill.
Fig. 1

Three entities: a rule, a client key, and a bucket per pair

Three entities: a rule, a client key, and a bucket per pairThree boxes. A Rule says what to match (plan, path), what to count by (API key, user, IP) and the allowance (rate and burst). A Client key is the identity we count against, such as user:42. A Bucket is the live state for one client key under one rule: tokens left and the last refill time. Notes give an example bucket key and value.Rulematch · key by · rate · burstClient keyuser:42 · key:k_9 · ip:/64Buckettokens · last refillone bucket per (rule, client key)key: rl:{user:42}:free-apivalue: { tokens: 13.4, ts: 1729500000.120 } expires when full
The entity map. One bucket per (rule, client key) pair. Notice the hash tag in the key: we'll use it when we shard.

API

The limiter's own interface is one internal call. The interesting API is the response the client sees, because a well-behaved client can only slow down if we tell it how.

# internal: made by the gateway middleware on every request
allow(clientKey="key:k_live_9", rule="free-api", cost=1) -> { allowed, remaining, retryAfterS }

# allowed: pass through, and advertise the budget
HTTP/1.1 200 OK
RateLimit-Policy: "free-api";q=20;w=2
RateLimit: "free-api";r=14;t=1

# rejected: say no, and say when
HTTP/1.1 429 Too Many Requests
Retry-After: 2
RateLimit: "free-api";r=0;t=2

429 and the optional Retry-After come from RFC 6585; Retry-After can be seconds or a date (RFC 9110). The RateLimit and RateLimit-Policy fields are from an IETF draft, not an RFC yet; GitHub and many others send x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset instead (GitHub Docs). Either is fine, as long as the 429 carries Retry-After.

Never trust the client to identify itself. The user ID comes from the

verified token, not a header the client sets. Behind a load balancer, read the client IP from X-Forwarded-For only as appended by your own proxies; everything to the left of that is whatever the client chose to send. And never let the client choose the rule (no ?plan=enterprise).

Back-of-envelope estimates

All inputs are assumptions to confirm with your interviewer; the arithmetic is ours and rounded.

Quantity Assumption Roughly
Checks per second one per request, 1M requests/s at peak ~1M script calls/s
Active buckets 100M users plus IPs and keys, ~2 rules each ~200-300M at the busiest time
Memory per bucket key + two numbers + overhead, budget ~100 B 300M x 100 B = ~30 GB
Throughput per shard budget ~100k script calls/s per Redis primary ~10 primaries, ~20 with headroom
Latency budget gateway to Redis in the same zone about 1-2 ms of our 10 ms

Notice what this tells us. Memory is small and buckets expire when idle, so this is a throughput and latency problem, not a storage problem. And one Redis node is out at 1M calls a second: we need a sharded cluster from day one.

033. High-level design

Let's start with the question that decides everything else: where does the check run?

Fig. 2

Where can the limiter live? Three places, one good default

Where can the limiter live? Three places, one good defaultThree lanes. In the first, the limiter runs inside the client app before the request leaves; anyone can remove it, so it is only a courtesy. In the second, every application server keeps its own counter behind a load balancer, so a user spread across several servers gets several times the limit. In the third, the limiter runs in the API gateway in front of all services and checks one shared counter, so a rejected request never reaches the services.In the clientIn each serviceAt the edge: API gatewayrequestokMobile / web app+ limiterAPIClientLBServer A (count 3)Server B (count 3)ClientAPI gateway+ limiterServicesBypassed with curlFine as politeness, neveras protection.Counters split N waysLimit 5, user sends 6:each server sees 3, all pass.One choke pointShared counter, and a 429costs the services nothing.
Three homes for a limiter. A client-side limiter is a courtesy anyone can delete; per-service counters split the count N ways; the gateway sees every request and can share one count.

In the client is a courtesy, not a control: anyone with curl skips it. In each application server feels natural, but a load balancer spreads one user across many servers and each counts only what it sees; with 10 servers and a limit of 100, a user gets close to 1,000 through. In the API gateway is where I'd put it. Every request already passes through it, it knows who the caller is after authentication, and a 429 costs the services nothing. Envoy, NGINX, Kong and the cloud API gateways all ship a limiter here for that reason (Envoy, Amazon API Gateway).

Now let's satisfy the requirements one at a time. Each picture is the previous one plus a few new boxes, drawn in colour; what we already had turns gray.

1) Identify the caller and allow or reject each request

We need:

  • API gateway fleet: about 50 stateless instances behind a load balancer. Authenticates and routes.
  • Limiter middleware: runs inside each gateway, before routing. Builds the client key and asks for a decision.
  • Redis: one shared place for the counts, so all 50 gateways agree.
  • Services: whatever is behind the gateway.
Fig. 3

Step 1: identify the caller and check one shared count

Step 1: identify the caller and check one shared countThe first board. Clients send requests to a fleet of API gateways (step 1). The limiter middleware in each gateway builds a key from the caller, such as user:42 or ip (step 2). It asks Redis, shared by every gateway, whether that key has budget left (step 3). If yes, the gateway forwards the request to the services (step 4).Gateway fleet (x50)clientsAPI Gatewayauth · routingLimitermiddlewareServicesx NRedisbuckets1key = user:422allow?3allowed4Who is calling?- API key: partners- user ID: logged in- IP (/64 for v6): anonymous only
The first board. The count lives in one shared store, not in each gateway. Follow badges 1 to 4.
  1. A client sends a request; the load balancer hands it to any gateway.
  2. The limiter middleware builds the client key: API key for partners, user ID from the token for logged-in users, IP (a /64 prefix for IPv6) for anonymous traffic.
  3. It asks Redis whether that key has budget left, and takes one unit if so.
  4. If allowed, the gateway forwards the request to the service.

Many corporate users share one IP behind a NAT, which is why IP is the key of last resort. Most real systems layer rules: a loose per-IP limit to slow floods, then a precise per-user or per-key limit for fairness. A request must pass every rule that matches.

2) Reject over-limit requests with 429, and say when to retry

No new boxes. We only add the "no" path, and it's the cheapest response in the system.

Fig. 4

Step 2: say no fast, and say when to come back

Step 2: say no fast, and say when to come backSame board, no new boxes. Step 1: a request arrives. Step 2: the limiter checks Redis and the bucket is empty. Step 3: the gateway answers 429 Too Many Requests straight away, with Retry-After and remaining-quota headers, and the services never see the request. Notes show the response headers.Gateway fleet (x50)clientsAPI Gatewayauth · routingLimitermiddlewareServicesx NRedisbuckets1empty24293The 429HTTP/1.1 429 Too Many RequestsRetry-After: 2RateLimit: "free";r=0;t=2On a 200 tooRateLimit-Policy: "free";q=20;w=2RateLimit: "free";r=14;t=1
Same board, a red path. The rejected request turns around at the gateway. The services never see it.
  1. A request arrives.
  2. The limiter's check finds the bucket empty and gets back "retry in 2 s".
  3. The gateway answers 429 Too Many Requests immediately, with Retry-After and the remaining-quota headers.

Send the quota headers on successful responses too. A good client watches remaining fall and slows down before it ever sees a 429.

3) Support different limits per caller and per endpoint

The free plan gets 10 a second, enterprise gets 1,000, and login gets 6 a minute per IP no matter who you are. We add one box:

  • Rule cache: the full rule table, held in memory in every gateway. Rules are read on every request, so they can't live behind a network call.
Fig. 5

Step 3: different limits per caller and per endpoint

Step 3: different limits per caller and per endpointOne new part in colour: a rule cache in each gateway's memory. Step 1: the limiter matches the request against the rules in memory (plan, path) and gets the key type and allowance. Step 2: it checks the bucket for that rule and key in Redis. Every matching rule must pass. Notes show two rules: free plan per API key at 10 per second with bursts of 20, and login per IP at 6 per minute that fails closed.Gateway fleet (x50)clientsAPI Gatewayauth · routingLimitermiddlewareServicesx NRedisbucketsRule cachein memorymatch1bucket per rule2rules- id: free-api match: plan=free, /v1/* key: api_key rate: 10/s burst: 20- id: login key: ip rate: 6/min on_store_failure: closed
The rule cache. Matching is a memory lookup; only the bucket check goes over the network.
  1. The limiter matches the request (plan, path, method) against the rules in memory, and gets back the key type, rate, burst and failure mode for each matching rule.
  2. It checks one bucket per matching rule in Redis.
- id: free-api
  match: { plan: free, path: "/v1/*" }
  key: api_key
  rate_per_second: 10
  burst: 20

- id: login
  match: { path: "/v1/login" }
  key: ip
  rate_per_second: 0.1     # 6 per minute
  burst: 5
  on_store_failure: closed

4) Let operators change limits without a redeploy

A partner launches and needs a temporary increase; an attack needs an emergency limit. Redeploying 50 gateways for either is too slow. We add:

  • Rules API: where operators edit rules. Validates them and keeps every version.
  • etcd: a small, strongly consistent config store with watches.
Fig. 6

Step 4: change a limit without redeploying 50 gateways

Step 4: change a limit without redeploying 50 gatewaysNew parts in colour: an operator, a Rules API and etcd. Step 1: the operator edits a rule through the Rules API, which validates it and keeps every version. Step 2: the API writes it to etcd. Step 3: every gateway holds a watch on the rules prefix, so etcd pushes the change and each gateway swaps its in-memory rule cache within about a second. If etcd is unreachable, gateways keep the last rules they had.Gateway fleet (x50)clientsAPI Gatewayauth · routingLimitermiddlewareServicesx NRedisbucketsRule cachein memoryoperatorRules APIvalidate · versionetcdrules1write2watch → push3
The control plane. Rules flow one way: API, store, watch, memory. The hot path never reads etcd.
  1. The operator edits a rule through the Rules API, which validates it (no rate: 0 on /* by accident).
  2. The API writes the new version to etcd.
  3. Every gateway holds a watch on the rules prefix; etcd pushes the change, and each gateway swaps its in-memory table within about a second.

If etcd is unreachable, gateways keep serving with the last rules they had. That's the property you want from a control plane: when it's down, nothing on the data path notices.

Split the data plane from the control plane

The data plane (gateway, rule cache, Redis) handles every request and must be fast and always up. The control plane (Rules API, etcd) changes rarely and can be slow or briefly down. Push configuration from one to the other, keep it in memory on the data side, and never put a control-plane call on the request path. The same split shows up in load balancers, service meshes and feature flags.

044. Deep dives

The board above is correct on a whiteboard. Now we push it against the non-functional requirements. For a senior candidate I expect the algorithm, atomicity and the failure mode; for staff, I expect you to bring up sharding limits, rule rollout and regions on your own.

1) Which algorithm should we count with?

Before reading on: the limit is 100 requests a minute, counted in fixed one-minute windows. A client sends 100 requests at 12:00:59 and 100 more at 12:01:00. How many get through, and in how many seconds?

All 200, in about one second. That's the classic flaw, and a good way into the four algorithms you should know.

Naive approach: Fixed window counter

One counter per key per window: INCR rl:user:42:202410211201, set an expiry on the first increment, reject above the limit. Redis documents exactly this pattern (Redis INCR). One number per key, one command, easy to explain.

Challenges: up to twice the limit across a window boundary, as the try-it showed. And every client's window resets at the same instant, so traffic arrives in synchronised waves at the top of each minute.

Fig. 7

Fixed windows let 200 through in about one second

Fixed windows let 200 through in about one secondA timeline across two one-minute windows, 12:00 to 12:01 and 12:01 to 12:02, with a limit of 100 per window. A tall red bar of 100 requests sits just before 12:01 and another just after. Each window sees only 100, so both are allowed, but a sliding one-minute view around 12:01 contains 200 requests in about one second.12:0012:0112:02window 1: 100 allowedwindow 2: 100 allowed100100200 in ~1 slimit: 100 / minuteeach window is happythe backend is not
The window edge. Each window is under its limit, and the backend still takes 200 requests in a second.
Solid approach: Sliding window log, or sliding window counter

Sliding log: store a timestamp per request in a sorted set, drop entries older than the window, count the rest. It's exact. Sliding counter: keep this window's count and the previous window's, and weight the previous one by how much of it still overlaps. Fifteen seconds into a minute, 75% of the last minute still counts: 8 x 0.75 + 3 = 9. Two numbers per key, and it assumes the previous window's requests were evenly spread.

Challenges: the log's memory grows with traffic (a key allowed 10,000 an hour stores up to 10,000 timestamps). The counter is an estimate, and neither expresses "quiet, then a burst when the page loads", which is how real clients behave.

Staff approach: Token bucket for API limits, sliding counter at the edge

A bucket holds up to capacity tokens and refills at rate tokens a second. Each request takes one (or cost for expensive calls); empty means 429. It stores two numbers, allows a burst up to the capacity, caps the long-run average at the rate, and "10 a second, bursts of 20" is a rule a product manager can read. Refill is computed lazily from elapsed time, so idle keys cost nothing.

For huge, coarse edge limits (per IP across millions of sites), the sliding counter is the better fit: Cloudflare measured it on 400 million requests and found 0.003% wrongly allowed or limited (Cloudflare).

Challenges: a full bucket lets a burst of capacity through at once. If the backend can't take bursts, keep the capacity small or put a leaky bucket (a queue drained at a fixed rate, as NGINX's limit_req does) in front of that one service (NGINX).

Fig. 8

Four ways to count: what each one remembers and where it slips

Four ways to count: what each one remembers and where it slipsFour cards. Fixed window keeps one counter per minute, so a client can send the full limit at the end of one minute and again at the start of the next, twice the limit in a few seconds. Sliding window log keeps every request timestamp and counts those in the last 60 seconds: exact, but memory grows with traffic. Sliding window counter keeps the previous and the current minute and weights the previous one by how much of it still overlaps the window: two numbers and a close estimate. Token bucket keeps a token count and a last-refill time; tokens drip in at a steady rate up to a capacity and every request takes one, which allows short bursts but caps the average.Fixed windowSliding window logSliding window counterToken bucketprevious minute8 requestscurrent minute3 so farrefill10 tokens/sbucketcap 20requesttakes 112:00 window12:01 windowKeeps: 1 counter per client per window+ tiny, one INCR- limit 5/min, yet 10 pass in ~2 s at the edgelast 60 sKeeps: every timestamp (sorted set)+ exact, no edge burst- memory grows with every request15 s into this minute: 8 x 0.75 + 3 = 9Keeps: 2 counters per client+ tiny and smooth at the edges- an estimate (assumes even spread)empty bucket -> 429Keeps: tokens + last refill time+ bursts up to cap, steady average- two knobs (rate, capacity) to tune
What each algorithm remembers decides where it's wrong. One counter allows double bursts at the edge; a log is exact but grows; two weighted counters are a cheap estimate; a token bucket allows a burst up to its capacity and caps the average.
Algorithm State per key Bursts Accuracy Good for
Fixed window 1 counter up to 2x at edges rough quick internal limits
Sliding log every timestamp none exact low-volume, high-stakes (login)
Sliding counter 2 counters smoothed close estimate edge limits at huge scale
Token bucket tokens + timestamp up to capacity exact for its model public API limits
Stripe uses token buckets in Redis for its request rate limiter, alongside a concurrent-requests limiter and two load shedders (

Stripe). Amazon API Gateway throttles with a token bucket described as rate and burst (AWS).

2) How does the count stay correct across 50 gateways?

Before reading on: two gateways handle requests from the same user at the same moment. Each reads

tokens = 1, decides "allowed", and writes back tokens = 0. What went wrong, and what's the cheapest fix?

Naive approach: Read, decide, write from the gateway

HGET the bucket, compute the refill and the decision in the gateway, HSET it back. The logic lives in normal application code, which is easy to test.

Challenges: two round trips with a gap between them. Both gateways read 1, both allow, both write 0: two requests on one token, a lost update that gets worse as one key gets hotter.

Fig. 9

Read, decide, write: two gateways spend one token twice

Read, decide, write: two gateways spend one token twiceSequence diagram with three participants: Gateway A, Redis and Gateway B. Step 1: A reads the bucket and sees one token. Step 2: B reads the same bucket and also sees one token. Step 3: A decides allow and writes zero. Step 4: B decides allow and also writes zero. Both requests went through on one token, a lost update. A note says the fix is to move the check and the write into one atomic script inside Redis.1HGET tokens → 12HGET tokens → 13allow, HSET tokens 04allow, HSET tokens 0Gateway ARedisGateway BFix: check and take inside Redis, in one scriptEVALSHA runs atomically per key; nothing interleaves2 requests, 1 token
The lost update. Each gateway is right on its own; together they spend one token twice.
Solid approach: Optimistic retry with WATCH and MULTI

WATCH the key, read it, compute, then MULTI ... EXEC the write. Redis aborts the transaction if the key changed since the watch, and the gateway retries.

Challenges: correct, but under contention for one hot key, gateways burn round trips on retries, which eats the latency budget exactly when traffic is highest. A distributed lock is worse still: acquire, read, write, release is four round trips on every request.

Staff approach: One Lua script per check, with Redis's clock

Move the whole decision into Redis. A script runs as a single unit; no other command touches the key halfway through (Redis scripting). One round trip, no race. Load it once and call it with EVALSHA, so you send a 40-byte hash instead of the script source.

-- KEYS[1] = bucket key, ARGV[1] = rate (tokens/s), ARGV[2] = capacity, ARGV[3] = cost
local rate, cap, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local t   = redis.call('TIME')                      -- Redis clock, not the gateway's
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6

local b      = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1]) or cap                 -- a new key starts full
local ts     = tonumber(b[2]) or now
tokens = math.min(cap, tokens + (now - ts) * rate)   -- refill for the time that passed

local allowed, retry = 0, 0
if tokens >= cost then tokens = tokens - cost; allowed = 1
else retry = math.ceil((cost - tokens) / rate) end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(cap / rate) + 1)   -- idle buckets disappear
return { allowed, math.floor(tokens), retry }

Notice three things. Time comes from Redis, so 50 gateways with slightly different clocks can't mint tokens. There's no background refill job. And the expiry is "time to fill up again": once a bucket would be full, deleting it changes nothing.

Challenges: a script blocks its shard while it runs, so keep it tiny (this one is microseconds). Test it like code, version it, and load it on every primary after a failover.

Fig. 10

One Lua script, run atomically inside Redis, decides each request

One Lua script, run atomically inside Redis, decides each requestThe gateway sends one EVALSHA call with the client key, the refill rate and the capacity. Inside Redis, as one atomic step, the script reads the Redis clock, the stored token count and last refill time, adds the tokens earned since then without going over the capacity, and checks whether at least one token is left. If yes, it takes a token, saves the bucket with an expiry and returns allowed with the remaining count. If no, it saves the refill and returns rejected with the seconds until the next token, which the gateway turns into a 429 with Retry-After.Inside Redis: one script, nothing else runs in betweenyesnorejected, retry_afterallowed, remainingAPI gatewayEVALSHA bucket.luakey, rate, capacity1Read bucketHMGET tokens ts2Refilltokens = min(cap, tokens + (now-ts) x rate)3tokens >= 1?4Take onetokens - 1, saveEXPIRE keyreturn allowedRejectsave refill onlyretry_after =(1 - tokens) / rateGateway answersforward the request,or 429 + Retry-After5Why a script and not GET, then SET?Two gateways can both read "1 token left" and both allow the request. A script runs as one unit inside Redis,so the read and the write for a key cannot interleave with another request for the same key.
Refill and take in one step. The script reads the bucket, adds the tokens earned since last time, and either takes one or reports how long until the next.
Many candidates reach for a distributed lock around the counter. It's correct and far too slow: four round trips per request, plus lock expiry to reason about. Redis already serialises commands per shard; the script uses that for free.

3) How do we serve a million checks a second inside 10 ms?

Before reading on: one Redis primary does roughly 100k of these scripts a second. You need 1M. And one abusive IP sends 200k requests a second. What do you change?

Naive approach: One big Redis with replicas for reads

Scale one primary vertically, add replicas, and send reads to them.

Challenges: one primary tops out well under 1M scripts a second. Replica reads don't help, because every check writes; worse, replication is asynchronous, so a replica can be a few writes behind and hand out tokens that were already spent.

Solid approach: Redis Cluster, sharded by bucket key

Redis Cluster splits keys across 16,384 hash slots, each primary owning a range (Redis Cluster). Every bucket key lands on exactly one shard, so the script never needs cross-shard coordination: one key, one shard, one atomic script. About 10 primaries cover 1M a second; run 20 for headroom, each with a replica for failover only. To check several rules for one caller in one script, use a hash tag like rl:{user:42}:free-api so that caller's buckets share a slot.

Challenges: hot keys. One abusive IP at 200k requests a second lands entirely on one shard and can starve every other key on it.

Staff approach: Pooled connections, local "blocked until", and a local pre-limiter

Three cheap defences around the cluster:

  • Pool connections from every gateway to every shard, so a check never pays for a TCP or TLS handshake (see TCP vs UDP).
  • Remember "no" locally. When a key is rejected with retryAfter = 30, the gateway caches "blocked until T" in memory and rejects locally until then. A flood of 200k a second becomes one Redis call per gateway per retry period.
  • A coarse local bucket in front: a per-gateway in-memory limit set generously absorbs bursts before they reach Redis. Envoy's docs recommend exactly this pairing of local and global limits (Envoy).

Challenges: "blocked until" is per gateway, so a blocked caller still costs one Redis call on each of the 50 gateways per period. That's 50 calls instead of 200k, which is the point.

Fig. 11

One key, one shard, one script, and hot keys stopped at the gateway

One key, one shard, one script, and hot keys stopped at the gatewayThree gateways on the left, each with a small in-memory cache of keys blocked until a time. In the middle, a Redis Cluster zone with three primaries owning hash slot ranges 0 to 5460, 5461 to 10922 and 10923 to 16383, each with a replica. Normal keys go from any gateway to the shard that owns their slot. A flood from one IP is rejected once and then answered from the gateway's blocked cache, so it stops reaching Redis. Notes: pooled connections, no reads from replicas, hash tags to keep one caller's buckets on one shard.Gateway 1blocked-until cacheGateway 2blocked-until cacheGateway 3blocked-until cacheRedis Cluster · 16,384 slotsPrimary 10 - 5460replicaPrimary 25461 - 10922replicaPrimary 310923 - 16383replicaip:203.0.113.7 floodanswered locallyKeep it fast- pooled connections- EVALSHA, not EVAL- never read replicas- {user:42} hash tag keeps one caller on one shardSizing (ours)1M checks/s ÷ ~100k perprimary ≈ 10, run ~20
One key, one shard. Normal keys go to the primary that owns their slot; a flood is answered at the gateway after the first rejection.
Rough math (ours): at 1M checks a second spread over 20 primaries, each handles ~50k, half its budget. A single 200k-per-second flood would quadruple one shard's load; the local "blocked until" cache turns it into ~50 calls a second. The hottest shards are the ones you most need to protect.

4) What happens when Redis is slow or down?

Before reading on: a Redis primary fails, and its replica takes 5 to 10 seconds to be promoted. What should the gateways do with requests for keys on that shard during those seconds?

This is the question that separates a design from a sketch.

Naive approach: Fail closed: reject when you can't check

If the limiter can't reach Redis, return 429 or 503. It feels safe: nothing unmetered gets through.

Challenges: a limiter outage becomes a full API outage. You've put a single point of failure in front of every service, and all your customers see errors because of a component that exists to protect them.

Solid approach: Fail open: allow when you can't check

If Redis times out, let the request through. Stripe recommends this for errors in the limiter itself (Stripe).

Challenges: protection disappears exactly when something is already wrong, which is when retry storms and abusers show up. And for login, unlimited password guesses for 10 seconds is a real security hole.

Staff approach: Fail soft, and make the failure mode part of the rule

Call Redis with a tight timeout (2 to 3 ms) behind a circuit breaker. When a call fails or the breaker is open, fall back to an in-memory token bucket per key at limit ÷ number of gateways. Limits get fuzzier, since a user spread unevenly across gateways gets somewhat more or less, but abuse stays bounded and the API stays up. Page the on-call either way.

Routes where extra requests cause harm (login, OTP verification, password reset) carry on_store_failure: closed in their rule. Rejecting logins for a minute beats unlimited guesses.

Challenges: limit ÷ gateways assumes even spread. If the load balancer is sticky or one region has more gateways, adjust the divisor from the gateway count each instance sees in the fleet registry.

Fig. 12

When Redis is slow or down: degrade to a local limit, do not take the API down

When Redis is slow or down: degrade to a local limit, do not take the API downThe gateway calls Redis with a tight timeout of a few milliseconds. If Redis answers, the shared bucket decides. If it times out or a circuit breaker is open, the gateway does not block every request and does not wave everything through: it switches to an in-memory token bucket per key with the limit divided by the number of gateways, and raises an alert. Strict routes such as login can choose to fail closed instead.yestimeoutstrict routeRequestkey = user:421Redis answersin < 5 ms?2Shared bucket decidesthe normal path: one countacross all gatewaysFail soft: local bucketin memory, limit / N gateways,alert on-call3Fail closedlogin, OTP: reject with 503rather than allow brute forcePick the failure mode per route, write it down, and test it by killing a Redis node.
Decide the failure mode per route. A tight timeout spots the problem; most routes fall back to a local bucket; brute-force-sensitive routes reject.

5) How do we roll out a rule change safely?

We already push rules by watch. The deep question is what stops a bad rule from limiting your biggest customer on launch day.

Before reading on: an operator ships a new rule, &quot;100 requests a minute per IP on

/v1/*". What happens to a customer whose 2,000 servers sit behind one NAT IP?

Naive approach: Rules in a config file in the gateway image

Version-controlled, reviewed, deployed like code.

Challenges: every change is a 50-gateway deploy, which takes too long during an attack. And the first time you learn the rule is wrong is when it's already blocking real traffic.

Solid approach: Gateways poll a rules table every 30 seconds

Edits go to a database; gateways reload periodically.

Challenges: up to 30 seconds to apply, constant polling load, and still no way to see a rule's effect before it bites.

Staff approach: Watched store, validation, shadow mode and a kill switch

Rules live in etcd and are pushed by watch, as in step 4. Around that:

  • Validate on write: no zero limits, no rule matching /* with a key type that can't be built for every route.
  • Version every change, so rollback is one write.
  • Shadow mode first. A new rule runs, but only logs "would have blocked"; you look at who it would have hit for a day, then switch it to enforce.
  • Kill switch. Any rule, or the whole limiter, can be turned off in one write.

Challenges: shadow mode doubles the checks for that rule while it runs. Sample it (check 10% of traffic) if the rule is broad.

Fig. 13

Rules flow one way: validated, versioned, shadowed, then enforced

Rules flow one way: validated, versioned, shadowed, then enforcedLeft to right: an operator edits a rule in the Rules API, which validates it (no zero limit on the whole API) and stores it as a new version. The rule is written to etcd. Every gateway watches the rules prefix and swaps its in-memory table within about a second. A new rule starts in shadow mode: gateways log what it would have blocked without blocking. After a day of clean metrics it is switched to enforce. A kill switch turns any rule off in one write.operatorRules APIvalidate · versionetcd/rules/*Gateway 1Gateway 2Gateway 3watch → push, ~1 sRule lifecycledraftshadowenforcekill switchlog "would block"before blocking
The rule lifecycle. Validated, versioned, shadowed, then enforced, with a kill switch always one write away.
Stripe's advice for shipping limiters lines up with this: hook them in so errors fail open, return clear 429s, keep kill switches, and

dark launch each limiter, watching what it would have blocked before it blocks anything (Stripe).

6) What if we run in three regions?

Before reading on: a partner has 1,000 requests a minute, and calls us from servers in the US, Europe and India. Where does their bucket live?

Naive approach: One global Redis for everyone

All regions check one cluster, so the count is exact.

Challenges: a cross-region round trip is 70 to 200 ms, on every request. That blows the 10 ms budget by ten times, and a transatlantic network blip takes every region's limiter down.

Solid approach: Split the limit statically per region

Each region has its own cluster and enforces a fixed share: say a third each.

Challenges: traffic is rarely even. A partner who sends 80% from the US hits their US share at 27% of their total limit and feels limited unfairly.

Staff approach: Local enforcement, shares rebalanced from observed usage

Each region enforces its share locally, with no cross-region call on the hot path. A small rebalancer collects per-key usage from each region every 10 seconds and moves shares toward where the traffic actually is (500 / 300 / 200 instead of thirds). New keys start with an even split.

Challenges: a caller who suddenly shifts regions is under-served for up to one rebalance period, and a caller hitting all three regions at once can briefly exceed the total. Both fit "availability over perfect accuracy"; write it down so nobody is surprised.

Fig. 14

Three regions, one limit: split it, then rebalance from what you see

Three regions, one limit: split it, then rebalance from what you seeThree region zones, us-east, eu-west and ap-south, each with its own gateways and Redis cluster enforcing a share of a 1,000 per minute limit: 500, 300 and 200. A small rebalancer at the bottom collects each region's usage for the key every 10 seconds and moves shares toward where traffic actually is. Notes: no cross-region call on the hot path, and a caller hitting all regions at once can briefly exceed the limit, which we accept.us-eastGatewaysRedisshare: 500 / mineu-westGatewaysRedisshare: 300 / minap-southGatewaysRedisshare: 200 / minShare rebalancerevery 10 susageusageNo cross-region call per request. A caller in all three regions at once may briefly get more.
Split the limit, then follow the traffic. No region waits on another; the rebalancer works off the hot path.

055. The final design

Here's everything on one board: clients, edge, the gateway fleet with its limiter, the Redis Cluster, the services, and the control plane. The numbered steps follow one request, then one rule change.

Fig. 15

Design a rate limiter: the whole system on one board

Design a rate limiter: the whole system on one boardLeft to right: browser, mobile and partner clients, an edge tier with a WAF and CDN and a load balancer, a gateway fleet with the limiter middleware, an in-memory rule cache and local fallback buckets, a Redis Cluster of three primaries with replicas, the services behind, and a control plane with a Rules API, an etcd rules store and metrics, edited by the on-call engineer. Numbered steps follow one request: edge, load balancer, rule match, one EVALSHA on the owning shard, forward if allowed, 429 with Retry-After if not, and a rule change written through the API and pushed to every gateway by watch.ClientsEdgeGateway fleet (x50)Redis ClusterServicesControl planeOperatorsBrowserby IPMobileby user IDPartnerby API keyWAF / CDNvolumetric DDoSLoad balancerL7 · TLSAPI gatewaylimiter middlewareRule cachein memoryLocal bucketsfallback · blocked-untilPrimary 1slots 0-5460Primary 2slots 5461-10922Primary 3slots 10923-16383+ a replica eachOrdersnormal limitsSearchcost = 5 tokensLogin / OTPfails closedRules APIvalidate · versionRules storeetcd · watchMetrics + logsallowed / limited per keyOn-call12match rule3EVALSHA4allowed5429 + Retry-After6fallback7write versionwatch, then push8Legendrequestpush (async)data read / writeFollow one request1Edge drops floods before they reach us2Load balancer picks any gateway3Key + rule matched in memory4One atomic script on the owning shardThen5Allowed: forwarded, quota in headers6Rejected: 429 + Retry-After, at once7On-call edits a rule through the API8Watch pushes it to every gateway
The whole system. The data plane (gateways and Redis) decides every request in one round trip; the control plane (Rules API and etcd) changes rules off the hot path. Follow badges 1 to 8.

Let's trace it:

  1. A request reaches the WAF / CDN, which drops volumetric floods before they reach us.
  2. The load balancer hands it to any of the 50 gateways.
  3. The limiter builds the client key and matches the rules in the rule cache, in memory.
  4. It runs one EVALSHA on the Redis primary that owns the key's slot. If Redis is slow, it falls back to the local buckets.
  5. Allowed: the request goes to the service, with quota headers on the response.
  6. Rejected: 429 with Retry-After, straight from the gateway.
  7. Separately, the on-call engineer edits a rule through the Rules API, which validates and versions it.
  8. etcd's watch pushes it to every gateway's rule cache within about a second.

066. What interviewers look for by level

Mid-level

  • Limiter in the gateway, with a reason.
  • Identifying callers sensibly (key, user, IP).
  • One algorithm explained correctly, usually token bucket or sliding window.
  • A shared counter in Redis, and a 429 with Retry-After.

Senior

  • Atomic check-and-take with a Lua script, and why not a lock.
  • A clear algorithm trade-off table.
  • Sharding by key, with sizing, pooled connections and no replica reads.
  • A deliberate failure mode, different for login.

Staff+

  • Accuracy treated as a tunable cost: local pre-limits, blocked-until caches, token leases.
  • Rule rollout: validation, shadow mode, kill switches.
  • Multi-region shares rebalanced from usage.
  • Observability for "why was I limited?", and how the limiter fits with load shedding and client backoff.

077. Follow-up questions

What if requests have very different costs?

Charge more than one token. A search that fans out to ten shards, a bulk export, or an LLM call measured in tokens takes cost tokens instead of one; the script above already takes a cost argument. Many GraphQL APIs score each query before running it and charge that score.

What if we need 10 million checks a second?

Ten times the shards works, because keys don't interact. The better answer cuts calls: each gateway leases a batch of tokens for a hot key (say 10 at a time), spends them locally and returns to Redis only when it runs out. Envoy's quota-based mode shares a global limit this way, from periodic load reports (Envoy). You trade a little accuracy for a big drop in Redis traffic.

What if a customer says they're being limited unfairly?

You need an answer in minutes. Log every rejection with key, rule, remaining tokens and gateway, and chart limited requests per key. Most cases turn out to be a shared office IP behind one NAT, or a retry loop in the customer's own code, and the chart shows which.

What if clients retry the moment they get a 429?

Then your limiter amplifies the problem. Return Retry-After on every 429, make your own SDKs honour it, and fall back to exponential backoff with jitter so thousands of clients don't retry in lockstep (Amazon Builders' Library). Only auto-retry idempotent requests, or POSTs with an idempotency key.

What if we need to limit concurrent requests, not requests per second?

That's a different counter: add when a request starts, remove when it ends, reject over the limit. Stripe runs one beside its rate limiter for expensive endpoints (Stripe). The trap is a crashed request that never removes itself, so store each in-flight request in a sorted set scored by start time and clear entries older than the request timeout.

088. How to say it in the interview

When I ask this question, I'm listening for three things: do you know where the check runs, can you make it atomic without making it slow, and do you have a plan for when it breaks.

In the interview

"I'd enforce limits in the API gateway, so there's one choke point and a 429 costs the services nothing. Each request maps to a key: API key for partners, user ID for logged-in users, IP only for anonymous routes, with IPv6 by /64. I'd use a token bucket, because it allows realistic bursts, caps the average, and is two numbers per key. Buckets live in a Redis Cluster sharded by key, and each check is one Lua script, called with EVALSHA, that refills from elapsed time on Redis's clock and takes a token atomically, so two gateways can't spend the last token twice. At a million checks a second that's about twenty primaries; memory is tens of gigabytes and idle buckets expire. Gateways pool connections, cache 'blocked until' for hot keys, and never read buckets from replicas. If Redis times out, most routes fall back to a local bucket at limit over gateways and we page someone; login and OTP fail closed. Rejections get 429 with Retry-After. Rules live in etcd, reach every gateway by watch, and every new rule runs in shadow mode first. Across regions, each region enforces a share that's rebalanced from observed usage."

On the whiteboard

  • Placement: API gateway middleware, one choke point
  • Key: API key > user ID > IP (/64 for IPv6); layer rules
  • Algorithm: token bucket (rate + burst); sliding counter at the edge
  • Atomic: one Lua script, EVALSHA, Redis TIME, lazy refill, EXPIRE when full
  • Scale: Redis Cluster by key, ~20 primaries, pooled, no replica reads
  • Hot keys: local "blocked until", local pre-limit
  • Failure: tight timeout + breaker → local bucket; login fails closed
  • Rules: etcd + watch, validate, version, shadow, kill switch
  • Regions: local shares, rebalanced every 10 s
  • Response: 429 + Retry-After; quota headers on every response

Concepts used in this design

Go deeper in Articles

Optional long reads behind this design, for when you want the full story.

Further reading (14)