Design a rate limiter service
The interviewer asks
“Design a rate limiting service used by all public APIs of a large company. It should support rules like '100 requests per minute per API key' and '10 OTP requests per hour per phone number', and work across hundreds of API servers.”
What a strong answer looks like
A strong answer first clarifies where limiting happens (API gateway vs inside services), what the keys are (user, API key, IP, tenant, endpoint) and whether limits must be exact or approximately right. Pick an algorithm on purpose: token bucket for bursty APIs, sliding window counter for fair per-minute quotas. Keep state in a shared Redis cluster with atomic Lua scripts, sharded by limit key, and keep the decision on the request path under a millisecond or two. Rules live in a config store and are pushed to gateways. Then handle the hard parts: hot keys, Redis failure (fail open vs fail closed per rule), multi-region limits, and clear client signals (429, Retry-After, X-RateLimit headers). Senior candidates add local pre-aggregation to cut Redis load and cost-based limits for expensive endpoints such as LLM calls.
Concepts to mention
1 Requirements
Functional
- Limit requests by configurable key: API key, user ID, IP, phone number, tenant, endpoint, or a combination
- Support rules such as N requests per second/minute/hour/day, with burst allowance
- Return HTTP 429 with Retry-After and X-RateLimit-Limit/Remaining/Reset headers when limited
- Rules can be changed without deploys and take effect within 30 seconds
- Support different tiers (free 100/min, paid 10,000/min) and per-customer overrides
- Shadow mode: evaluate a new rule and log would-be blocks without blocking
Non-functional
- Added latency p99 under 2 ms per request
- Handle 1M requests/s across all gateways, with about 50M active keys
- Rate limiter failure must not take down the APIs (configurable fail-open per rule)
- Accuracy: within ~1-5% of the limit for general APIs; strict for security limits like OTP and login
- Availability 99.99% for the decision path
Out of scope (say it out loud)
- Full DDoS protection at the network layer (handled by the CDN / WAF)
- Billing and quota invoicing (monthly quotas are a related but separate system)
- Bot detection with ML
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
gRPCRateLimit.ShouldAllow(descriptors[], cost=1) | Called by gateway or sidecar. Descriptors like [{api_key: k1}, {ip: 1.2.3.4}, {route: /v1/payments}]. Returns allow/deny, remaining, reset_at for the tightest rule. |
PUT/admin/v1/rules/{rule_id} | Create/update a rule: match descriptors, limit, window, algorithm, mode (enforce|shadow), fail_mode (open|closed). |
GET/admin/v1/rules?service= | List rules; used by gateways to sync rule config. |
GET/admin/v1/usage/{key} | Support/debug: current counters and recent 429s for a key. |
4 Data model
rulesPostgres + pushed to gateways via etcd/config servicerule_id (PK)servicematch (descriptor pattern)limitwindow_sburstalgorithmmodefail_modepriorityupdated_atLow write volume, needs audit history. Gateways hold the full rule set in memory and refresh on change notification.
countersRedis Clusterkey = rl:{rule_id}:{descriptor_value}:{window_start}value = countTTL = 2 × windowHash tag on descriptor value so all windows for one key land on the same shard. TTL means no cleanup job.
token_bucketsRedis Clusterkey = tb:{rule_id}:{descriptor}hash {tokenslast_refill_ms}Refill computed lazily on each call inside a Lua script, so no background refiller is needed.
limit_eventsKafka → ClickHousetsrule_idkeydecisionmode429s and shadow-mode hits for dashboards, tuning and customer support.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Check a request
- 1Request with API key k1 arrives at the gateway; the RL filter builds descriptors (api_key=k1, ip, route).
- 2The filter calls the rate limit service (or a co-located sidecar library) with all descriptors in one call.
- 3The service matches descriptors to rules held in memory, e.g. 'paid tier: 10,000/min per api_key' and 'POST /payments: 50/s per api_key'.
- 4For each matched rule it runs a Lua script on the Redis shard for that key; scripts for different shards run in parallel, same shard pipelined.
- 5The sliding-window script reads current and previous window counts, computes the weighted count, and increments only if under the limit (atomic).
- 6The tightest result wins: if any enforce-mode rule denies, the gateway returns 429 with Retry-After and X-RateLimit headers.
- 7If allowed, the request goes to the backend; shadow-mode denials are only logged to Kafka.
- 8If Redis does not answer within 5 ms, the rule's fail_mode decides: fail open for general APIs, fail closed (or local limiter) for OTP/login.
Change a rule
- 1An engineer updates a rule in the admin API, first in shadow mode.
- 2The rule is saved in Postgres with an audit row, and a new rules version is written to etcd.
- 3All rate limit service instances watch etcd, load the new rule set within seconds, and swap it atomically in memory.
- 4Dashboards show shadow hits; after review the rule is switched to enforce.
7 Deep dives
Choosing the algorithm
Fixed window counter (count per calendar minute) is the cheapest: one INCR with TTL. Its flaw is the boundary burst: a client can send 100 at 12:00:59 and 100 at 12:01:00, so 200 in two seconds. Sliding window log stores every request timestamp in a sorted set and is exact, but memory grows with the limit (10,000/min means 10,000 entries per key), so it is too expensive at 50M keys. Sliding window counter keeps the current and previous window counts and estimates: count = current + previous × (overlap fraction). It is two small counters per key and Cloudflare reported that on real traffic only about 0.003% of requests were wrongly allowed or limited; this is the best default for 'N per minute' quotas. Token bucket stores tokens and last refill time; each request refills tokens lazily (elapsed × rate, capped at burst) then takes one. It allows controlled bursts and supports cost-based limits (an LLM call costs tokens equal to estimated usage). Leaky bucket smooths output to a constant rate and is really a queue, useful for protecting a downstream with fixed capacity, such as an SMS provider. Say which you choose and why; many systems use token bucket for per-second burst limits and sliding window for per-minute or per-hour quotas.
Atomicity and the race condition
A naive implementation does GET count, compare, then SET count+1 from the API server. Two gateways can both read 99, both allow, both write 100, and the limit leaks. Under 1M req/s this is not rare. The fix is to make check-and-increment one atomic operation on the server holding the data. In Redis, a Lua script runs atomically on the shard: it reads the counters, decides, increments and sets TTL in one step, with no other command in between. For a simple fixed window, INCR is atomic by itself (INCR, then if result == 1 set EXPIRE; compare result to the limit). Keep all keys used by one script on one shard using a hash tag like rl:{k1}:..., because Redis Cluster rejects multi-key scripts across slots. Clock source matters too: use Redis server time (TIME command inside the script) or a monotonic time sent by the caller, not each gateway's wall clock, so windows align. Avoid distributed locks here: a lock per request would double the latency and become its own bottleneck.
Scaling past one Redis round trip per request
At millions of requests per second, even 0.5 ms per Redis call adds up, and very hot keys (one big customer sending 50k/s) overload a single shard. Two techniques help. First, local pre-aggregation: each gateway keeps a local counter and syncs with Redis every 50-100 ms or every N requests, adding its local count in one INCRBY. Decisions between syncs use the last global count plus local count. You trade a small overshoot (bounded by gateways × sync interval × rate) for 10-100× fewer Redis calls; Stripe and Envoy-style designs use variants of this. Second, for very large limits, split the key into K sub-counters (k1#0..k1#7) spread across shards, each with limit/K, chosen at random per request; the sum is approximately right. For strict limits (OTP: 5/hour/phone) keep the exact central check, since the volume per key is tiny. A clear senior point: accuracy needs differ by rule, so the design supports both an exact path and an approximate fast path.
8 Trade-offs
Centralised service vs library in each gateway
A central gRPC service is easy to update and to reason about, but adds a network hop. A library or Envoy filter inside each gateway talking directly to Redis removes a hop and a failure point, but spreads rule logic across many deployments. A common middle ground is a sidecar or in-gateway filter with rules pushed from a central config service, which is how Envoy's global rate limit works (it calls an external rate limit service) and what most API gateways offer.
Fail open vs fail closed
If Redis is down, failing closed blocks all traffic, turning a cache outage into a full outage. Failing open keeps the business running but removes protection exactly when you may be under attack. Choose per rule: fail open for general API quotas, fail closed or fall back to a conservative local in-memory limit (limit ÷ number of gateways) for OTP sends, login attempts and payment creation, where abuse costs real money (each OTP SMS costs ~₹0.15-0.25).
Exact vs approximate counting
Exact central counting gives correct limits but costs one Redis round trip per check and suffers on hot keys. Approximate local batching scales far better but can overshoot by a few percent. For customer-facing paid quotas, a small overshoot is usually acceptable; for fraud and security limits it is not.
9 Bottlenecks
Hot key on one shard
A single enterprise customer at 100k req/s sends all increments to one Redis shard. Use local pre-aggregation or split counters across shards. Also add a per-gateway local limiter as a first line, so obviously over-limit traffic is dropped without any Redis call.
Attack traffic with random keys
A DDoS with millions of random IPs creates millions of new keys and fills Redis memory. Keep TTLs short, put IP-level limiting at the CDN/WAF layer, and cap memory with an eviction policy (volatile-ttl) so the limiter degrades gracefully.
10 Failure handling
Redis shard failure
Redis Cluster promotes a replica within seconds. Counters written since the last replication may be lost, so a few clients get a little extra quota; acceptable. During failover, the per-rule fail_mode applies and a local in-memory limiter keeps the worst abuse in check.
Bad rule pushed (limit set to 0)
This would block all traffic for a service. Prevent with validation (limits above a floor for broad rules), shadow mode first, staged rollout of rule versions, and a one-click rollback to the previous version in etcd.
Multi-region traffic
Each region has its own Redis. A global limit of 1,000/min can be split per region by traffic share (e.g. 600 Mumbai, 400 Hyderabad) and rebalanced every minute, or regions sync counts asynchronously and accept short-term overshoot. A single global Redis would add 30-100 ms cross-region latency, which breaks the 2 ms budget.
11 What impresses
- Explains the fixed-window boundary burst and quantifies sliding-window memory trade-offs
- Identifies the read-modify-write race and fixes it with a Lua script, not a lock
- Different fail modes for OTP/login vs general APIs, with the cost reason
- Local batching with a bounded overshoot formula
- Shadow mode and staged rule rollout to avoid self-inflicted outages
- Cost-based limits (weight requests by cost, e.g. LLM tokens) instead of only counting requests
12 Mistakes
- Keeping counters in each server's memory and calling it a global limit
- GET then SET from the application, creating a race condition
- Using a sliding window log for large limits and running out of memory
- Not deciding what happens when Redis is unavailable
- Making every check cross-region
- Forgetting to tell clients when to retry (Retry-After, headers)
13 Follow-ups
Lessons this design uses
Practice questions
- Your public API enforces rate limits. What should the API tell clients about limits, and how should a well-behaved client react?
- Your login endpoint suddenly gets 50,000 failed attempts per minute from thousands of IPs, using real email and password pairs from other sites' leaks. How do you protect users?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.