Rate Limiting - Complete Deep Dive
Prerequisites: Caching, API Gateway Used in: Rate Limiter HLD, every API-facing system
What is Rate Limiting?
Rate limiting controls how many requests a client can make in a given time window. It protects your system from being overwhelmed β whether by a bug, abuse, or DDoS attack.
Real-world analogy: A nightclub with a bouncer. Capacity is 200. Once 200 people are inside, the bouncer stops letting people in until someone leaves. Doesnβt matter who you are β the limit applies to everyone.
Without rate limiting:
Buggy client sends 10,000 requests/sec
β Your servers max out CPU
β Database connection pool exhausted
β Legitimate users get errors
β System crashes
With rate limiting:
Buggy client sends 10,000 requests/sec
β Rate limiter allows 100/sec, rejects 9,900
β Client gets 429 Too Many Requests
β System stays healthy for everyone else
Where Rate Limiting Lives
Client β CDN/Edge (Cloudflare rules) β API Gateway (per-user limits) β Service
β
Per-endpoint limits
Best practice: Apply at the edge (API Gateway level). Donβt put rate limiting inside each microservice β thatβs too late and duplicates logic.
Rate Limiting Algorithms
1. Token Bucket (Most Popular in Interviews)
How it works: Imagine a bucket that holds tokens. Tokens are added at a fixed rate. Each request costs one token. If bucket is empty, request is rejected.
Bucket capacity: 10 tokens (max burst)
Refill rate: 2 tokens/second
Timeline:
t=0: bucket has 10 tokens
t=0: 5 requests arrive β 5 tokens used β 5 remaining
t=1: 2 tokens added β 7 remaining
t=1: 3 requests arrive β 3 used β 4 remaining
t=2: 2 tokens added β 6 remaining
t=2: 8 requests arrive β only 6 allowed, 2 rejected
Properties:
- Allows bursts (up to bucket capacity)
- Average rate matches refill rate
- Simple to implement
class TokenBucket:
def __init__(self, capacity, refill_rate):
self.capacity = capacity
self.tokens = capacity
self.refill_rate = refill_rate # tokens per second
self.last_refill = time.time()
def allow_request(self):
# Refill tokens based on elapsed time
now = time.time()
elapsed = now - self.last_refill
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
self.last_refill = now
# Check if we have a token
if self.tokens >= 1:
self.tokens -= 1
return True # Allowed
return False # Rejected (429)
Used by: AWS API Gateway, Stripe, most cloud providers
2. Sliding Window Log (Most Precise)
How it works: Store the timestamp of every request. To check if a new request is allowed, count how many timestamps fall within the last N seconds.
Window size: 60 seconds
Max requests: 100
Request arrives at t=150:
- Look at all timestamps in range [90, 150]
- Count = 98 β under limit β allow, store timestamp 150
Request arrives at t=151:
- Look at all timestamps in range [91, 151]
- Count = 100 β AT limit β reject
Implementation with Redis Sorted Set:
ZREMRANGEBYSCORE key 0 (now - window_size) # Remove old entries
ZCARD key # Count current window
ZADD key now now # Add this request
EXPIRE key window_size # Cleanup
Pros: Perfectly accurate. No edge-case bursts. Cons: Memory-heavy β stores every request timestamp. At 10K requests/sec, thatβs 600K entries per minute per user.
3. Sliding Window Counter (Best Tradeoff)
How it works: Combines two fixed windows and weights the previous window by time overlap. ~99% accurate with minimal memory (just 2 counters).
Window size: 1 minute
We're at 15 seconds into the current minute (25% in)
Previous minute count: 80 requests
Current minute count: 20 requests
Weighted estimate = 80 Γ 75% + 20 Γ 100% = 60 + 20 = 80
(75% because 75% of previous window overlaps with our sliding window)
Limit = 100 β 80 < 100 β allowed
Why itβs approximate: We assume requests in the previous window were evenly distributed. In reality they might have been bursty. But the error is typically < 1%.
Pros: Only stores 2 integers per key. Very fast. Cons: ~1% inaccuracy (acceptable for most use cases).
Used by: Cloudflare, most production rate limiters.
4. Leaky Bucket
How it works: Requests enter a queue (bucket). The queue processes at a fixed rate. If queue is full, new requests are dropped.
Queue capacity: 10
Processing rate: 5 requests/second
Burst of 15 requests:
- 10 enter the queue
- 5 are dropped immediately
- Queue drains at 5/sec (takes 2 seconds to empty)
Difference from Token Bucket:
- Token Bucket: allows bursts (processes immediately if tokens available)
- Leaky Bucket: smooths output (always processes at fixed rate)
Used by: Network traffic shaping (TCP congestion control), not common in API rate limiting.
5. Fixed Window Counter (Simplest but Flawed)
How it works: Count requests in fixed time windows (minute 1, minute 2, etc).
Window: 1 minute
Limit: 100
Minute 1 (0:00 - 0:59): 60 requests β allowed
Minute 2 (1:00 - 1:59): 0 requests so far β reset counter
The flaw β burst at window edges:
Minute 1, second 59: 100 requests (just under limit)
Minute 2, second 0: 100 requests (new window, counter reset)
Result: 200 requests in 2 seconds! Double the intended rate.
This is why sliding window is preferred.
Algorithm Comparison
| Algorithm | Accuracy | Memory | Burst Handling | Complexity |
|---|---|---|---|---|
| Token Bucket | Good | Low (1 counter + timestamp) | Allows controlled bursts | Low |
| Sliding Window Log | Perfect | High (stores all timestamps) | No bursts at boundary | Medium |
| Sliding Window Counter | ~99% | Low (2 counters) | Minimal boundary burst | Low |
| Leaky Bucket | Good | Medium (queue) | Smooths all bursts | Medium |
| Fixed Window | Poor (edge burst) | Low (1 counter) | Bad at boundaries | Very Low |
For interviews: say Token Bucket or Sliding Window Counter. Explain why fixed window has the edge problem if asked.
Distributed Rate Limiting
Single-server rate limiting is easy (in-memory counter). But with 10 servers, each seeing different requests, how do you enforce a global limit?
Approach 1: Centralized Store (Redis)
All servers check the same Redis instance before allowing a request.
Server A: redis.INCR("user:123:minute:500") β returns 51 β under 100 β allow
Server B: redis.INCR("user:123:minute:500") β returns 52 β under 100 β allow
...
Server C: redis.INCR("user:123:minute:500") β returns 101 β over limit β REJECT
Problem: Race condition between INCR and limit check. Solution: Use Redis Lua script (atomic execution):
-- Atomic rate limit check
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1]) -- Set TTL on first request
end
if count > tonumber(ARGV[2]) then
return 0 -- Rejected
end
return 1 -- Allowed
Approach 2: Local + Sync
Each server maintains a local counter. Periodically sync with central store. Less accurate but no Redis dependency on every request.
Server A: local_count=30, syncs every 5s
Server B: local_count=25, syncs every 5s
Central: total=55 (under 100) β all good
Trade-off: Can overshoot limit by up to (num_servers Γ sync_interval_requests). Acceptable for most use cases.
HTTP Response for Rate-Limited Requests
When a request is rate-limited, return:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1625097600
{
"error": "Rate limit exceeded. Try again in 30 seconds."
}
Headers to include:
Retry-After: seconds until the client can retryX-RateLimit-Limit: total requests allowed in windowX-RateLimit-Remaining: requests remainingX-RateLimit-Reset: Unix timestamp when window resets
Rate Limiting by What?
| Limit by | When | Example |
|---|---|---|
| User ID | Authenticated APIs | βUser can make 1000 API calls/hourβ |
| IP address | Public endpoints, login pages | βMax 10 login attempts per IP per minuteβ |
| API key | Third-party integrations | βFree tier: 100 calls/day, Pro: 10000/dayβ |
| Endpoint | Expensive operations | β/search: 30/min, /upload: 5/minβ |
| Combination | Fine-grained control | βUser X on endpoint Y: 50/minβ |
Common Interview Questions
Q: βWhere would you add rate limiting in this design?β A: At the API Gateway level, before requests reach any backend service. Use Redis for distributed state across multiple gateway instances.
Q: βWhat algorithm would you use?β A: Token Bucket for most cases β itβs simple, allows controlled bursts, and only needs 2 values per key (tokens + last_refill_timestamp). For stricter requirements, Sliding Window Counter.
Q: βWhat if Redis is down?β A: Fail open (allow all requests) or fail closed (reject all). Usually fail open β better to serve some extra requests than reject all users. Log the failure and alert.
Q: βHow do you handle distributed rate limiting without a central store?β A: Each server gets a fraction of the limit (100 total / 10 servers = 10 per server). Coarse but works without coordination. Or use the local+sync approach with periodic aggregation.
Q: βWhatβs the difference between rate limiting and throttling?β A: Rate limiting = hard reject after limit. Throttling = slow down (queue/delay) instead of reject. Rate limiting is simpler and more common.
Real-World Examples
| Company | Limit | Details |
|---|---|---|
| GitHub API | 5000/hour (authenticated) | Token-based, returns headers |
| Twitter API | 300 tweets/3 hours | Per-user per-app |
| Stripe | 100/sec (test mode) | Per-API-key |
| Google Maps | 50 requests/sec | Per-project |
| OpenAI | Tokens/min + requests/min | Dual limit |
| β Back to Fundamentals | β Caching | Next: Distributed Locking β |