Hirestack
Hirestack
Worked Example 15.8 · 8 of 15

Design a distributed rate limiter

📖 5 min read · 9 sections · May 2026
Asked at: Stripe Cloudflare Datadog Razorpay

The setup

"You're at an API company. Your customers have rate limits (1000 requests/minute per API key, for example). Design the rate-limiting system. Must work across 100 API gateway nodes; per-customer limits must be enforced accurately (not 100× the limit just because we have 100 nodes); failures must be graceful."

This problem isolates a single distributed-systems concept: shared state with strong consistency requirements at high throughput. The test is whether you can navigate the speed/accuracy/availability tradeoff explicitly.

Clarifying questions

QuestionStory answer
Total request rate at peak?1M req/sec across all gateways.
Number of unique rate-limited customers/keys?10M API keys.
Acceptable inaccuracy in limit enforcement?<5% over-allocation acceptable; would be reasonable in practice.
Latency budget?<2ms added per request for the rate-limit check.
What happens on rate-limit service failure — block all traffic or let through?Fail-open by default (better to over-grant than to deny legit traffic). Configurable per customer tier.

Design Decision 1: which algorithm?

flowchart LR
    C1[Client] --> G1[API Gateway 1]
    C2[Client] --> G2[API Gateway 2]
    C3[Client] --> G3[API Gateway N]
    G1 -->|EVAL Lua: take token?| R[("Redis cluster
per-key bucket")] G2 -->|EVAL Lua| R G3 -->|EVAL Lua| R R -.replication.-> RR[(Replica)] G1 -->|"allowed / 429"| C1 G2 --> C2 G3 --> C3
AlgorithmHowProsCons
Token bucketEach user has bucket of N tokens; refills at rate R/sec; consume 1 per request; reject if emptySmooths bursts (allows brief bursts up to bucket size); intuitive; widely used in practiceBurst tolerance is "feature" — some use cases require strict rate
Leaky bucketQueue with fixed drain rate; overflow rejectedStrict rate enforcement; predictable outputRejects bursts entirely — bad UX for bursty workloads
Fixed window counterIncrement counter per minute window; reset to 0 each minuteSimplest; cheap memory (one counter)Boundary problem: 2× burst across the window edge — a request at 11:59:59 and another at 12:00:01 both count separately, allowing 2× allowed rate
Sliding window logRecord timestamp of each request; count requests in last 60sMaximally accurateO(N) memory per user where N = rate; expensive at high QPS
Sliding window counterCurrent minute + (1 - elapsed/60) × previous minute countCompromise; accurate enough without per-request storageSlightly off in worst case; widely used in practice

Production pick: token bucket. Smooth, easy to reason about, widely battle-tested. State per user: (current_tokens: float, last_refill_at: timestamp) — 16 bytes. For 10M users: 160 MB. Trivial.

Design Decision 2: where does the bucket state live?

The central architectural question. Options span a spectrum of speed/accuracy/availability tradeoffs.

ApproachLatencyAccuracyAvailabilityVerdict
Per-gateway in-memory onlyFastest (zero network)Poor — each gateway sees only its traffic; 100 gateways = 100× over-allocationNo central SPOF; each gateway fails independentlyWrong for accurate limit enforcement
Central RedisAdds 0.5-1ms per request (network roundtrip)Perfect — single source of truthRedis is SPOF; outage = decision neededStandard for low-mid scale
Sharded Redis clusterSame as central; per-customer state on one shardPer-shard accurate; horizontal scaleBetter — shard failure affects only fractionProduction answer
Local + periodic sync to centralFast common case; occasional sync costBrief windows of inaccuracy between syncsLocal state continues if central downTrade-off for ultra-low-latency requirements

Production answer: sharded Redis cluster with atomic operations. Each customer's bucket lives on one Redis shard via consistent hashing. Gateway makes a single Redis call per request:

def is_allowed(customer_id):
    bucket_key = f"ratelimit:{customer_id}"
    # Atomic Lua script:
    #   - Refill tokens based on time elapsed since last refill
    #   - If tokens >= 1: decrement, return ALLOWED + remaining
    #   - Else: return REJECTED + retry-after
    result = redis.eval(token_bucket_script, key=bucket_key, args=[
        bucket_size, refill_rate, current_time
    ])
    return result

Lua script ensures atomic check-and-decrement. Without it: two simultaneous requests could both see "1 token available," both decrement, both pass — bucket goes to -1.

The Lua script — the actual atomicity primitive

local bucket_size = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])  -- tokens per second
local current_time = tonumber(ARGV[3])

local state = redis.call('HMGET', KEYS[1], 'tokens', 'last_refill')
local tokens = tonumber(state[1] or bucket_size)
local last_refill = tonumber(state[2] or current_time)

-- Refill based on time elapsed
local elapsed = current_time - last_refill
local refilled = math.min(bucket_size, tokens + elapsed * refill_rate)

if refilled >= 1 then
  refilled = refilled - 1
  redis.call('HMSET', KEYS[1], 'tokens', refilled, 'last_refill', current_time)
  redis.call('EXPIRE', KEYS[1], 3600)  -- TTL for cleanup
  return {'allowed', refilled}
else
  redis.call('HMSET', KEYS[1], 'tokens', refilled, 'last_refill', current_time)
  redis.call('EXPIRE', KEYS[1], 3600)
  return {'rejected', refilled}
end

Single Redis roundtrip; atomic via Lua execution; ~0.5-1ms latency.

Cross-examination round 1: Redis hot key

Interviewer: A whale customer makes 100K rps. Their bucket key is on one Redis shard. That shard handles 100K rps for one key. Sustainable?
Redis can handle 100K+ ops/sec for a single key. Right at the edge. Mitigations: (1) Subdivide the customer's quota into N sub-keys; route each request to a hash-based sub-key. The customer's logical limit is 100K rps; each sub-key handles 10K rps. (2) Local L1 cache: maintain a per-gateway estimate of "current tokens" with periodic sync to Redis. Brief inaccuracy windows during sync, but the customer can still be over-limited overall. (3) Dedicated Redis instance for whale customers. Most production combines: subdivide for very large, accept some inaccuracy for ultra-high-volume.
Follow-up: What if the customer's rate is genuinely 1M rps for a brief burst?
A: Token bucket allows bursts up to bucket size. If their burst exceeds bucket size, requests are rejected — this is the design. If they need higher steady-state, increase their rate or migrate them to higher tier. If the burst is a one-off spike (DDoS, integration bug), they hit the rate limit and the system protects itself.

Cross-examination round 2: Redis failure

Interviewer: Redis primary fails. 3-second failover gap. What happens?
During the gap, all rate-limit checks fail. Two policies:
  • Fail-open: allow all requests during the gap. Brief over-allocation; user-visible: traffic flows normally. Better UX, allows brief abuse window.
  • Fail-closed: reject all requests until Redis recovers. Worse UX (legit traffic blocked); safer for security-critical limits.
Default: fail-open for fairness limits; fail-closed for security/auth gates. Make this a per-rule configuration. Circuit breaker on the Redis client side: when Redis is down, gateway falls back to "allow all" mode until Redis recovers. Log all decisions in failover mode for audit.

Cross-examination round 3: configuration changes

Interviewer: Customer upgrades to a higher tier. Their limit should change immediately. How?
Bucket configuration stored separately from current state. Config service stores per-customer (bucket_size, refill_rate). Gateway fetches config (cached aggressively, TTL ~1 min). Lua script reads config from KEYS / ARGS at execution time. Config update: push event from billing system → invalidate config cache → next gateway request uses new config. The customer's current tokens persist across config changes; new bucket size and refill rate apply going forward.

Takeaway

Distributed rate limiting is the classic "where does shared state live?" question. Pure local is wrong (over-allocates); pure central is fragile and slow; sharded central with caching and graceful failure handling is what works. The interview test is whether you can articulate why each option falls short and explicitly pick the right tradeoff for the use case.

The deeper lesson: any distributed enforcement of a shared limit requires choosing between speed, accuracy, and availability — never all three. There's always a tradeoff. Senior engineers explicitly name the chosen point on this triangle (sharded Redis with fail-open) and justify it in terms of the use case.