InterviewKata
System design · Worked design D02 of 46 · Warm-up · Long form

Design a distributed rate limiter

A public API must stop any one client from sending more than its share: to protect the service, keep costs fair and blunt abuse. On one server, a counter in memory does it. With a hundred API servers behind a load balancer, the counters must be shared, fast, correct under concurrency, and must never become the thing that takes the API down. This is an infrastructure design: expect depth on algorithms, atomicity and failure modes. It follows the interview framework phase by phase.

1. The prompt

"Design a rate limiter for our public API. Each API key may make at most N requests per period; different endpoints and plans have different limits."

There's no product UI here: the "users" are other services. Interviewers want to see an algorithm chosen for a reason, counters that stay correct when 100 servers update them at once, a check that adds almost no latency, and a clear answer to "what happens when the limiter itself breaks?".

2. Frame the problem

Where is the limit enforced: one gateway tier, or inside every service?

In the API gateway tier, in front of all services (about 100 gateway servers).

The limiter is a library or sidecar in the gateway, not a separate hop. Services may add their own limits later.

Limit by what: API key, user or IP address?

By API key for authenticated traffic, by IP for unauthenticated endpoints such as login.

The counter key is configurable per rule.

Are short bursts allowed?

Yes. A client may burst briefly above its average rate.

That points towards a token bucket rather than a strict window.

How accurate must it be?

A few percent over the limit is fine; letting through twice the limit is not.

This allows approximate, much cheaper designs (local counting, sliding counters).

How much traffic?

About 1M requests a second at peak, from millions of API keys.

One Redis node can't take every check; we need sharding or local counting.

How fast must limits change?

Plan or endpoint limits change without a deploy, taking effect within a minute.

Rules live in a config store that gateways cache and refresh.

What if the limiter breaks?

The API must keep working for normal customers.

We need a default of failing open, with exceptions for sensitive endpoints.

One region or several?

Gateways run in three regions.

A truly global limit would need cross-region coordination; we'll decide how exact it must be.

3. What it must do

  1. The gateway can allow or reject each request against the rules that match it (per client key, per endpoint and plan), allowing short bursts.
  2. A rejected client gets a clear answer: 429 Too Many Requests with Retry-After and the remaining quota.
  3. Operators can change rules (limits per plan or endpoint) without a deploy.

Not now:

4. How well it must do it

TargetWhy this number
Cheap: under 2 ms added per request at p99 speedIt runs on every API call; it mustn't be noticeable.
Scale: 1M checks a second across ~100 gateways load shapeThe limiter sees all API traffic, including abusive floods.
Accurate enough: a client never gets more than ~5% over its limit consistencyLimits protect capacity and fairness; small overshoot is harmless, 2× is not.
Never the outage: if the limiter fails, normal traffic still flows (fail open), except on sensitive endpoints (fail closed) surviving failuresA rate limiter that takes the API down has done the opposite of its job.
Live rules: rule changes take effect within 60 s rulesIncidents and plan changes need fast reaction.

5. Numbers that change the design

QuantityWorking→ so we…
ChecksOne per request, 1M/s; a Redis node does roughly 100–200K simple ops/s→ so a Redis cluster of ~10–20 shards, or local counting for most checks
State10M active keys × a few counters × ~100 bytes~1–3 GB → so it all fits in memory
Latency budgetA same-zone round trip to Redis ≈ 0.3–1 ms→ so exactly one round trip per check: a single atomic script
RulesA few thousand rules × ~200 bytes< 1 MB → so every gateway caches all rules in memory
One abusive key100,000 requests a second from one keyAll on one Redis shard → so we need a local "blocked" cache for hot keys

6. The nouns

rules     rule_id, match (endpoint, plan), algorithm (token_bucket | sliding_window), rate, burst, window,
          key_by (api_key | user | ip), fail_mode (open | closed), version
redis     rl:{rule_id}:{client_key} → {tokens, last_refill_ms}     TTL ≈ time to refill completely

7. The contract

The limiter has three faces: an in-process call the gateway makes, the headers clients see, and an admin API for rules.

// inside the gateway (a library call, not a network hop)
allow(client_key, request) → {allowed, remaining, reset_after_ms, rule_id}

// what an API client sees when limited
HTTP/1.1 429 Too Many Requests
Retry-After: 12
RateLimit-Limit: 100
RateLimit-Remaining: 0

// admin API (operators)
PUT /v1/rules/{rule_id}   {match, algorithm, rate, burst, key_by, fail_mode}   If-Match: version

Retry-After is standard HTTP. The RateLimit-* header fields are being standardised by the IETF (still a draft in October 2026); many APIs use X-RateLimit-* names.

8. First working version

A limiter library in every gateway, a shared Redis for counters, and a config store for rules. Build each request.

8.1 Checking a request

The gateway finds the matching rule in its cached copy, reads the client's counter from Redis, and if it's under the limit, writes it back incremented and lets the request through.

8.2 Rejecting a request

When the count is at the limit, the gateway answers 429 at once with Retry-After. The services behind it never see the request, which is the point.

8.3 Changing a rule

An operator updates a rule through the admin API. It's stored in the config store with a new version; each gateway polls for changes every 30 seconds (or subscribes to change notifications) and swaps its cached copy.

Parked for later (section 9 fixes each)

  1. Which algorithm? A simple counter per minute lets twice the limit through at a minute boundary.
  2. Read, then write: two gateways can both read 99 and both allow the 100th request.
  3. A Redis round trip on every one of 1M requests a second.
  4. If Redis is down, every request waits on it.
  5. One abusive key overloads one Redis shard.
  6. Three regions: is the limit per region, or global?

9. Make it hold up

9.1 Choosing the algorithm accurate enough · cheap

AlgorithmHowGoodBad
Fixed windowCount per clock window (this minute); reset each minuteOne counter per key; simplestAllows up to 2× the limit across a window boundary (try it in section 13)
Sliding logStore a timestamp per request; count those in the last 60 sExactMemory grows with the limit; expensive at high limits
Sliding window counterCurrent window count + previous window count × the fraction of it still inside the last 60 sTwo counters; close to exact; smoothAn approximation (assumes the previous window's requests were spread evenly)
Token bucketTokens refill at rate R up to burst B; each request takes one (Module 24)Allows controlled bursts; two numbers per key; the common choiceTwo parameters to tune per rule

The requirements allow bursts and a few percent of error, so token bucket is the default, with a sliding window counter for rules that must be strict over a window ("100 per minute, no bursts"). Cloudflare has described using the sliding window counter at very large scale.

9.2 Counting correctly with 100 servers accurate enough

ApproachWhy
✗ BadA counter in each server's memoryThe load balancer spreads a client over 100 servers, so each sees 1% of its traffic: the effective limit becomes 100×.
~ GoodShared counters in Redis with GET, then SETShared, but read-then-write races (Module 17): two servers read 99, both allow, both write 100.
✓ GreatAn atomic operation in Redis: a Lua script that refills and takes a token in one step (or INCR plus EXPIRE in one script for a window). Keys are spread over a Redis cluster by client key.Redis runs each script atomically, so concurrent requests can't both take the last token. One round trip per request.
-- token bucket, run atomically by Redis (KEYS[1] = bucket, ARGV = rate/s, burst, now_ms)
local b = redis.call("HMGET", KEYS[1], "tokens", "ts")
local tokens, ts = tonumber(b[1]) or tonumber(ARGV[2]), tonumber(b[2]) or tonumber(ARGV[3])
tokens = math.min(tonumber(ARGV[2]), tokens + (tonumber(ARGV[3]) - ts) / 1000 * tonumber(ARGV[1]))
local ok = tokens >= 1
if ok then tokens = tokens - 1 end
redis.call("HSET", KEYS[1], "tokens", tokens, "ts", ARGV[3])
redis.call("PEXPIRE", KEYS[1], math.ceil(tonumber(ARGV[2]) / tonumber(ARGV[1]) * 1000))
return ok and 1 or 0

Pass the time in from one clock (or read Redis's own TIME), so gateways with slightly different clocks can't refill a bucket twice.

9.3 Keeping the overhead tiny cheap · scale

ApproachWhy
✗ BadA separate rate-limit microservice called over HTTP for every requestAdds a network hop, its own failure mode and capacity planning, for a check that should cost microseconds.
~ GoodMiddleware in the gateway talking straight to Redis, with a short timeoutOne fast round trip; simple.
✓ GreatTwo tiers: each gateway keeps a local token bucket per hot key (allowing its share of the global rate) and syncs usage to Redis every 100 ms; cold keys go straight to RedisMost checks never leave the process; global accuracy stays within a few percent. Used when 1 ms per request is too much.

9.4 When the limiter itself fails never the outage

If Redis is slow or down, every API request waits on it. Give the call a tight timeout (a few milliseconds) and a circuit breaker (Module 24), and decide the fallback per rule:

Alert when the breaker opens: running without the limiter is a risk that someone should know about.

9.5 Hot keys and abuse scale · cheap

9.6 Several regions, and pushing rules accurate enough · live rules

A global limit across regions needs either a cross-region round trip per request (far too slow) or an approximation:

Most APIs accept per-region limits. Rules are versioned in the config store and pushed to gateways with a notification, plus a periodic poll as a backstop; gateways keep the last good copy if the store is down.

9.7 The whole design after the deep dives

A normal request, an abusive client, and a Redis outage, through the final design.

10. Interviewer follow-ups

Traffic grows 10×: 10M requests a second.

Add Redis shards (keys are spread by client) and move more keys to the two-tier scheme, so most checks stay in process. The rules cache and the algorithms don't change. Watch the hottest shard, not the average.

Rejected clients retry immediately and make it worse.

Always send Retry-After, and publish client libraries that honour it with exponential backoff and jitter. Rejections are answered from the local "blocked" cache, so retries cost almost nothing. Persistent offenders can be blocked at the edge.

Some requests are far more expensive than others.

Charge tokens by cost instead of 1 per request: a search costs 5, a report export 50. The token bucket handles this unchanged. GraphQL APIs often compute a query cost before running it and charge that.

Limit concurrent requests, not just the rate.

A concurrency limiter counts in-flight requests per key: increment on start, decrement on finish, with a TTL so a crashed request can't hold a slot forever. Stripe has described running this kind of limiter alongside rate limits.

How accurate is the two-tier scheme, really?

Each gateway may hand out up to its local share before the next sync. With N gateways and sync interval T, the worst-case overshoot is about N × local rate × T. With short intervals and small local shares, that's a few percent. Shorten the interval for strict rules.

How do you test it before trusting it?

Run new rules in shadow mode first (log what would be rejected, reject nothing), compare against expectations, then enforce. Load-test the hot-key path and the Redis-down path; game-day the circuit breaker.

What about WebSockets and long-lived connections?

Limit connection attempts per key, limit concurrent connections per key, and limit messages per connection on the server holding it. A per-request limiter alone doesn't cover them.

Should paying customers ever be rejected?

Their limits are higher, and a sudden spike is often a bug in their code. Return 429 with clear headers, alert their account team on sustained throttling, and offer a burst allowance. Silent throttling causes support tickets; visible quotas prevent them.

11. When things break

FailureWhat users seeHow we detect itWhat limits the damage
Redis shard down or slowNothing for most clientsTimeouts; breaker opens; alertFail open with local limits; fail closed only on sensitive rules
Bad rule pushed (limit set to 0)Many 429s429 rate per rule spikesShadow mode for new rules; versioned rules with instant rollback
Config store unavailableNothingRefresh errorsGateways keep the last good rules
Abusive flood from one keyNothing for othersTop keys by rejectionsLocal blocked cache; edge blocking for floods
Clock skew between gatewaysSlight over- or under-limitingPer-gateway rejection rates differUse one time source (Redis TIME)

12. Wrap-up and the clock

Trade-offs we made:

What we'd watch:

At 10×:

A 45-minute interview

0–4Frame the problem (where, by what, bursts, accuracy, failure)
4–9Must do, how well, numbers
9–13Nouns and contract
13–22First working version, parked list
22–40Deep dives: algorithm (9.1), atomicity (9.2), overhead (9.3), failure (9.4)
40–45Follow-ups and wrap-up

A 60-minute interview

0–5Frame the problem
5–11Must do, how well, numbers
11–16Nouns and contract
16–26First working version
26–50Deep dives 9.1–9.6 and the final picture
50–60Follow-ups: retries, cost-based limits, testing; wrap-up
LevelWhat a strong answer shows
Mid-levelTwo or three algorithms explained, the limiter in the gateway, a shared Redis counter, 429 with headers.
SeniorThe read-then-write race fixed atomically; fixed window vs token bucket with the boundary problem; Redis sizing; fail-open vs fail-closed decided per rule.
Staff+Two-tier local/global design with accuracy bounds, hot-key handling, multi-region strategy, rule distribution and safe rollout, and how limits interact with retries and client behaviour.

Common mistakes:

13. Try it, check yourself, read more

Limit: 10 requests per 10 seconds (token bucket: burst 10, refill 1 per second). Pick a traffic pattern and compare the algorithms. Green = allowed, faded red = rejected.

What goes wrong with per-server in-memory counters behind a load balancer?

Each server sees only its share of a client's requests, so each lets the full limit through.

What's the weakness of a fixed-window counter?

A burst at the end of one window and another at the start of the next both fit their windows.

How do you stop two servers both granting the last token?

The check and the decrement happen in one atomic step.

Redis is down. What should the limiter do?

The limiter must not become the outage; login-like endpoints stay strict.
ResourceWhy
Scaling your API with rate limiters (Stripe engineering blog)The kinds of limiters Stripe runs in production, with code.
How we built rate limiting capable of scaling to millions of domains (Cloudflare blog)The sliding window counter approximation at enormous scale.
Design a distributed rate limiter (Hello Interview)Another walkthrough.