Design a Rate Limiter
Interviewers love this because it tests knowledge of algorithms, Redis, distributed systems, and API design. Know all 4 algorithms and their trade-offs cold.
The 4 Rate Limiting Algorithms
Bucket holds max N tokens. Tokens added at rate R/sec. Each request consumes 1 token. If empty → reject.
→ AWS API Gateway, Stripe, most production rate limiters. Best overall.
Requests enter a queue (bucket). Processor drains at fixed rate R. If queue full → reject. Output is always at constant rate.
→ Traffic shaping, network QoS. When output rate must be strictly constant.
Divide time into fixed windows (e.g. per minute). Count requests in current window. If count > limit → reject. Reset counter at window boundary.
→ Simple rate limiting where boundary bursts are acceptable.
Store timestamp of every request in a sorted set. On each request: remove old timestamps, count remaining, if < limit → allow and add new timestamp.
→ Strict rate limiting for critical APIs. When accuracy > memory.
Compromise between Fixed Window and Sliding Log. Count = curr_window × (time_in_window / window_size) + prev_window × (1 - weight). Approximation.
→ Production systems needing accuracy without log overhead. Cloudflare uses this.
Redis-Based Token Bucket Implementation
Why Lua scripts? Redis operations are not atomic when combined. A Lua script executes atomically on the Redis server — no race conditions without using locks.
-- Lua script (runs atomically in Redis)
-- Keys: [key] Args: [maxTokens, refillRate, refillInterval, now, cost]
local key = KEYS[1]
local maxTokens = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local refillInterval = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local cost = tonumber(ARGV[5])
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(data[1]) or maxTokens
local last_refill = tonumber(data[2]) or now
-- Calculate new tokens to add since last refill
local elapsed = now - last_refill
local new_tokens = math.floor(elapsed / refillInterval * refillRate)
tokens = math.min(maxTokens, tokens + new_tokens)
last_refill = now
if tokens >= cost then
tokens = tokens - cost
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
redis.call('EXPIRE', key, 86400) -- TTL: auto-cleanup
return 1 -- ALLOWED
else
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
return 0 -- REJECTED
end # Python usage
import redis
import time
r = redis.Redis()
script = r.register_script(LUA_SCRIPT)
def is_allowed(user_id: str, max_tokens=100, refill_rate=10, cost=1) -> bool:
result = script(
keys=[f"rate:{user_id}"],
args=[max_tokens, refill_rate, 1000, int(time.time() * 1000), cost]
)
return result == 1
# HTTP middleware usage
def rate_limit_middleware(request):
if not is_allowed(request.user_id):
return Response(429, {
"error": "Too Many Requests",
"retry_after": 1 # seconds
})
return next_handler(request) Distributed Rate Limiting
Rate Limit Response Headers
| Header | Example Value | Meaning |
|---|---|---|
| X-RateLimit-Limit | 1000 | Max requests allowed in window |
| X-RateLimit-Remaining | 742 | Remaining requests in current window |
| X-RateLimit-Reset | 1735689600 | Unix timestamp when window resets |
| X-RateLimit-Retry-After | 42 | Seconds until client can retry (429 only) |
| X-RateLimit-Window | 3600 | Window duration in seconds |
Trade-offs Summary
| Consideration | Option A | Option B | Recommendation |
|---|---|---|---|
| Algorithm | Token Bucket (burst OK) | Sliding Window Log (strict) | Token Bucket for most APIs |
| Storage | Redis (centralized) | In-memory (local) | Redis for accuracy; in-memory for ultra-low latency |
| Scope | Per user | Per IP | Per API key (authenticated) + IP (unauthenticated) |
| Rate limit granularity | Per second | Per minute/hour | Multiple: 10/sec AND 1000/min |
| Failure mode | Fail open (allow all) | Fail closed (block all) | Fail open for availability; fail closed for security |
| Client transparency | Silent rejection | 429 + Retry-After header | Always return 429 with Retry-After |
Bottlenecks
→ Redis Cluster shards by user_id hash. 100M ops/sec is achievable with 10-node cluster. Each node handles ~10M ops/sec.
→ Rate limiter should fail open for availability. Log the Redis failure. Circuit breaker: if Redis error rate > 50% in 10 sec, bypass rate limiting temporarily.
→ Set TTL on every key = window_size × 2. Auto-cleanup removes inactive users. At 100 bytes per key × 1M users = only 100 MB.