System design interview: design a rate limiter

A rate limiter counts requests per client and rejects the ones over a limit with HTTP 429, before they reach the service they would overload. To design one, pick an algorithm (token bucket is the usual default), run it at the API gateway against a shared counter store such as Redis, and make each check a single atomic operation so that many servers enforcing the same limit cannot race each other.

By Published 8 min read

1. Clarify the requirements

"Design a rate limiter" can mean a library, a service or a gateway feature. Pin down who is limited, by what key, and how exactly.

  • Functional: limit requests per API key, user or IP address; different rules per endpoint and plan (say 100 a minute free, 1,000 paid); reject excess with 429 and say when to retry.
  • Non-functional: add a few milliseconds at most, stay available when parts fail, and enforce one limit across all gateway nodes, not one per node. Small overshoot in a burst is usually fine.
  • Out of scope unless asked: network-level DDoS protection (that happens before HTTP), monthly billing quotas, and queuing excess work for later.

2. Estimate the scale

The limiter runs on every request, so its cost scales with total traffic. The figures are assumed round numbers.

Back-of-envelope estimate for a limiter in front of a large public API (assumed figures)
QuantityAssumptionResult
Peak traffic1 million API requests a second across all gateways1 million limiter checks a second (one per request per rule)
Active clients10 million API keys or users seen in a dayat most 10 million buckets; fewer if idle keys expire
Token bucket memoryabout 100 bytes a key in Redis, overhead included10 million x 100 B = 1 GB: trivially fits in memory
Sliding log memorylimit of 1,000 a minute, about 64 bytes per stored timestampup to 64 KB per busy key; 1 million keys at the limit = 64 GB
Counter shardsabout 100,000 script calls a second per Redis shard1,000,000 / 100,000 = 10 shards; run 20 for headroom and failover
Latency budgetunder 5 ms added at p99; a same-zone Redis round trip under 1 msone round trip per request, never a cross-region call
Back-of-envelope estimate for a limiter in front of a large public API (assumed figures). Scroll sideways on a small screen.

So per-key state must be a few numbers, not a list of timestamps, and the counter store must be sharded and in the gateways' zone.

3. Define the API

# Internal: one call per matching rule
check(key="apikey:k_123", rule="search-paid", cost=1)
  -> { allowed: true, limit: 1000, remaining: 742, resetAfterMs: 31000 }

# Public: what an over-limit client receives
HTTP/1.1 429 Too Many Requests
Retry-After: 31
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 31
The check is usually a library call in the gateway; the 429 is the public contract.

Retry-After is standard HTTP; the X-RateLimit-* headers are only a convention, and APIs differ on whether the reset is seconds remaining or a Unix timestamp, so document your choice. Send them on successful responses too, so clients can slow down early.

4. Sketch the data model

Per-client state lives in Redis; rules live in configuration
ItemTypeWhy it is there
rl:{rule}:{client}Redis hash keyOne bucket per client per rule; its hash picks the shard
tokensnumberTokens left after the last refill
tsinteger, millisecondsLast refill time, so the next check adds tokens earned since
TTLmillisecondsTime to refill fully; idle keys expire, so memory tracks active clients
Ruleconfig row: match, capacity, refill rate, algorithmLoaded and cached by every gateway; changes rarely
Per-client state lives in Redis; rules live in configuration. Scroll sideways on a small screen.

No background job adds tokens. Each check refills lazily, tokens = min(capacity, tokens + elapsed x rate), so an expired key can safely be treated as a full bucket.

5. Choose the algorithm

This is the decision the interviewer most wants to hear. Name the options, then pick one.

Five common rate-limiting algorithms
AlgorithmHow it worksStrengthCost
Token bucketCapacity B, refilled at r tokens a second; each request spends oneBursts up to B, average held to r; two numbers per keyTwo parameters to tune; a full bucket releases a burst at once
Leaky bucket (queue)A queue of size B drained at a fixed rate rPerfectly smooth output for a fragile downstreamQueueing delay; a full queue drops new requests
Fixed window counterOne counter per key per clock minuteOne integer, very cheapUp to 2x the limit across a boundary: 100 at 0:59, 100 more at 1:00
Sliding window logStore each request's timestamp; count those in the windowExactMemory grows with the limit (the 64 GB above)
Sliding window counterWeight the previous window's count by its overlap with the sliding windowTwo integers; removes most of the boundary burstApproximate: assumes the previous window was evenly spread
Five common rate-limiting algorithms. Scroll sideways on a small screen.

A worked sliding window counter: limit 100 a minute, 80 requests last minute, 30 so far this minute, 15 seconds in. The sliding window still covers 75% of the previous minute, so the estimate is 30 + 80 x 0.75 = 90, and the request is allowed.

A sound default is token bucket for per-client API limits, because real clients are bursty. Use a sliding window counter when the contract reads "N per minute", and a leaky bucket queue for outbound calls to a partner API that only accepts a steady rate. (A leaky bucket used as a meter rather than a queue is equivalent to a token bucket.)

6. The architecture

  1. Clients
    • Apps and API clients (may throttle themselves)
  2. Edge
    • Load balancer
    • API gateway nodes with limiter middleware
    • Local rule cache
  3. Counters
    • Redis cluster, sharded by client key
    • One replica per shard
  4. Services
    • Backend services
    • In-service limits for costly operations
  5. Control
    • Rule configuration service
    • Metrics: allowed, denied, fail-open count
Where the limiter sits on the request path. Clients reach a load balancer and a fleet of API gateway nodes. Each gateway identifies the client, finds the matching rules in its cached rule configuration, and runs one atomic script per rule against a sharded Redis cluster in the same zone. Allowed requests continue to the backend services, which may add finer limits on expensive operations; denied requests get a 429 from the gateway. A configuration service pushes rule changes, and every decision is counted in metrics.
  1. IdentifyPick the most specific key available: API key, then user ID, then IP address. Only trust X-Forwarded-For when it was set by your own proxies.
  2. CheckRun one script per matching rule on the shard that owns the key, with a timeout of a few milliseconds.
  3. AnswerForward and attach the headers, or return 429 with Retry-After set to the time until enough tokens refill.

Where it lives. The gateway suits per-client limits: one enforcement point, before the backend does any work. Services add limits where cost varies by request. Client-side throttling saves wasted calls, but clients can be modified, so it never enforces anything.

7. Make it correct across many servers

The race. If a gateway reads the bucket, decides, then writes it back, two gateways can both read tokens = 1, both allow, and both write 0. The fix is one atomic step: Redis runs a Lua script without interleaving other commands, so the whole check is one round trip and one indivisible operation.

-- KEYS[1]: bucket key   ARGV: capacity, refill per second, cost
local cap  = tonumber(ARGV[1])
local rate = tonumber(ARGV[2]) / 1000            -- tokens per ms
local cost = tonumber(ARGV[3])
local t    = redis.call("TIME")                  -- one clock per shard
local now  = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)

local b      = redis.call("HMGET", KEYS[1], "tokens", "ts")
local tokens = tonumber(b[1]) or cap             -- missing key = full bucket
local ts     = tonumber(b[2]) or now
tokens = math.min(cap, tokens + math.max(0, now - ts) * rate)

local allowed = 0
if tokens >= cost then tokens = tokens - cost; allowed = 1 end
redis.call("HSET", KEYS[1], "tokens", tokens, "ts", now)
redis.call("PEXPIRE", KEYS[1], math.ceil(cap / rate))
return { allowed, math.floor(tokens) }
Token bucket check as a Redis script, using Redis's own clock.
  • Clock skew. If each gateway passes its own timestamp, one whose clock runs ahead refills buckets early. Reading TIME inside the script gives every check on a key the same clock. For a fixed window, set the expiry in the same script as INCR, or a crash between the two leaves a key that never expires.
  • Sharding. Spread keys with Redis Cluster's hash slots (see sharding). A script must touch keys in one slot, so a sliding window counter puts the shared part of its two window keys in a hash tag, rl:{search:k_123}:<window>.
  • Hot keys. One huge customer can saturate a shard. Let each gateway lease a batch of tokens (say 50) and spend them locally: Redis calls for that key drop 50-fold, and overshoot is bounded by the lease size times the number of gateways.
  • Cache denials. A key denied with Retry-After: 30 can be rejected from local memory for 30 seconds, so abusive clients cost almost nothing.
  • Multi-region. A global counter costs a cross-region round trip per request. Either split each limit across regions, or count locally and exchange counts every second or so, accepting overshoot of about one sync interval of traffic. That is consistency and CAP in practice: latency and availability over an exact global count.

8. What breaks, and what you do about it

Failure modes worth naming
FailureEffectMitigation
Redis shard down or slowChecks time out for every key on that shardApply the rule's policy: fail open with a per-node fallback limit (global limit divided by node count), or fail closed
Replica promoted after a crashAsynchronous replication loses recent decrements; clients get a few extra requestsAccept it for API limits; keep billable quotas in a durable store
Clients retry instantly after 429Retries amplify load when the system is busiestRetry-After, documented backoff with jitter, cached denials
Bad rule deployed (limit set to 0)Every client is blocked at onceValidate rules and run new ones in shadow mode, logging would-be denials, before enforcing
Many users behind one IPAn office or carrier NAT shares one limitKey on API key or user ID; give IP-only rules generous limits
Failure modes worth naming. Scroll sideways on a small screen.

9. Trade-offs to discuss

  • Fail open or fail closed. Failing open keeps the product working but removes protection exactly when something is wrong; failing closed turns a limiter outage into a full outage. Choose per rule: open for general API traffic, closed for logins, password resets, one-time codes and payouts, where a missing limit is a security hole.
  • Exact or cheap. A sliding log is exact and memory-hungry; counters and buckets are approximate and tiny. Token leases cut load further but loosen the limit.
  • Reject or delay. A 429 pushes the problem to the client; a queue absorbs bursts but adds latency, which suits background work better than interactive requests.

10. How to talk through it in the interview

Fictional example

The follow-up that separates a sketch from a design
Interviewer
You have twenty gateway nodes. Two of them check the same API key in the same millisecond, and the bucket has one token left. What happens?
Candidate
With a plain read then write, both see one token and both allow. I would run refill, check and decrement as one Lua script on the shard that owns the key, so the second call sees zero. The script also reads Redis's clock, so a gateway with a fast clock cannot refill early.
Interviewer
And if that Redis shard is unreachable?
Candidate
For this API rule I fail open after a 5 ms timeout, with a local per-node limit. For the login rule I fail closed: an attacker would love a limiter outage.
  • Compare at least three algorithms; the fixed-window boundary is the classic probe.
  • Raise the race condition yourself before the interviewer does.
  • State the failure policy per rule, not one answer for everything.

Practicing this design with Natively

Practice the walk-through as a mock interview, with a friend asking the follow-ups or with you reading the prompt aloud. Natively transcribes the conversation live and can suggest talking points for each question as it comes in; comparing them with what you said is a quick way to spot gaps such as clock skew or the fail-open policy. If you sketch the bucket and gateway layout, a screenshot of the diagram can be captured and explained. The transcript and notes stay available afterwards, so you can see which algorithm comparison took too long.

Questions people ask

Which rate limiting algorithm should I pick in an interview?

Token bucket is the safest default: it stores two numbers per client, allows short bursts and caps the average rate. Mention the fixed window's boundary problem and the sliding window counter as its cheap fix, then explain why bursts are acceptable for your API. Interviewers care more about the comparison than the pick.

Why not just use INCR and GET in Redis for a rate limiter?

Separate commands leave a gap between reading and writing, so two servers can both see the last token and both allow a request. A Lua script runs atomically on the Redis shard, so the refill, the check and the decrement happen as one step. It also lets you set the key's expiry in the same operation.

Should a rate limiter fail open or fail closed?

It depends on the rule. General API limits usually fail open with a small per-server fallback limit, because blocking every request turns a limiter outage into a full outage. Limits that protect logins, password resets or payouts should fail closed, since an outage there is an opening for attackers.

Where to go next

Practice with the same assistant you would use on a call: it listens, transcribes and suggests answers from your own resume and the job description.

Facts on this page were last checked on . Product details come from the Natively app itself; if something here is out of date, tell us at hello@heynatively.com.

Keep reading

Put it on your own machine.

A live-conversation assistant that runs locally. Your conversations stay on your machine.

macOS 12+ · Windows 10/11

© 2026 Natively AI Private Limited

Source-available under the Natively Personal Use Source License v1.0 · IP geolocation by DB-IP