Chapter 28: Rate Limiting & Throttling
Why Rate Limit
Rate limiting controls the frequency of requests to prevent system overload and ensure fair resource allocation.
| Goal | Description | Example Scenario |
|---|---|---|
| Prevent Abuse | Block malicious actors, DDoS attacks, credential stuffing | Limit login attempts to 5 per minute per IP |
| Protect Resources | Prevent resource exhaustion (CPU, memory, DB connections) | API servers can only handle 10K req/sec |
| Fair Usage | Ensure all users get proportional access | Prevent one user from monopolizing API quota |
| Cost Control | Limit usage of expensive operations | Cap video transcoding jobs at 100/day per user |
| SLA Enforcement | Tie limits to subscription tiers | Free: 100 req/hr, Pro: 10K req/hr, Enterprise: unlimited |
| Graceful Degradation | Shed load before cascading failures | Return 429 instead of crashing under load |
Where Rate Limiting Applies
- API Gateway: Public-facing APIs (REST, GraphQL)
- Authentication: Login, password reset, 2FA attempts
- Database: Query rate per tenant to prevent noisy neighbors
- Microservices: Inter-service communication limits
- Third-Party Integrations: Respect downstream rate limits
- User Actions: Post creation, file uploads, message sends
Token Bucket
The most widely used rate limiting algorithm. Tokens are added to a bucket at a fixed rate, and each request consumes a token.
Capacity: 100 tokens
Current: 45 tokens] B[Refill Rate: 10 tokens/sec] C{Request Arrives} D[Token Available?] E[Allow Request
Consume 1 token] F[Reject Request
429 Too Many Requests] end B -->|Add tokens periodically| A C --> D D -->|Yes| E D -->|No| F E -->|Update bucket| A
Algorithm Parameters
| Parameter | Description | Example |
|---|---|---|
| Bucket Size | Maximum tokens the bucket can hold (burst capacity) | 100 tokens |
| Refill Rate | Tokens added per time unit | 10 tokens/second = 600/minute |
| Initial Tokens | Tokens at bucket creation | Usually equal to bucket size |
| Token Cost | Tokens consumed per request | Typically 1, but can vary by operation |
How Bursts Work
Token bucket allows short bursts up to bucket capacity, then enforces sustained rate equal to refill rate.
| Time | Tokens Available | Request | Result | Explanation |
|---|---|---|---|---|
| 0s | 100 | 50 requests | Allow all (50 tokens left) | Burst allowed |
| 0.5s | 50 | 60 requests | Allow 50, reject 10 | Bucket empty after 50 |
| 1s | 10 | 20 requests | Allow 10, reject 10 | Refilled 10 tokens at 1s mark |
| 2s | 10 | 5 requests | Allow all (5 tokens left) | Steady state: 10 tokens/sec |
Implementation Pseudocode
class TokenBucket:
def __init__(self, capacity, refill_rate):
self.capacity = capacity
self.refill_rate = refill_rate # tokens per second
self.tokens = capacity
self.last_refill = now()
def allow_request(self, tokens_required=1):
self.refill()
if self.tokens >= tokens_required:
self.tokens -= tokens_required
return True
return False
def refill(self):
current_time = now()
elapsed = current_time - self.last_refill
tokens_to_add = elapsed * self.refill_rate
self.tokens = min(self.capacity, self.tokens + tokens_to_add)
self.last_refill = current_time
Advantages & Disadvantages
| Advantages | Disadvantages |
|---|---|
| Allows bursts (important for real-world traffic) | Requires state per user/key (memory overhead) |
| Memory efficient (2 numbers: tokens + timestamp) | Clock drift in distributed systems causes inconsistency |
| Simple implementation, low CPU | Burst can overload downstream if not sized correctly |
| Easy to reason about and debug | No sub-second precision without high-res timers |
Real-World Usage
- AWS API Gateway: Token bucket with burst and sustained rate
- Stripe API: 100 req/sec sustained, 1000 req/sec burst
- Kubernetes: Client-side rate limiting for API server
- Network Traffic Shaping: QoS in routers (tc command in Linux)
Leaky Bucket
Requests are added to a fixed-size queue and processed at a constant rate, like water dripping from a bucket with a hole.
Variable Rate] B[Queue/Bucket
Max Size: 100
Current: 30] C[Process at Constant Rate
10 req/sec] D{Queue Full?} E[Add to Queue] F[Reject Request
429] end A --> D D -->|No| E D -->|Yes| F E --> B B -->|Leak| C
Key Characteristics
| Property | Token Bucket | Leaky Bucket |
|---|---|---|
| Output Rate | Variable (allows bursts) | Constant (smooth output) |
| Burst Handling | Immediate if tokens available | Queued for later processing |
| Memory | O(1) per key | O(queue_size) per key |
| Latency | Low (immediate accept/reject) | Higher (queuing delay) |
| Use Case | API rate limiting, burst-tolerant systems | Traffic shaping, constant throughput required |
Implementation Pseudocode
class LeakyBucket:
def __init__(self, capacity, leak_rate):
self.capacity = capacity # max queue size
self.leak_rate = leak_rate # requests per second
self.queue = []
self.last_leak = now()
def allow_request(self, request):
self.leak()
if len(self.queue) < self.capacity:
self.queue.append(request)
return True
return False
def leak(self):
current_time = now()
elapsed = current_time - self.last_leak
requests_to_process = int(elapsed * self.leak_rate)
# Process (remove) requests from queue
for i in range(min(requests_to_process, len(self.queue))):
process(self.queue.pop(0))
self.last_leak = current_time
When to Use Leaky Bucket
| Scenario | Why Leaky Bucket | Example |
|---|---|---|
| Constant Processing Rate | Downstream can't handle bursts | Video encoding service with fixed workers |
| Traffic Shaping | Smooth out bursty network traffic | ISP bandwidth throttling |
| Background Jobs | Maintain steady resource usage | Email sending queue (1000/min constant) |
| Third-Party APIs | Respect strict rate limits | Twitter API allows exactly 300 req/15min |
Fixed Window Counter
Count requests in fixed time windows (e.g., 1-minute intervals). Simple but has edge-case issues.
Count: 0] B[12:01:00 - 12:01:59
Count: 0] C[12:02:00 - 12:02:59
Count: 0] end A -->|Reset at 12:01:00| B B -->|Reset at 12:02:00| C
How It Works
| Time | Window | Request Count | Action | Result |
|---|---|---|---|---|
| 12:00:30 | 12:00:00 - 12:00:59 | 50 | Request arrives | Allow (count = 51) |
| 12:00:59 | 12:00:00 - 12:00:59 | 99 | Request arrives | Allow (count = 100) |
| 12:00:59.9 | 12:00:00 - 12:00:59 | 100 | Request arrives | Reject (limit reached) |
| 12:01:00 | 12:01:00 - 12:01:59 | 0 | Window resets | Count resets to 0 |
| 12:01:01 | 12:01:00 - 12:01:59 | 0 | Request arrives | Allow (count = 1) |
The Edge-Case Problem
Limit: 100 req/min. User sends 100 requests at 12:00:59 and 100 at 12:01:00 = 200 requests in 1 second, violating the intent of the limit.
Implementation Pseudocode
class FixedWindowCounter:
def __init__(self, limit, window_size):
self.limit = limit
self.window_size = window_size # seconds
self.counter = 0
self.window_start = current_window_start()
def allow_request(self):
current_window = current_window_start()
# Reset counter if new window
if current_window > self.window_start:
self.counter = 0
self.window_start = current_window
if self.counter < self.limit:
self.counter += 1
return True
return False
def current_window_start(self):
return (now() // self.window_size) * self.window_size
Advantages & Disadvantages
| Advantages | Disadvantages |
|---|---|
| Extremely simple to implement | Allows 2x burst at window boundaries |
| Memory efficient: O(1) per key | Unfair: early arrivals in window get priority |
| Fast lookups (counter + timestamp) | Not suitable for strict rate enforcement |
| Easy to visualize and explain | Window reset causes traffic spikes |
Sliding Window Log
Track the timestamp of every request in a log. Most accurate but memory-intensive.
Sorted Timestamps] B{Count requests
in last 60 sec} C[12:00:15
12:00:42
12:01:03
12:01:28] D{Count < 5?} E[Allow & Add Timestamp] F[Reject 429] end A --> C C --> B B --> D D -->|Yes| E D -->|No| F E --> A
How It Works
- Store timestamp of every request in a sorted log (array or sorted set)
- On new request, remove timestamps older than window size
- Count remaining timestamps
- If count < limit, allow request and add new timestamp
- Otherwise reject
Example: 5 requests per minute
| Current Time | Log (timestamps in window) | Count | New Request | Result |
|---|---|---|---|---|
| 12:00:15 | [12:00:15] | 1 | - | Allow |
| 12:00:42 | [12:00:15, 12:00:42] | 2 | - | Allow |
| 12:01:03 | [12:00:15, 12:00:42, 12:01:03] | 3 | - | Allow |
| 12:01:28 | [12:00:42, 12:01:03, 12:01:28] | 3 | - | Allow (12:00:15 expired) |
| 12:01:55 | [12:01:03, 12:01:28, 12:01:55] | 3 | Arrives | Allow (count now 4) |
Implementation Pseudocode
class SlidingWindowLog:
def __init__(self, limit, window_size):
self.limit = limit
self.window_size = window_size # seconds
self.log = [] # sorted list of timestamps
def allow_request(self):
current_time = now()
cutoff_time = current_time - self.window_size
# Remove expired timestamps
self.log = [ts for ts in self.log if ts > cutoff_time]
if len(self.log) < self.limit:
self.log.append(current_time)
return True
return False
Redis Implementation
-- Use sorted set with timestamps as scores
ZADD rate_limit:user123 {timestamp} {request_id}
ZREMRANGEBYSCORE rate_limit:user123 0 {cutoff_timestamp}
ZCARD rate_limit:user123 -- count requests in window
EXPIRE rate_limit:user123 {window_size + buffer}
Advantages & Disadvantages
| Advantages | Disadvantages |
|---|---|
| Perfectly accurate, no boundary issues | High memory: O(N) per key where N = limit |
| True sliding window at millisecond precision | Expensive cleanup on every request |
| Fair: respects exact time windows | Slower than counter-based approaches |
| Can query exact request history | Doesn't scale well to high limits (10K+ req/min) |
Sliding Window Counter
Hybrid approach combining fixed window simplicity with sliding window accuracy. Best balance for production systems.
Algorithm
Estimate current window count using weighted sum of previous + current fixed windows.
12:00:00-12:00:59
Count: 80] B[Current Window
12:01:00-12:01:59
Count: 40] C[Sliding Window
12:00:30-12:01:29
Weight prev: 50%
Weight curr: 50%] D[Estimate:
80 Ã 0.5 + 40 = 80] end A --> C B --> C C --> D
Formula
Current Time: 12:01:30 (50% through current window)
Window Size: 60 seconds
Limit: 100 requests
previous_window_count = 80
current_window_count = 40
overlap_percentage = (60 - 30) / 60 = 0.5
estimated_count = previous_window_count à overlap_percentage + current_window_count
= 80 Ã 0.5 + 40
= 40 + 40
= 80
80 < 100 â Allow request
Step-by-Step Example
| Current Time | Prev Window Count | Curr Window Count | Overlap % | Estimated Count | Allow? (limit=100) |
|---|---|---|---|---|---|
| 12:01:00 | 80 | 0 | 100% | 80 Ã 1.0 + 0 = 80 | Yes |
| 12:01:15 | 80 | 20 | 75% | 80 Ã 0.75 + 20 = 80 | Yes |
| 12:01:30 | 80 | 40 | 50% | 80 Ã 0.5 + 40 = 80 | Yes |
| 12:01:45 | 80 | 80 | 25% | 80 Ã 0.25 + 80 = 100 | No (at limit) |
| 12:02:00 | 100 | 0 | 100% | 100 Ã 1.0 + 0 = 100 | No |
Implementation Pseudocode
class SlidingWindowCounter:
def __init__(self, limit, window_size):
self.limit = limit
self.window_size = window_size
self.windows = {} # {window_id: count}
def allow_request(self):
current_time = now()
current_window = current_time // self.window_size
previous_window = current_window - 1
current_count = self.windows.get(current_window, 0)
previous_count = self.windows.get(previous_window, 0)
# Calculate position in current window (0.0 to 1.0)
elapsed_in_current = (current_time % self.window_size) / self.window_size
previous_weight = 1.0 - elapsed_in_current
estimated_count = previous_count * previous_weight + current_count
if estimated_count < self.limit:
self.windows[current_window] = current_count + 1
return True
return False
Redis Implementation
-- Store two counters: previous and current window
current_window = floor(timestamp / window_size)
previous_window = current_window - 1
current_key = "rate_limit:user123:" .. current_window
previous_key = "rate_limit:user123:" .. previous_window
current_count = GET current_key OR 0
previous_count = GET previous_key OR 0
elapsed_in_current = (timestamp % window_size) / window_size
estimated = previous_count * (1 - elapsed_in_current) + current_count
if estimated < limit then
INCR current_key
EXPIRE current_key (window_size * 2)
return true
end
return false
Advantages & Disadvantages
| Advantages | Disadvantages |
|---|---|
| Near-perfect accuracy (small boundary issues) | Approximation can allow slightly over limit |
| Memory efficient: O(1) per key (2 counters) | Requires synchronized clocks in distributed systems |
| Fast: simple arithmetic, no log cleanup | More complex than fixed window |
| Smooths traffic, reduces boundary bursts | Edge cases at window transition moments |
Algorithm Comparison
| Algorithm | Accuracy | Memory per Key | Burst Handling | Complexity | Best Use Case |
|---|---|---|---|---|---|
| Token Bucket | Good | O(1) 2 numbers |
Excellent Immediate burst up to capacity |
Low | API rate limiting with burst tolerance |
| Leaky Bucket | Good | O(N) Queue size |
Poor Constant output only |
Medium | Traffic shaping, constant processing rate |
| Fixed Window | Poor 2x burst at boundaries |
O(1) 1 counter |
Poor Unpredictable at boundaries |
Very Low | Internal services, non-critical limits |
| Sliding Window Log | Perfect Exact timestamps |
O(N) Store all requests |
Fair True sliding enforcement |
High | Low-traffic critical APIs, audit trails |
| Sliding Window Counter | Excellent ~5% error |
O(1) 2 counters |
Good Smooth distribution |
Medium | Large-scale APIs, multi-tenant SaaS |
Performance Comparison (1M requests/sec)
| Metric | Token Bucket | Fixed Window | Sliding Window Log | Sliding Window Counter |
|---|---|---|---|---|
| Memory (1K users) | 32 KB | 16 KB | 800 MB | 32 KB |
| CPU per Check | ~50 ns | ~30 ns | ~10 Ξs | ~100 ns |
| Latency p99 | < 1 ms | < 1 ms | 5-10 ms | < 1 ms |
| Boundary Burst | Controlled by capacity | Up to 2x limit | None | < 10% over limit |
Decision Tree
| Requirement | Algorithm Choice | Reason |
|---|---|---|
| Need to allow bursts | Token Bucket | Explicit burst capacity parameter |
| Must enforce constant rate | Leaky Bucket | Guaranteed smooth output |
| Millions of users, memory constrained | Sliding Window Counter | O(1) memory with good accuracy |
| Audit requirements, legal compliance | Sliding Window Log | Perfect accuracy, full history |
| Simple internal service | Fixed Window | Easiest to implement, good enough |
| Multi-tier API (free/pro/enterprise) | Token Bucket | Different burst capacities per tier |
| Real-time traffic shaping | Leaky Bucket | Network QoS, video streaming |
Distributed Rate Limiting
In multi-server deployments, rate limiting must be coordinated to prevent over-provisioning.
Challenges
| Challenge | Description | Impact |
|---|---|---|
| Race Conditions | Multiple servers check limit simultaneously | Over-limit by up to N Ã servers |
| Synchronization Latency | Time to propagate counts across nodes | Stale data allows more requests |
| Network Partitions | Servers can't reach shared state store | Fail open (allow all) or closed (reject all)? |
| Clock Skew | Server clocks drift apart | Window boundaries misaligned |
| Bottleneck | Centralized state store becomes bottleneck | Slower than single-server rate limiting |
Redis-Based Solutions
Option 1: INCR + EXPIRE (Fixed Window)
-- Simple but has race condition between INCR and EXPIRE
key = "rate_limit:user123:" .. current_window
count = INCR key
if count == 1 then
EXPIRE key window_size
end
if count <= limit then
return true
else
return false
end
Problem: If server crashes after INCR but before EXPIRE, key never expires.
Option 2: Lua Script (Atomic)
-- Atomic execution, no race conditions
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call('GET', key)
if current == false then
redis.call('SET', key, 1, 'EX', window)
return 1
end
current = tonumber(current)
if current < limit then
redis.call('INCR', key)
return 1
else
return 0
end
Option 3: Token Bucket with Redis
-- Store tokens and last_refill_time
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
-- Refill tokens
local elapsed = now - last_refill
local tokens_to_add = elapsed * refill_rate
tokens = math.min(capacity, tokens + tokens_to_add)
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 3600)
return 1
else
return 0
end
Option 4: Sliding Window Counter with Redis
-- Most accurate distributed algorithm
local key_prefix = KEYS[1]
local limit = tonumber(ARGV[1])
local window_size = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local current_window = math.floor(now / window_size)
local previous_window = current_window - 1
local current_key = key_prefix .. ":" .. current_window
local previous_key = key_prefix .. ":" .. previous_window
local current_count = tonumber(redis.call('GET', current_key) or "0")
local previous_count = tonumber(redis.call('GET', previous_key) or "0")
local elapsed = (now % window_size) / window_size
local previous_weight = 1 - elapsed
local estimated = previous_count * previous_weight + current_count
if estimated < limit then
redis.call('INCR', current_key)
redis.call('EXPIRE', current_key, window_size * 2)
return 1
else
return 0
end
Strategies for Reducing Redis Load
| Strategy | Description | Tradeoff |
|---|---|---|
| Local Cache + Sync | Check local counter, sync to Redis periodically | Faster but less accurate (over-limit by ~10%) |
| Sticky Sessions | Route user to same server (local rate limiting) | Uneven load distribution, server failures problematic |
| Per-Server Quota | Divide limit by N servers (each enforces limit/N) | Wastes capacity if traffic uneven across servers |
| Redis Pipeline | Batch multiple checks into one round trip | Reduced latency but still hits Redis |
| Redis Cluster | Shard users across multiple Redis nodes | More infrastructure complexity |
Handling Redis Failures
| Strategy | When to Use | Consequence |
|---|---|---|
| Fail Open | Availability > rate limiting (e.g., user-facing APIs) | Allow all requests, no rate limiting |
| Fail Closed | Security > availability (e.g., payment APIs) | Reject all requests, service unavailable |
| Fallback to Local | Best of both worlds | Temporary over-limit, but service continues |
| Circuit Breaker | Frequent Redis timeouts | Stop checking Redis, fail open for N seconds |
Rate Limiting Architecture
Rate limiting can be enforced at multiple layers for defense in depth.
Prevent wasted requests| B[Load Balancer] B -->|2. L7 Load Balancer
DDoS protection| C[API Gateway] C -->|3. API Gateway
User/API key limits| D[Auth Service] C -->|3. API Gateway| E[Service A] C -->|3. API Gateway| F[Service B] D -->|4. Service-Level
Fine-grained limits| G[Database] E -->|4. Service-Level| G F -->|4. Service-Level| G G -->|5. Database
Query/connection limits| H[(Storage)] style C fill:#e1f5ff style G fill:#ffe1e1
Layer-by-Layer Breakdown
| Layer | Scope | Purpose | Limit Examples |
|---|---|---|---|
| 1. Client-Side | Per client instance | Prevent wasted requests, improve UX | 1 search request per 300ms, debounce |
| 2. Load Balancer | Per IP, global | DDoS protection, syn flood prevention | 10K req/sec per IP, connection limits |
| 3. API Gateway | Per user, per API key | Enforce subscription tiers, abuse prevention | Free: 100/hr, Pro: 10K/hr |
| 4. Application Service | Per endpoint, per operation | Protect expensive operations | 5 video encodes/min, 10 exports/day |
| 5. Database | Per query, per connection | Prevent slow queries, connection pool exhaustion | Max 1000 connections, query timeout 30s |
Multi-Tenant SaaS Architecture
100K req/sec system-wide] C -->|Pass| D[Check Tenant Limit
Tenant A: 10K req/min] D -->|Pass| E[Check User Limit
User 123: 100 req/min] E -->|Pass| F[Check Endpoint Limit
/export: 10 req/hr] F -->|Pass| G[Process Request] C -->|Fail| H[429: System Overload] D -->|Fail| I[429: Tenant Quota Exceeded] E -->|Fail| J[429: User Limit Reached] F -->|Fail| K[429: Endpoint Throttled] end
Hierarchical Rate Limiting
Apply multiple limits in order from broadest to most specific.
| Priority | Scope | Limit | Purpose |
|---|---|---|---|
| 1 | Global | 100K req/sec | System capacity limit |
| 2 | Per Tenant | 10K req/min | Prevent noisy neighbor |
| 3 | Per User | 100 req/min | Fair usage within tenant |
| 4 | Per API Endpoint | /search: 20 req/min /export: 5 req/hr |
Protect expensive operations |
| 5 | Per Resource | 10 concurrent uploads per user | Prevent resource exhaustion |
HTTP Headers & Responses
Standard headers communicate rate limit status to clients.
Common Rate Limit Headers
| Header | Description | Example Value |
|---|---|---|
X-RateLimit-Limit |
Maximum requests allowed in window | 1000 |
X-RateLimit-Remaining |
Requests left in current window | 457 |
X-RateLimit-Reset |
Unix timestamp when limit resets | 1672531200 |
X-RateLimit-Window |
Window size in seconds | 60 |
Retry-After |
Seconds to wait before retrying (in 429 response) | 42 |
Example Response Headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 457
X-RateLimit-Reset: 1672531200
Content-Type: application/json
{
"data": "..."
}
429 Too Many Requests Response
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1672531200
Retry-After: 42
Content-Type: application/json
{
"error": {
"code": "rate_limit_exceeded",
"message": "You have exceeded the rate limit of 1000 requests per hour.",
"retry_after": 42,
"limit": 1000,
"window": "1 hour",
"documentation_url": "https://api.example.com/docs/rate-limits"
}
}
Client-Side Handling
| Strategy | Implementation | When to Use |
|---|---|---|
| Exponential Backoff | Wait 1s, 2s, 4s, 8s between retries | General-purpose retry logic |
| Respect Retry-After | Read header, wait exact seconds specified | When server provides Retry-After |
| Throttle Based on Remaining | Slow down requests as Remaining â 0 | Batch processing, background jobs |
| Pre-emptive Rate Limiting | Client tracks own request count, pauses before hitting limit | SDK/library for API |
| Circuit Breaker | Stop retrying after N consecutive 429s | Prevent thundering herd |
Standard: IETF Draft
The IETF RateLimit Header Fields draft standardizes these headers:
RateLimit-Limit: Requests allowed in windowRateLimit-Remaining: Requests leftRateLimit-Reset: Seconds until reset (not Unix timestamp)
HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 45
RateLimit-Reset: 42
RateLimit-Policy: 100;w=60
Design a Rate Limiter
End-to-end design for a multi-tenant SaaS with per-user, per-API, and global limits.
Requirements
| Requirement | Details |
|---|---|
| Scale | 10M users, 100K requests/sec |
| Limits | Per-user: 100 req/min Per-API: varies by endpoint Global: 100K req/sec |
| Accuracy | Allow up to 10% over-limit acceptable |
| Latency | Rate limit check < 5ms p99 |
| Distributed | 100 API servers across 3 regions |
High-Level Architecture
Static rate limit: 1K req/sec per IP] B --> C[Load Balancer
L4/L7 DDoS protection] C --> D[API Gateway Cluster
100 servers] D --> E[Rate Limit Middleware] E --> F{Check Local Cache} F -->|Cache Hit| G{Under Limit?} F -->|Cache Miss| H[Redis Cluster
Sliding Window Counter] H --> G G -->|Yes| I[Process Request] G -->|No| J[Return 429] I --> K[Application Services] subgraph "Redis Cluster" H L[(Shard 1
Users A-F)] M[(Shard 2
Users G-L)] N[(Shard 3
Users M-Z)] end H --> L H --> M H --> N style E fill:#e1f5ff style H fill:#ffe1e1
Data Model
| Key Pattern | Data Structure | Value | TTL |
|---|---|---|---|
rl:user:{id}:{window} |
String (counter) | Request count in window | 2 Ã window size |
rl:api:{endpoint}:{window} |
String (counter) | Global endpoint count | 2 Ã window size |
rl:tenant:{id}:{window} |
String (counter) | Tenant request count | 2 Ã window size |
rl:global |
HyperLogLog | Approximate global count | 60 seconds |
Algorithm Choice
Sliding Window Counter for all limits (best balance of accuracy and performance).
Implementation Flow
function handle_request(user_id, api_endpoint):
# 1. Check global limit (fastest path)
if not check_global_limit():
return 429_response("System overload")
# 2. Check local cache (no network call)
local_count = local_cache.get(user_id)
if local_count and local_count > limit * 0.9:
# Approaching limit, check Redis
pass
elif local_count and local_count < limit * 0.5:
# Safely under limit, allow without Redis check
local_cache.incr(user_id)
return process_request()
# 3. Check Redis (accurate distributed count)
redis_result = redis_sliding_window_check(user_id, limit, window)
if not redis_result.allowed:
return 429_response(redis_result)
# 4. Check endpoint-specific limit
endpoint_result = check_endpoint_limit(api_endpoint)
if not endpoint_result.allowed:
return 429_response(endpoint_result)
# 5. Update local cache
local_cache.set(user_id, redis_result.count, ttl=60)
# 6. Process request
return process_request()
Optimizations
| Optimization | Technique | Benefit |
|---|---|---|
| Local Cache | In-memory LRU cache per API server (10K entries, 60s TTL) | Reduce Redis calls by 80% |
| Lua Scripts | Atomic sliding window counter in Lua | 1 round trip instead of 5 |
| Redis Pipelining | Batch multiple user checks in one call | Reduce latency by 50% |
| Sharding | Hash user_id to Redis shard (consistent hashing) | Horizontal scaling |
| Read Replicas | Redis read replicas for geographically distant servers | Lower cross-region latency |
| Lazy Cleanup | TTL-based expiration, no manual deletion | Avoid cleanup overhead |
Failure Modes
| Failure | Strategy | Impact |
|---|---|---|
| Redis Down | Fail open with local cache limits (higher threshold) | Temporary over-limit, but service available |
| Redis Slow | Circuit breaker after 3 timeouts, fail open for 60s | Avoid cascading failures |
| Cache Stampede | Probabilistic early expiration (jittered TTL) | Smooth cache refills |
| Clock Skew | Use NTP, accept 10% accuracy loss | Slight over-limit acceptable |
Throttling vs Rate Limiting vs Load Shedding
Related but distinct concepts for controlling system load.
| Concept | Definition | Mechanism | Example |
|---|---|---|---|
| Rate Limiting | Cap number of requests per time window | Count requests, reject after limit | 100 requests per minute per user |
| Throttling | Slow down requests to maintain steady rate | Delay processing, queue requests | Process 10 requests per second (queue the rest) |
| Load Shedding | Drop requests when system is overloaded | Monitor system health, shed lowest-priority traffic | Reject 50% of non-critical requests when CPU > 80% |
| Backpressure | Signal upstream to slow down | TCP flow control, HTTP 503, async feedback | Return 503 to load balancer to stop sending traffic |
| Circuit Breaking | Stop calling failing downstream service | Detect failures, open circuit, fail fast | Stop calling payment API after 5 consecutive errors |
When to Use Each
| Scenario | Technique | Reason |
|---|---|---|
| Prevent abuse, enforce quotas | Rate Limiting | Clear per-user limits |
| Downstream can't handle bursts | Throttling | Smooth out traffic spikes |
| System under unexpected load | Load Shedding | Protect critical services |
| Cascading failure prevention | Circuit Breaking | Fail fast, don't overwhelm failing service |
| Producer faster than consumer | Backpressure | Signal upstream to slow down |
Comparison: Rate Limiting vs Throttling
| Property | Rate Limiting | Throttling |
|---|---|---|
| Response to Excess | Reject immediately (429) | Queue and delay |
| User Experience | Fail fast, retry later | Slower responses, no errors |
| Resource Usage | Low (just counting) | Higher (queue management) |
| Algorithm | Token bucket, sliding window | Leaky bucket, queue |
| Typical Use | API quotas, abuse prevention | Traffic shaping, smooth load |
Load Shedding Strategies
| Strategy | Description | Example |
|---|---|---|
| Random Shedding | Drop X% of requests randomly | Drop 30% of requests when CPU > 80% |
| Priority-Based | Preserve high-priority traffic | Keep paid users, shed free tier |
| Graceful Degradation | Disable non-essential features | Disable recommendations, keep checkout working |
| LIFO (Latest In, First Out) | Drop newest requests first | Process older requests first (fairness) |
| Adaptive | Increase shed percentage as load grows | CPU 70% â 10% shed, 80% â 30%, 90% â 50% |
Summary
- Token Bucket: Most common algorithm, allows bursts, O(1) memory. Default choice.
- Leaky Bucket: Constant output rate, use when downstream can't handle bursts.
- Fixed Window: Simple but allows 2x burst at boundaries. Avoid for strict limits.
- Sliding Window Log: Perfect accuracy but high memory (O(N)). Use for low-traffic critical APIs.
- Sliding Window Counter: Best balance of accuracy and efficiency. Production-ready for large scale.
- Distributed Rate Limiting: Use Redis with Lua scripts for atomic operations. Accept 10% over-limit for performance.
- Multi-Layer Defense: Apply rate limits at client, load balancer, API gateway, and service layers.
- HTTP Headers: Always return X-RateLimit-* headers. Use 429 status with Retry-After.
- Failure Modes: Fail open (availability) or fail closed (security). Circuit breakers prevent cascading failures.
- Rate Limiting â Throttling â Load Shedding: Rate limiting caps usage, throttling delays processing, load shedding drops low-priority traffic.