Skip to content
ajdevhub
7 min read

Module B9 β€” Design a Rate Limiter

System Design Mastery Course | Track B: HLD | Week 19


🎯 Module Overview

Duration: 1 Week | Track: B β€” HLD Fundamentals Deep Dive
Prerequisites: B1–B8
Goal: Rate limiting is the most commonly asked infrastructure component in system design interviews. It appears as a standalone question (β€œdesign a rate limiter”) and as part of every API design question. This module covers all 5 algorithms with exact memory/accuracy trade-offs, Redis Lua implementation, and distributed coordination strategies.


1. Why Rate Limit?

Three primary motivations:

1. DDoS / Abuse Prevention
   Attacker sends 100K requests/sec to API
   Per-IP rate limit at CDN edge: attacker gets 429, backend sees zero traffic
   
2. Noisy Neighbour
   One API tenant bug: tight loop, 10K req/sec
   Per-API-key limits: buggy tenant throttled, others unaffected
   
3. Cost Control
   External API costs money per call (OpenAI, Google Maps)
   Bug sends 1M calls/min = $50K cloud bill overnight
   Outbound rate limiter: max 1,000 external calls/minute

2. Algorithm Comparison

AlgorithmMemoryAccuracyBurst?Best Use Case
Fixed WindowO(1)~Low2Γ— at boundaryInternal services
Sliding Window LogO(requests)ExactNoFinancial APIs
Sliding Window Counter β˜…O(1)~99.7%No boundaryProduction default
Token BucketO(1)ExactYes (up to B)Burst-ok APIs
Leaky BucketO(queue)ExactNoPayment processors

3. Fixed Window Counter

Key: "rl:fixed:{userId}:{current_minute}"
Ops: INCR key β†’ if count==1: EXPIRE key 60

Problem: Boundary attack
  00:59 β€” 100 requests (allowed, window ends)
  01:00 β€” 100 requests (allowed, new window)
  Total: 200 requests in 2 seconds ← 2Γ— your intended rate

Use when: internal services, rough protection only
Avoid: any external-facing high-value API

4. Sliding Window Log

Data structure: Redis Sorted Set (score = timestamp)
Ops:
  ZADD key {now_ms} {now_ms}
  ZREMRANGEBYSCORE key 0 {now - window_ms}  ← remove old entries
  count = ZCARD key
  
Properties:
  Memory: O(requests in window) β€” 100 req/user Γ— 1M users = 100M entries in Redis
  Accuracy: Exact β€” no boundary problem
  
When to use: financial APIs, operations where exact count is regulatory requirement
When not: high-volume consumer APIs (memory prohibitive)

5. Sliding Window Counter β˜… (Production Default)

Key insight: approximate sliding count from 2 fixed windows

Keys: "rl:{userId}:{prev_minute}", "rl:{userId}:{curr_minute}"

Formula:
  elapsed_fraction = seconds_into_current_minute / 60.0
  effective_count = prev Γ— (1 - elapsed_fraction) + curr
  
  if effective_count >= limit β†’ reject
  else β†’ INCR curr_key, allow

Example (at 30 seconds into minute):
  prev = 80, curr = 30, limit = 100
  effective = 80 Γ— 0.5 + 30 = 70 β†’ allowed

Boundary attack fails:
  00:59: 100 requests β†’ prev = 100
  01:30: 60 requests cumulative β†’ effective = 100Γ—0.5 + 60 = 110 β†’ BLOCKED βœ“

Memory: O(2 keys per user) β€” same as fixed window!
Accuracy: Max error bounded by ~0.003% in practice

6. Token Bucket

Concept:
  Bucket capacity: B tokens (max burst)
  Refill rate: R tokens per second
  Each request consumes 1 token
  No tokens β†’ reject request

Behaviour:
  Idle period β†’ bucket fills to B tokens
  Burst arrives β†’ can serve up to B requests instantly
  After burst β†’ restores to steady R req/sec rate

Use when:
  Upload API: allow burst of 10 uploads then 1/minute
  Chat API: allow typing burst then throttle
  
Implementation: MUST use Redis Lua script for atomicity
  Without Lua: GET tokens, compute new tokens, SET tokens
  Race condition: two concurrent requests both read 1 token, both decrement to 0
  Both get allowed, but you actually had only 1 token β†’ count wrong

7. Redis Lua Script (Critical Concept)

-- Sliding Window Counter β€” Lua for atomicity
local prev = tonumber(redis.call('get', KEYS[1])) or 0
local curr = tonumber(redis.call('get', KEYS[2])) or 0
local limit = tonumber(ARGV[1])
local fraction = tonumber(ARGV[2])

local count = math.floor(prev * (1.0 - fraction)) + curr

if count >= limit then
    return {0, count}  -- blocked
end

local new_count = redis.call('incr', KEYS[2])
if new_count == 1 then
    redis.call('expire', KEYS[2], ARGV[3])
end

return {1, count + 1}  -- allowed
WHY LUA? Redis Lua scripts execute atomically.
No concurrent execution during script run.
No race conditions possible.
Alternative (wrong): GET β†’ compute β†’ INCR in separate commands
  Two concurrent requests: both GET count=99, both allowed, count reaches 101

8. Distributed Rate Limiting

Problem: 10 gateway nodes, where does the counter live?

Option 1: Local counter per gateway
  Fast (no network), but:
  Effective limit = N Γ— limit (N = gateway count)
  10 gateways Γ— 100/min = 1000/min actual
  
Option 2: Centralised Redis β˜… (production default)
  All gateways β†’ one Redis cluster
  Latency: ~1ms per request (Redis round-trip)
  Accuracy: exact global count
  Sharding: consistent hashing on API key β†’ each key on one shard
  Failure: fail open (allow) + circuit breaker
  
Option 3: Synchronised local counters
  Each gateway syncs to peers every 100ms
  Max overshoot: sync_interval Γ— rate
  Use: non-critical limits, Redis unavailable scenario
  
Production choice: Centralised Redis with consistent hashing
  3–5 Redis cluster nodes
  Single Redis call per API request
  If shard fails β†’ fail open (not fail closed)

9. HTTP 429 Response Standard

Always include these headers:

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100            ← the limit
X-RateLimit-Remaining: 0          ← how many left
X-RateLimit-Reset: 1705334460     ← Unix timestamp of window reset
Retry-After: 47                   ← seconds until retry (REQUIRED)

Body: {"error": {"code": "RATE_LIMIT_EXCEEDED", "retryAfter": 47}}

On every successful request too:
X-RateLimit-Remaining: 73         ← lets clients self-throttle proactively

Why Retry-After matters:
  Without it: clients retry immediately β†’ make overload WORSE
  With it: good clients wait β†’ thundering herd problem self-resolves

10. Multi-Tier Architecture

Layer 1: CDN / Edge (per IP, ~10,000 req/min)
  β†’ Catches DDoS before it reaches your infra
  β†’ Cloudflare, AWS WAF, Akamai handle this
  
Layer 2: API Gateway (per API key, varies by tier)
  β†’ Free: 100 req/min, Pro: 10,000 req/min  
  β†’ Centralised Redis backing
  β†’ This is what most "rate limiter" interview questions are about

Layer 3: Application Layer (per endpoint)
  β†’ POST /search: 10 req/sec (expensive DB query)
  β†’ POST /payment: 5 req/min (fraud prevention)
  β†’ GET /profile: 1,000 req/min (cheap)

Layer 4: Database / External API (outbound limits)
  β†’ Max 50 req/sec to Google Maps API
  β†’ Circuit breaker: trips at 50% error rate

πŸ“ Tasks

Task 1 β€” Algorithm Selection

  1. Twitter β€œpost tweet”: 300 per 3 hours. Which algorithm?
  2. Payment API: must NEVER exceed 5/min (regulatory). Which algorithm?
  3. File upload: burst of 10 then 1/minute. Which algorithm? What are B and R?
  4. Demonstrate fixed window boundary attack with numbers
  5. Prove sliding window counter max error is bounded

Task 2 β€” Redis Implementation

  1. Implement Fixed Window Counter. Write exact Redis commands.
  2. Implement Sliding Window Log. What data structure? Memory for 1M users Γ— 100/min?
  3. Show the race condition in Token Bucket without Lua. Concrete 2-request example.
  4. Redis experiences 50ms latency spike β€” impact on API p99? How to handle?
  5. Redis crashes β€” fail open (allow all) or fail closed (block all)? Justify.

Task 3 β€” Distributed Design

Design rate limiter for 10 gateway nodes, 100M API calls/day, per-key limits. Questions: Redis calls per request? Latency budget? Shard failure handling?

⭐ Task 4 β€” URL Shortener Rate Limiting

Add rate limiting to B5 URL shortener: POST /shorten: 10/min anonymous, 1,000/min authenticated GET /{code}: 1,000/min per IP DELETE /links/{id}: 5/min Global: 50,000 req/sec total


βœ… Completion Checklist

  • Why rate limit: DDoS, noisy neighbour, cost control
  • Fixed window: O(1) memory, boundary burst = 2Γ— limit
  • Sliding window log: exact, sorted set, O(requests) memory
  • Sliding window counter: O(1) memory, 99.7% accurate, formula
  • Token bucket: B tokens capacity, R tokens/sec refill, burst ok
  • Leaky bucket: uniform output, no burst, queue-based
  • Algorithm selection matrix: know when to pick each
  • Redis Lua: why required (atomicity), race condition without it
  • Distributed centralised Redis: consistent hashing, fail open
  • 429 response: all 4 headers, why Retry-After is critical
  • Multi-tier: CDN β†’ Gateway β†’ App β†’ DB/external
  • Completed Task 1 β€” algorithm selection scenarios
  • Completed Task 2 β€” Redis implementation + failure modes
  • Completed Task 3 β€” distributed design
  • Completed Task 4 β€” URL shortener rate limiting

β†’ Next: Module B10 β€” Consistent Hashing & Service Discovery


← B8 YouTube Notes ⚑ Interactive Module ↑ Roadmap NEXT: B10 Consistent Hashing β†’