Notes
Requirements
Start with requirements, as you would in the interview. Limit by API key, user, IP or route, with different rules per plan, changeable without a deploy. Each check should add less than a millisecond at p ninety-nine, handle a million checks a second across the fleet, and be accurate within a few percent. Clients should be told their limits. And the limiter must never be the reason the API is down.
- limit by API key, user, IP,
- route, or a combination
- rules per plan, change live
- adds under ~1 ms at p99
- 1M+ checks per second
- accurate within a few %
- tells clients the limit
- never takes the API down
Placement
Where can it run? As a library inside each service, which adds no network hop but is hard to update across many languages. In the gateway or a sidecar proxy, the most common choice, one place for all traffic. Or as a separate limiter service that the gateway calls for each request, like Envoy's global rate limit service; that's clean but adds a network call of about a millisecond.
- client library in each
- service: no hop, hard to
- update everywhere
- gateway / sidecar: one
- place, all languages (common)
- separate limiter service:
- gRPC call per request (Envoy
- global rate limit service)
- + ~0.5-1 ms per check
Rules
Model the rules as data. Each rule has a match, like route POST slash login or plan free, a key to count by, like IP or API key, and a limit with an optional burst. Store them in a rules service, push them to every limiter, and cache them in memory, so a request never waits on a rules lookup. Version them so a bad change can be rolled back in seconds.
- match: {route: POST /login}
key: ip
limit: 5 per minute
- match: {plan: free}
key: api_key
limit: 60/min, burst 20
- match: {plan: pro}
key: api_key
limit: 1,000/min, burst 200
cached locally, versioned The check
Here's the check itself, a token bucket stored as a small hash in Redis. A Lua script reads the token count and last refill time, adds the tokens earned since then up to the cap, takes one if available, writes back, and sets an expiry so idle keys disappear. Because Redis runs a script atomically, two gateways can't both take the last token. And it's one round trip, a few hundred microseconds inside a data centre.
tokens, ts = HMGET key t ts now = redis TIME tokens = min(cap, tokens + (now - ts) * rate) if tokens >= 1: tokens -= 1; allowed = 1 HSET key t tokens ts now PEXPIRE key ttl return allowed, tokens one script = atomic, 1 RTT
Races
Two classic bugs. If the gateway reads the count, decides, then writes it back, two gateways can both read one remaining token and both allow. Do the whole read-modify-write inside the store, as one script. And if each gateway uses its own clock to compute refills, skew between machines leaks extra tokens, so use the store's clock. Also decide whether client retries should count twice.
- GET then SET from the app:
- two gateways both read 1
- token → both allow (race)
- → do it in one script
- clock skew between gateways:
- use the store's clock
- retries count twice: count
- once per request id, or
- accept it
Sharding state
One Redis shard handles maybe fifty to a hundred thousand script calls a second, so shard buckets by key across a cluster. The trap is a hot key: one enterprise customer making eighty thousand calls a second lands entirely on one shard. Split its bucket into eight sub-buckets, each with an eighth of the limit, and pick one at random per request. Or move that customer to the local approach on the next beat.
- shard by limit key (Redis
- Cluster hashes the key)
- one Redis shard: roughly
- 50-100k script calls/s
- hot key: one tenant at 80k/s
- lands on one shard
- → split its bucket into 8
- sub-buckets of limit / 8
- → or local pre-allocation
Local + global
At very high volume, a store call per request is too much. The hybrid approach: each gateway keeps a local bucket per key in memory and decides in microseconds. Every hundred milliseconds or so, it reports how much it used to the shared store and gets back the global remaining count. Store traffic drops by orders of magnitude. The cost is accuracy: the fleet can overshoot briefly, which is fine for quotas but not for login protection.
- each gateway keeps a local
- bucket per key in memory
- → decision in microseconds
- every ~100 ms: report usage
- to Redis, receive the global
- remaining count
- + far fewer store calls
- - can overshoot briefly
- (accept a few % error)
Failure handling
When the store is slow or down, the limiter must not become the outage. Give the check a tight timeout, a few milliseconds, behind a circuit breaker. For normal traffic, fail open, but fall back to a coarse per-instance limit so capacity is still protected. For security-sensitive routes like login, one-time passwords and payments, fail closed. Either way, alert that limiting is degraded.
- tight timeout on the check
- (e.g. 5 ms), circuit breaker
- fail open: allow, but apply
- a coarse per-instance limit
- (protects capacity)
- fail closed: login, OTP,
- password reset, payments
- alert: limiter degraded
Multiple regions
Across regions, a single global store would add a cross-ocean round trip to every request. Instead, split each limit between regions by their traffic share, say six hundred a minute in the US and four hundred in Europe, and rebalance the split every minute or so. Or replicate counters asynchronously between regions and accept brief overshoot. Exact global limits and low latency don't go together.
- one global Redis: +80 ms
- cross-region per check → no
- split quota: 1,000/min →
- US 600, EU 400 by traffic
- share; rebalance each minute
- or async replicate counts
- (CRDT counters) and accept
- brief overshoot
Related limits
Two relatives worth mentioning. A concurrency limiter caps requests in flight rather than per second, which suits slow endpoints like exports or model calls where one request can take thirty seconds. And load shedding is adaptive: when the service itself is overloaded, measured by latency or queue depth, it drops the lowest-priority traffic first, regardless of quotas. Rate limits give fairness; these two protect capacity.
- rate: N requests per second
- concurrency: N in flight
- (protects slow endpoints:
- reports, exports, LLM calls)
- load shedding: when the
- service is overloaded, drop
- low-priority traffic first
- (adaptive, from latency or
- queue depth)
Client contract
The client contract matters. Reject with 429 Too Many Requests and a Retry-After header. Send limit, remaining and reset headers on every response, not only rejections, so well-behaved clients slow down before they hit the wall. And log each rejection with the rule that fired, because the first support ticket will be 'why am I being limited?'
HTTP/1.1 429 Too Many Requests Retry-After: 12 RateLimit-Limit: 1000 RateLimit-Remaining: 0 RateLimit-Reset: 12 also on successful responses so clients can pace themselves log every 429 with rule id
Sizing
Sizing. Ten million active keys at about a hundred bytes per bucket is roughly a gigabyte, tiny. Operations are what matter. Five hundred thousand checks a second at about sixty thousand script calls per shard is nine shards, so run twelve with replicas. Switch the heaviest keys to the hybrid approach and store traffic can fall to tens of thousands a second.
10M active keys × ~100 B per bucket (hash + key + overhead) ≈ 1 GB → tiny 500k checks/s at peak ÷ ~60k per shard ≈ 9 shards → run 12 + replicas for failover hybrid sync: 500k → ~20k/s
Say it like this
In the interview, cover rules as data, the gateway as the place it runs, a token bucket updated atomically in Redis with the store's clock, sharding by key with a plan for hot keys, fail open with local limits but fail closed for login, split quotas across regions, and 429 with rate limit headers. Then give the sizing: memory is small, operations decide the cluster.
- rules: match + key + limit
- runs in the gateway
- token bucket, Lua in Redis
- (atomic, store clock)
- sharded by key; hot keys
- split or local
- store down: fail open with
- local limits; login fails
- closed
- regions: split quotas
- 429 + RateLimit headers