HV
home / system-design / rate-limiter

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

1. Token Bucket ✓ Most Used

Bucket holds max N tokens. Tokens added at rate R/sec. Each request consumes 1 token. If empty → reject.

✓ Pros
· Allows bursts up to bucket capacity
· Smooth average rate
· Memory efficient: 2 values per user
✗ Cons
· Burst at boundary: user can consume all tokens then immediately get refill
· Race condition without atomic ops

→ AWS API Gateway, Stripe, most production rate limiters. Best overall.

2. Leaky Bucket

Requests enter a queue (bucket). Processor drains at fixed rate R. If queue full → reject. Output is always at constant rate.

✓ Pros
· Perfectly smooth output rate
· Simple queue-based implementation
✗ Cons
· Recent requests may be starved by old ones stuck in queue
· Does NOT allow bursts at all

→ Traffic shaping, network QoS. When output rate must be strictly constant.

3. Fixed Window Counter

Divide time into fixed windows (e.g. per minute). Count requests in current window. If count > limit → reject. Reset counter at window boundary.

✓ Pros
· Very simple
· Low memory: 1 counter per user per window
✗ Cons
· Boundary burst: user can fire 2× limit by bursting at end + start of adjacent windows

→ Simple rate limiting where boundary bursts are acceptable.

4. Sliding Window Log

Store timestamp of every request in a sorted set. On each request: remove old timestamps, count remaining, if < limit → allow and add new timestamp.

✓ Pros
· Most accurate — no boundary burst
· Precise request counting
✗ Cons
· High memory: stores every timestamp
· O(N) cleanup per request

→ Strict rate limiting for critical APIs. When accuracy > memory.

5. Sliding Window Counter

Compromise between Fixed Window and Sliding Log. Count = curr_window × (time_in_window / window_size) + prev_window × (1 - weight). Approximation.

✓ Pros
· Low memory (2 counters)
· Smooths out boundary bursts (95% accurate)
· Better than fixed window
✗ Cons
· Slight inaccuracy (assumes uniform distribution in prev window)

→ 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

Single Server: Client → Server → Redis → Allow/Reject Simple. Redis as central authority. Multi-Region Challenge: US: Server A (allows 40 req) EU: Server B (allows 65 req) Total: 105 req for 100 limit → VIOLATED Solutions: Option 1: Global Redis Cluster All servers point to SAME Redis cluster + Accurate across all regions - Redis becomes a bottleneck + single point of failure - Cross-region latency for Redis reads Option 2: Sticky Sessions (Client → Same Server) LB routes same user to same server Server has local in-memory counter + Fast (no network hop) - Server failure loses counter state - Doesn't work with horizontal scaling Option 3: Local + Sync (Gossip Protocol) Each server has local counter Servers gossip their counts periodically (every 100ms) Allow if local_count + estimated_global < limit + No Redis dependency - Small window of over-limit (acceptable for most APIs) Industry standard: Option 1 for critical APIs, Option 3 for high-throughput ones

Rate Limit Response Headers

HeaderExample ValueMeaning
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

ConsiderationOption AOption BRecommendation
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 is the bottleneck at 1M+ requests/sec

→ Redis Cluster shards by user_id hash. 100M ops/sec is achievable with 10-node cluster. Each node handles ~10M ops/sec.

⚠️ Redis goes down — all requests allowed (fail open) or all blocked (fail closed)

→ 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.

⚠️ Memory growth: 1M users × 1 counter each = 1M keys in Redis

→ Set TTL on every key = window_size × 2. Auto-cleanup removes inactive users. At 100 bytes per key × 1M users = only 100 MB.