Design a Rate Limiter

Design a distributed API rate limiter: rules per user and per IP, a shared counter store with atomic updates, races, and failing open or closed.

Case study30 minLesson 2 of 7
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions
  10. What to practise next

A rate limiter sits in front of an API and says "no" to clients that send too much. Every request asks "may this one through?", and the answer must come back within a millisecond or two, from any of dozens of gateway servers, which all have to agree on how many requests each client has made.

Interviewers like it because it is small enough to finish and still touches shared state, atomic updates, races, clocks, and what to do when a dependency dies. The counting algorithms themselves are covered in Rate Limiting; this case study is about building the whole service. It follows the steps in A Framework for Any Design.

Clarify the problem

"Design a rate limiter" can mean a class in one process or a service for a whole company. Find out which before you say "token bucket".

The first five minutes
Interviewer

Design a rate limiter for our public API.

You

Is this a limiter inside one service, or a shared one that protects every endpoint across many gateway servers?

Interviewer

Shared. We run a fleet of API gateways, and every request passes through one of them.

You

What do we limit by: API key, user account, IP address? And do different endpoints or plans get different limits?

Interviewer

Per API key for logged-in traffic, per IP for anonymous traffic. Plans have different limits, and login has its own strict limit.

You

And the scale: how many requests a day, and how many distinct clients?

Interviewer

About a billion requests a day, from around 10 million API keys and a similar number of IPs.

You

Then my priorities are: the check adds under 2 ms at the 99th percentile, a limiter outage must not take the API down, and limits can be a few percent loose but never ten times loose. I'll leave out billing and usage dashboards.

  • Functional: limit requests per API key, per IP and per account, with rules that vary by endpoint and plan; reject excess requests with 429 and say when to retry; let operators change rules without a deploy.
  • Non-functional: under 2 ms added at p99; the API stays up if the limiter is down; counts are shared across all gateways, accurate to within a few percent; rule changes apply within about 10 seconds.
  • Out of scope: billing, usage dashboards, bot detection beyond simple limits.

Estimate the scale

Turn the daily number into rates. A day has 86,400 seconds.

  • Average: 1 billion ÷ 86,400 ≈ 11,600 requests a second; call it 12,000.
  • Peak: assume 3x the average, so 36,000 a second.
  • Counter operations: a typical request is checked against two rules (its API key and its IP), so the counter store sees 2 × 36,000 = 72,000 operations a second at peak.
Estimate: Counter store load at peak

How many counter operations per second must the shared store handle at peak?

You know
  • 1 billion API requests a day
  • Peak is 3x the average
  • Each request is checked against 2 rules, one store operation each

Show the working
  1. Average: 109 ÷ 86,400 s ≈ 11,600, call it 12,000 a second
  2. Peak: 12,000 × 3 = 36,000 a second
  3. Store operations: 36,000 × 2 = 72,000 a second

Estimate 72 thousand/s (anything from 30 thousand/s to 200 thousand/s is a good answer)

One in-memory node does on the order of 100,000 simple operations a second, but each check here runs a small script. Plan for about 50,000 a node and shard across 4 for headroom.

Estimate: Memory for every counter

How much memory do the counters need if every client is active at once?

You know
  • 10 million API keys and 10 million IP addresses
  • One token bucket per key or IP
  • About 100 bytes per bucket, including the key and the store's overhead

Show the working
  1. Buckets: 10 million + 10 million = 20 million
  2. 20 × 106 × 100 B = 2 × 109 B = 2 GB

Estimate 2 GB (anything from 500 MB to 10 GB is a good answer)

Small. Memory is not the problem; operations per second and latency are.

Your turn: Define the API

There are three interfaces here: what the API client sees, what the gateway asks the counter store, and how operators change rules. Sketch each before reading on.

Compare with a model answer

Clients see a 429 with Retry-After and headers showing what is left. The gateway makes one atomic "take a token" call per rule. Operators write rules to a config store that gateways reload. The decision to argue is 429 versus 503.

Define the API

What clients see, and the internal calls
Every response from the API:
  X-RateLimit-Limit: 100        bucket size for the tightest rule
  X-RateLimit-Remaining: 36     tokens left after this request
 
Rejected request:
  429 Too Many Requests
  Retry-After: 1                seconds until a token is available
  body: { "error": "rate_limited", "rule": "key-free-plan", "retry_after_s": 1 }
 
Gateway to counter store, one atomic call per rule:
  take(key, capacity, refill_per_sec, cost = 1)
    -> { allowed, remaining, retry_after_ms }
 
Operators:
  PUT /admin/rules/{rule_id}    body: the rule (see the data model)
  400 if the rule is invalid; gateways pick up changes within 10 s

The decision worth arguing is the status code. 429 means "you, this client, sent too much"; 503 means "we are overloaded". Use 429 for limits, because a well-behaved client reads Retry-After and slows down. Keep 503 for load shedding, when the whole service is struggling regardless of who is asking. Mixing them up makes clients retry at the wrong times.

Your turn: Model the data

What records exist, how big are they, who reads them, and how often? Pick a kind of store for each.

Compare with a model answer

Rules: a few hundred records, read constantly, changed rarely, so every gateway holds them in memory. Counters: 20 million tiny records written on every request, so an in-memory key-value store with atomic scripts and expiry.

Model the data

Rules (config store) and counters (in-memory store)
rule
  rule_id          "key-free-plan"
  match            endpoint pattern and plan, e.g. "/v1/*", plan = free
  key_by           api_key | ip | account
  capacity         100            burst size
  refill_per_sec   10             long-run rate
  on_store_down    open | local | closed
 
counter            key = "rl:{rule_id}:{subject}"   e.g. "rl:key-free-plan:k_8f3a"
  tokens           float, 8 bytes
  last_refill_ms   integer, 8 bytes
  TTL              capacity / refill_per_sec = 100 / 10 = 10 s

Rules are a few hundred records in any durable config store. Gateways poll them into memory every 10 seconds, so no request waits on them.

Counters are the opposite: 20 million records of about 100 bytes, updated on almost every request, needed in about half a millisecond. That calls for an in-memory key-value store (such as Redis) that can run a small script atomically and expire keys. Durability barely matters: losing a counter just gives one client a fresh bucket.

The TTL is a neat trick. A bucket idle for capacity ÷ rate = 10 seconds is full again, and a missing key counts as full, so expiry changes no answer and frees memory.

Your turn: High-level design

Sketch where the check happens, where counters and rules live, and what a rejected request touches.

Compare with a model answer

The limiter is a library inside each gateway, so the only extra hop is to the counter store. Rejected requests never reach the backend.

High-level design

Run the limiter as a library in every gateway node. A separate limiter service would add a network hop to every request and one more fleet to keep alive. The counter store is split into 4 shards by key, each with a replica.

Each gateway checks counters in a shared store before forwarding. Rules reach the gateways in the background, off the request path.API clients to API gateway + limiter (HTTPS); API gateway + limiter to Counter store (4 shards) (take(key)); Rules store to API gateway + limiter (reload every 10 s), asynchronous; API gateway + limiter to Backend services (if allowed).API clientsAPI gateway +limiterCounter store (4shards)Rules storeBackend servicesHTTPStake(key)reload every 10 sif allowed

request and replyasynchronous message

Each gateway checks counters in a shared store before forwarding. Rules reach the gateways in the background, off the request path.

Step through an allowed request, a rejected burst and a shard dying.

Allowed, rejected, and the store goes downUse the arrows or ← → keys
  1. Allowed, rejected, and the store goes down, step 1API client to API gateway + limiter; API gateway + limiter to Counter store; Rules store to API gateway + limiter, asynchronous; API gateway + limiter to Backend services.API clientAPI gateway +limiterCounter storeRules storeBackend services
    tokens
    37
    allowed
    0
    rejected
    0

    1A free-plan client with key k_8f3a calls GET /v1/orders. The gateway already holds the rules in memory: per key, capacity 100, refill 10 a second.

  2. Allowed, rejected, and the store goes down, step 2API client to API gateway + limiter; API gateway + limiter to Counter store; Rules store to API gateway + limiter, asynchronous; API gateway + limiter to Backend services.API clientAPI gateway +limiterCounter store37 → 36Rules storeBackend services
    tokens
    36
    allowed
    0
    rejected
    0

    2One atomic call to the shard that owns rl:key-free-plan:k_8f3a: refill for the time passed, then take a token. About 0.5 ms round trip.

  3. Allowed, rejected, and the store goes down, step 3API client to API gateway + limiter; API gateway + limiter to Counter store; Rules store to API gateway + limiter, asynchronous; API gateway + limiter to Backend services.API clientAPI gateway +limiterCounter storeRules storeBackend services
    tokens
    36
    allowed
    1
    rejected
    0

    3Allowed. The gateway forwards the request and adds X-RateLimit-Remaining: 36 to the response.

  4. Allowed, rejected, and the store goes down, step 4API client to API gateway + limiter; API gateway + limiter to Counter store; Rules store to API gateway + limiter, asynchronous; API gateway + limiter to Backend services.API clientAPI gateway +limiterCounter storetokens: 0Rules storeBackend services
    tokens
    0
    allowed
    38
    rejected
    0

    4A buggy loop in the client fires 80 requests within a tenth of a second. 37 pass: the 36 tokens left, plus 1 that refills in that 0.1 s.

  5. Allowed, rejected, and the store goes down, step 5API client to API gateway + limiter; API gateway + limiter to Counter store; Rules store to API gateway + limiter, asynchronous; API gateway + limiter to Backend services.API clientAPI gateway +limiter429Counter storeRules storeBackend services
    tokens
    0
    allowed
    38
    rejected
    43

    5The other 80 − 37 = 43 get 429 with Retry-After: 1. The backend never sees them; protecting it is the whole point.

  6. Allowed, rejected, and the store goes down, step 6API client to API gateway + limiter; API gateway + limiter to Counter store; Rules store to API gateway + limiter, asynchronous; API gateway + limiter to Backend services.API clientAPI gateway +limiterdeny cacheCounter storeRules storeBackend services
    tokens
    0
    allowed
    38
    rejected
    50

    6The next token arrives in 1 ÷ 10 = 0.1 s, so the gateway remembers "k_8f3a denied for 100 ms" in its own memory. The loop's next 7 requests are rejected without calling the store.

  7. Allowed, rejected, and the store goes down, step 7API client to API gateway + limiter; API gateway + limiter to Counter store; Rules store to API gateway + limiter, asynchronous; API gateway + limiter to Backend services.API clientAPI gateway +limiterCounter storeRules storecapacity 200Backend services

    7An operator raises the free plan to 200. Gateways pick it up on their next 10-second poll; no request waits.

  8. Allowed, rejected, and the store goes down, step 8API client to API gateway + limiter; API gateway + limiter to Counter store; Rules store to API gateway + limiter, asynchronous; API gateway + limiter to Backend services.API clientAPI gateway +limiterlocal limitCounter storedownRules storeBackend services

    8Now the shard holding this key dies. The call times out after 5 ms. The rule says on_store_down: local, so the gateway falls back to an in-memory bucket with its share of the limit.

  9. Allowed, rejected, and the store goes down, step 9API client to API gateway + limiter; API gateway + limiter to Counter store; Rules store to API gateway + limiter, asynchronous; API gateway + limiter to Backend services.API clientAPI gateway +limiterCounter storedownRules storeBackend services

    9Requests within the local limit go through. The API stays up, roughly limited, while the replica takes over.

1 / 9

Deep dives

Interviewers push on four places: the algorithm, the shared counter, store failure, and the key you limit by.

Choosing the algorithm per rule

Pick the algorithm per rule, by what the rule protects. Rate Limiting explains how each one counts.

Which algorithm for which rule
OptionGood atBad atPick it when
Token bucket Usual defaultAllows short bursts, holds the long-run rate. Two numbers per key, one atomic script.A full bucket lets a whole burst through at once.General per-key and per-IP API limits. My default here.
Sliding window logExact count over the window.One timestamp per request per key, so memory grows with the limit.Small limits that must be exact: 5 login attempts per account per 15 minutes.
Fixed window counterOne integer and an expiry. Cheapest possible.Up to twice the limit across a window edge.Coarse quotas where the edge does not matter: 100,000 calls a day.

Say it like this: "Token bucket for the API limits, because clients legitimately burst when a page loads. A sliding log for login, because the limit is 5 and I want it exact. A daily quota is a fixed window with a 24-hour expiry." What would change my mind: if the backend cannot absorb bursts at all, a leaky bucket that queues requests fits better.

A shared counter with atomic updates

Twenty gateways each counting alone would let a client spread across them through up to twenty times its limit. So the count lives in one place per key: the shard of the counter store that owns that key, chosen by consistent hashing so adding a shard moves only a small share of keys.

The danger is splitting the check from the update. Here is the race, with one token left and two gateways.

The read-then-write race, and the fixUse the arrows or ← → keys
  1. The read-then-write race, and the fix, step 1Gateway A to Counter shard; Gateway B to Counter shard.Gateway AGateway BCounter shardtokens = 1
    tokens
    1
    allowed
    0

    1The bucket for k_8f3a holds 1 token. Two requests from the same client arrive at gateways A and B in the same millisecond.

  2. The read-then-write race, and the fix, step 2Gateway A to Counter shard; Gateway B to Counter shard.Gateway Aread 1Gateway BCounter shardtokens = 1
    tokens
    1
    allowed
    0

    2Naive version: A reads the count. 1 token, so A decides to allow.

  3. The read-then-write race, and the fix, step 3Gateway A to Counter shard; Gateway B to Counter shard.Gateway Aread 1Gateway Bread 1Counter shardtokens = 1
    tokens
    1
    allowed
    0

    3Before A writes back, B reads too. It also sees 1 token and also decides to allow.

  4. The read-then-write race, and the fix, step 4Gateway A to Counter shard; Gateway B to Counter shard.Gateway AGateway BCounter shardtokens = 0
    tokens
    0
    allowed
    2

    4Both write tokens = 0 and both requests pass. Two requests spent one token. Under heavy concurrency this overshoots by as many requests as are in flight at once.

  5. The read-then-write race, and the fix, step 5Gateway A to Counter shard; Gateway B to Counter shard.Gateway AallowedGateway BCounter shard1 → 0
    tokens
    0
    allowed
    1

    5The fix: send the whole refill-check-take as one script that the shard runs to completion before starting another. A's script runs first and takes the token.

  6. The read-then-write race, and the fix, step 6Gateway A to Counter shard; Gateway B to Counter shard.Gateway AallowedGateway B429Counter shard0
    tokens
    0
    allowed
    1

    6B's script runs next, sees 0 tokens and rejects. One token, one request. No locks were needed, because the shard runs scripts one at a time.

1 / 6
The atomic take, run inside the counter store
take(key, capacity, rate, cost):
  now = store's own clock, in ms
  tokens, last = read key            (missing key: tokens = capacity, last = now)
  tokens = min(capacity, tokens + (now - last) / 1000 * rate)
  if tokens >= cost:
      tokens = tokens - cost
      allowed = true
  else:
      allowed = false
  write key = (tokens, now), expire after capacity / rate seconds
  return allowed, floor(tokens), time until tokens >= cost

Two details show you have built one. First, the script uses the store's clock, not the gateway's: gateway clocks drift apart by milliseconds, and if A's clock is behind B's, now - last goes negative and the refill math breaks. Second, do not retry a timed-out take. The store may have taken the token before the reply was lost, so a retry would charge the client twice. Treat a timeout as "store down" for that one request.

Say the cost of replication out loud. Each shard copies to its replica asynchronously, so if a primary dies, the last few hundred milliseconds of decrements are lost: a few extra requests, once, which fits "a few percent loose".

Failing open or closed

The counter store will be unreachable sometimes. Decide in advance, per rule, what the gateway does then.

When the counter store cannot answer
OptionGood atBad atPick it when
Fail openA limiter outage never becomes an API outage.No protection until the store is back; one abusive client can hurt everyone.Low-risk endpoints where availability matters most and the backend has headroom.
Fall back to local limits Usual defaultStill available, still roughly limited. Each of 20 gateways allows 1/20 of the limit in its own memory.Only right if the load balancer spreads a client evenly; a client pinned to one gateway gets 1/20 of its limit.General API limits. My default.
Fail closedNo request skips the limit, ever.A store outage rejects every request on that endpoint.Login, password reset, SMS sending: endpoints where an unlimited attacker costs more than a short outage.

The arithmetic for the fallback: the free plan refills 10 a second, so each of 20 gateways allows 10 ÷ 20 = 0.5 a second for that key. A client spread evenly gets about 20 × 0.5 = 10 a second in total, the same as normal. That is why on_store_down is a field on the rule, not a global switch.

Per-user and per-IP rules together

A request can match several rules: the IP rule, the API key rule and an endpoint rule. Check them all, allow only if every one allows, and return the longest Retry-After. Check the cheapest, broadest rule first (per IP) so a flood is rejected before it costs a second store call.

  • IP alone is a poor identity. An office or a mobile carrier can put thousands of users behind one address. Use IP for anonymous traffic and floods, and the key or account once known.
  • IPv6 needs a prefix. One home is often given a whole block of addresses (commonly a /64), so limit by that prefix, or a single client can rotate through endless addresses.
  • Login needs two keys. Limit per IP (one attacker trying many accounts) and per target account (many IPs trying one account). Either alone misses one attack.

Bottlenecks and failures

At 10x (360,000 requests a second at peak), the counter store sees 720,000 operations a second. At about 50,000 per shard that is roughly 15 shards; run 16. Consistent hashing means going from 4 to 16 moves keys gradually, and a moved key simply starts as a full bucket. The gateways scale by adding nodes, since they hold only rules and a small deny cache.

The first thing to break is usually a hot key: one abusive client sending 50,000 requests a second lands every check on one shard. The local deny cache from the tracer handles this: after the first rejection, that gateway stops asking the store until the next token is due. Add a local pre-limit per IP in each gateway to drop obvious floods before they reach the store at all.

The deny cache does not help a big customer whose plan allows 20,000 requests a second, because those requests are allowed. For such keys, let each gateway take tokens in batches: one call takes 50, and the gateway spends them locally. Store calls drop 50-fold; the cost is that a gateway can hold a few unspent tokens when the limit is hit.

When this diesWhat happensWhat you do
A gateway nodeIts in-flight requests fail.The load balancer removes it. Counters are shared, so no limit state is lost.
A counter shard primaryChecks for its keys time out until the replica is promoted, a few seconds.Each rule's on_store_down applies meanwhile. Recent decrements may be lost, so some clients get a few extra requests.
The whole counter storeNo shared counts.Local limits for most rules, fail closed for login. Alert, because limits are now approximate.
The rules storeNo rule changes.Gateways keep the last rules they loaded. Nothing on the request path notices.
Network slow to the storeChecks blow the 2 ms budget.A 5 ms timeout, then the fallback. After a few timeouts, stop calling that shard for a few seconds (a circuit breaker), so requests do not each pay 5 ms.

What a strong answer sounds like

Most candidates know the token bucket. Levels differ on many servers, races and failure.

LevelWhat the answer does
JuniorExplains one algorithm well, in one process. Needs prompting to notice that many gateways each counting alone multiplies the limit.
MidPuts counters in a shared store, makes the check atomic, returns 429 with Retry-After, and estimates store load.
SeniorPicks the algorithm per rule, chooses fail open or closed per endpoint with reasons, uses the store's clock, refuses to retry a timed-out take, handles hot keys with a local deny cache, and says how loose the limits get during failures.
Defending the failure modeStrong answer
Interviewer

Your counter store is down. What happens to the login endpoint?

You

Login fails closed. If the limiter is down and login is unlimited, an attacker can try passwords as fast as our servers answer. A few minutes of failed logins is the smaller harm. The rest of the API falls back to local limits at one twentieth of the limit per gateway, so it stays up and roughly limited.

Interviewer

Isn't rejecting every login a bad experience?

You

It is, so I'd soften it: fall back to a strict local limit, say 1 attempt per IP per minute per gateway, instead of rejecting outright. Users can still log in slowly, and an attacker gets almost nothing.

A weak answer: "We'd make the store highly available so it never goes down." Every dependency goes down; the interviewer wants to hear what you do then.

Follow-up questions

How do you limit across several regions?

A store shared across regions puts a round trip of tens of milliseconds on every request. Instead, give each region its own counters and split the limit by each region's share of traffic, or sync regional counts every second and accept a small overshoot.

How would you limit by cost instead of by request?

Pass cost to the take call: a bulk export takes 10 tokens, a read takes 1. If the cost is only known after the request finishes, take an estimate up front and charge the difference afterwards.

How do you roll out a new, stricter limit safely?

Run it in shadow mode first: the gateway evaluates the rule and logs who would have been rejected, but lets them through. After a few days of logs you know which customers it hits, and can warn them before enforcing.

Why not just put the limit in the load balancer?

Many load balancers can limit per IP, a good first layer against floods, but they do not know the API key or plan. Use both layers.

Self-review: rate limiter0 of 13

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure

What to practise next

The counter store here is a small, specialised key-value store. Build a general one, with replication and quorums, in Design a Key-Value Store.