Production-Grade Distributed Rate Limiters: Architecture, Algorithms, and Failure Modes
Introduction
Every second, a Fortune 500 API gateway makes a critical decision: should this request proceed or be rejected? Multiply that decision by millions of users across dozens of data centers, and you have one of distributed systems' most deceptively complex challenges. A rate limiter must be fast (sub-millisecond overhead), accurate (no user should bypass their quota), and resilient (failures shouldn't bring down your API). Yet these goals fundamentally conflict when you eliminate the central bottleneck.
This post dissects the architecture of production-grade distributed rate limiters, the kind that protect Stripe's payment APIs or GitHub's REST endpoints from abuse while serving legitimate traffic at scale. We'll compare algorithm trade-offs with concrete memory and accuracy numbers, examine how counters synchronize across regions without coordination overhead, solve the hot-key problem that can melt a Redis cluster, and walk through failure modes that have taken down real services.
Whether you're designing a multi-tenant SaaS platform or scaling an API gateway to millions of requests per second, you'll leave with a decision framework grounded in production data, not theory.

Rate Limiting Algorithms: Trade-offs in Accuracy, Memory, and Burst Handling
Before distributing anything, we need an algorithm that balances three constraints: memory efficiency (bytes per user), accuracy (preventing quota bypass), and burst tolerance (allowing legitimate traffic spikes). The choice cascades through every architectural decision that follows.

Token Bucket: The Production Workhorse
Token bucket dominates production systems because it elegantly handles bursts while maintaining average rate limits. The model is simple: a bucket holds up to capacity tokens, refills at rate tokens per second, and each request consumes one token. If the bucket is empty, reject the request.
Memory footprint: 16 bytes per key (8-byte timestamp + 8-byte float for tokens). For one million active users, that's 16 MB, easily cacheable in application memory or Redis.
Implementation (Python, single-threaded; production code requires locks or atomic operations):
import time
class TokenBucket:
def __init__(self, capacity: int, rate: float):
self.capacity = capacity
self.rate = rate
self.tokens = float(capacity)
self.last_refill = time.time()
def allow(self) -> bool:
now = time.time()
elapsed = now - self.last_refill
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
self.last_refill = now
if self.tokens >= 1.0:
self.tokens -= 1.0
return True
return False
Burst behavior: With capacity=100 and rate=10/sec, a user can send 100 requests instantly (draining the bucket), then sustain 10 req/sec indefinitely. This matches real-world usage: a mobile app syncing after being offline, or a batch job catching up. Fixed-window algorithms reject these legitimate bursts.
Weakness: Explaining token bucket to non-technical stakeholders is harder than "100 requests per minute." The abstraction leaks when users ask, "Why was I rate limited when I only sent 80 requests this minute?" (Answer: you sent 150 in the previous 30 seconds.)
Sliding Window Counter: Approximation with Acceptable Error
Sliding window counter approximates a true sliding window using two fixed-window counters, dramatically reducing memory compared to storing individual timestamps. For a 60-second window, maintain counters for the current minute and previous minute.
At timestamp T=61s (1 second into the new minute), estimate the count as:
count ≈ current_window + (previous_window × overlap_ratio) count ≈ current_window + (previous_window × (60 - 1) / 60)
The boundary problem: This approximation breaks at window edges. Suppose a limit of 100 req/min:
- User sends 100 requests at
T=59s(end of minute 0) - User sends 100 requests at
T=60s(start of minute 1) - At
T=60s, the algorithm sees:100 + (100 × 0/60) = 100(allowed) - Result: 200 requests in 1 second, 2× the intended rate
The worst case occurs precisely at the boundary (T=60s), not mid-window. As time progresses into the new window, the overlap ratio shrinks and the previous window's weight decreases, making violations less severe.
Memory: 16 bytes per key (two 8-byte counters). Identical to token bucket, but the semantics are easier to explain to product teams.
When to use: Choose sliding window counter when business requirements demand "X requests per time window" semantics and you can tolerate up to 2× burst at window boundaries. Many APIs (Twitter's 300 requests/15-min limit, for example) accept this trade-off for implementation simplicity.
Fixed Window: Simple but Flawed
Fixed window resets counters at fixed intervals (every minute at :00 seconds). It's trivial to implement (increment a counter, check against limit, reset at interval) but suffers from the same boundary problem as sliding window counter, except without the smoothing approximation. A user can send 100 requests at T=59s and 100 at T=60s, achieving 200 requests in one second for a "100 req/min" limit.
When to use: Internal rate limiting where you control the clients and can space requests evenly, or when rate limits are conservative enough (set to 50% of actual capacity) that 2× bursts are acceptable.
Comparison Table
| Algorithm | Memory/Key | Burst Handling | Accuracy | Complexity |
|---|---|---|---|---|
| Token Bucket | 16 bytes | Configurable (capacity parameter) | Exact | Medium |
| Sliding Window Counter | 16 bytes | Limited (2× at boundaries) | ~Approximate | Low |
| Fixed Window | 8 bytes | Uncontrolled (2× at boundaries) | Poor | Very Low |
| Sliding Window Log | 8N bytes (N=requests in window) | None | Exact | High |
Sliding window log (storing timestamps of every request) provides perfect accuracy but consumes 8 bytes per request. For a 100 req/min limit, that's 800 bytes per active user, 50× more than token bucket. It's used only when accuracy is paramount and request rates are low (e.g., authentication attempts: 5 per minute).
Distributed Counter Storage: Consistency vs. Latency
Algorithms establish the rules; storage systems enforce them across data centers. The central tension: strong consistency (accurate limits) requires coordination (latency and availability cost), while eventual consistency (fast and available) allows temporary quota violations.

Redis: The Default Choice for Centralized Limits
Redis dominates rate limiting implementations because atomic operations (INCR, EXPIRE) and Lua scripting provide exactly-once counter updates with sub-millisecond latency. A single Redis instance handles 100,000 operations per second on commodity hardware, sufficient for many mid-scale APIs.
Token bucket in Redis (Lua script for atomicity):
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local 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
local elapsed = now - last_refill
tokens = math.min(capacity, tokens + elapsed * rate)
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 3600)
return 1
else
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 3600)
return 0
end
Note: Production code must handle script errors, Redis unavailability (fail-open vs. fail-closed policy), and key expiration edge cases.
Scaling bottleneck: A single Redis instance becomes the bottleneck at ~100K requests/sec. Sharding by user ID distributes load, but "hot" users (a popular API client, or an attacker) still concentrate on one shard. We'll address this in the hot-key section.
Multi-region challenge: Redis replication is asynchronous. With instances in us-east-1 and eu-west-1, a user can consume their quota in the US, and the EU instance won't know for 10-500ms (typical cross-region replication lag). For a 100 req/min limit, that's 1-8 extra requests the EU instance might allow during lag. If this is unacceptable, you need synchronous replication (at 50-150ms cross-region latency cost per write) or a different architecture.
In-Memory Counters: Edge Rate Limiting
For ultra-low latency (<0.1ms overhead), store counters in application memory on each API gateway instance. This is "edge" rate limiting: limits are enforced locally, without network calls.
Go implementation with concurrent access:
type RateLimiter struct {
buckets sync.Map // key: userID, value: *TokenBucket
capacity int
rate float64
}
func (rl *RateLimiter) Allow(userID string) bool {
val, _ := rl.buckets.LoadOrStore(userID, NewTokenBucket(rl.capacity, rl.rate))
bucket := val.(*TokenBucket)
return bucket.Allow() // TokenBucket.Allow() must use atomic operations
}
Note: sync.Map is optimized for read-heavy workloads with stable keys. For write-heavy or frequently-changing keys, a sync.RWMutex protecting a regular map may perform better. Benchmark your specific access pattern.
The consistency problem: With 10 gateway instances, each enforcing a 100 req/min limit locally, a user can send 1,000 req/min total (100 to each instance). This is only acceptable when:
- Load balancers distribute traffic evenly (sticky sessions break this)
- Limits are conservative (set to 1/N of desired limit, where N is instance count)
- You're rate limiting for cost control, not security (attackers will exploit the N× multiplier)
For security-critical limits (login attempts, payment API calls), you must synchronize across instances.
Multi-Region Synchronization: CAP Theorem in Practice
Global APIs need rate limiting that works across regions without forcing all traffic through a single data center. The CAP theorem constraint is unavoidable: you cannot have strong consistency, availability, and partition tolerance simultaneously. Rate limiting is inherently a CP problem (consistency and partition tolerance), but production systems often choose AP (availability and partition tolerance) with bounded inconsistency.

Gossip Protocols: Eventual Consistency with Bounded Drift
Gossip protocols (popularized by Cassandra and Consul) allow nodes to share counter state without central coordination. Each gateway instance maintains local counters and periodically exchanges updates with peers.
Architecture:
- Each gateway instance maintains token buckets in memory
- Every 100ms, instance A selects 3 random peers and sends counter deltas:
{"user_123": -5, "user_456": -12}(tokens consumed since last gossip) - Peers merge deltas into their local state
- Convergence time: O(log N) gossip rounds to reach all N nodes
Bounded inconsistency: With 100ms gossip intervals and 10 gateway instances, a user's counter can be up to ~1 second stale (worst case: just after gossip, request hits an instance that hasn't received updates yet, waits for next gossip round). For a 100 req/min limit (1.67 req/sec), that's ~1-2 extra requests maximum per user during high load.
Trade-off: Gossip adds CPU overhead (serializing/deserializing state, network I/O) and memory (tracking deltas between gossip rounds). It's best for coarse-grained limits (thousands of requests per hour) where 1-2 request drift is negligible.
Centralized with Regional Caching: Hybrid Approach
Most production systems use a hybrid: centralized Redis for source of truth, with short-TTL caches on gateway instances.
Flow:
- Request arrives at gateway in
eu-west-1 - Check local cache (1-second TTL): if counter exists and not expired, use it
- Cache miss: query Redis in
eu-west-1(1-2ms latency) - Redis miss or need to update: increment counter in Redis, cache locally
- Return allow/deny decision
Cache hit rate: With 1-second TTL, a user making 10 req/sec achieves ~90% cache hit rate (first request in each second misses, next 9 hit). This reduces Redis load by 10× while keeping counters reasonably synchronized.
Inconsistency window: The 1-second cache TTL means counters can drift by up to 1 second × request rate. For 10 req/sec, that's 10 requests of potential overage. Tune TTL based on your accuracy requirements: 100ms TTL gives 1 request drift, 5-second TTL gives 50 requests drift.
Regional Redis with asynchronous replication:
- Primary Redis in each region (
us-east-1,eu-west-1,ap-south-1) - Gateways in a region talk to local Redis (low latency)
- Redis instances replicate asynchronously (50-200ms lag)
- Accept that a user can consume 1× quota per region during replication lag
For a 1,000 req/hour limit across 3 regions, worst case is 3,000 requests if a user hits all regions simultaneously before replication catches up. This is acceptable for cost-control limits, but not for security limits (authentication, payment).
When you need strong consistency: Use a distributed counter service like Google's Chubby or Apache ZooKeeper, accepting 50-150ms cross-region latency per request. This is rare; most APIs choose availability and bounded inconsistency over strong consistency.

Hot-Key Sharding: Handling Skewed Traffic
The Pareto principle applies to API traffic: 20% of users generate 80% of requests. A single popular user (or attacker) can overwhelm a Redis shard, creating a "hot key" problem. Standard sharding by user ID doesn't help because all requests for user_123 still hit one shard.
The Problem: Quantified
Suppose a Redis instance handles 100,000 ops/sec across 100,000 users (average 1 req/sec per user). If one user suddenly generates 50,000 req/sec (a DDoS attack or a misconfigured client), that shard now handles 150,000 ops/sec and starts queueing requests. Latency spikes from 1ms to 50-100ms, affecting all 100,000 users on that shard.
Solution 1: Probabilistic Splitting
Instead of one counter per user, maintain N counters per user (typically N=4 to 16) and randomly select one per request. Each counter enforces limit/N.
Example: For user_123 with a 1,000 req/min limit, create 4 counters: user_123:0, user_123:1, user_123:2, user_123:3, each with a 250 req/min limit. On each request, hash a random value to select a counter.
Load distribution: A hot user's 50,000 req/sec now distributes across 4 shards (12,500 req/sec each), reducing single-shard load by 4×. The more counters (higher N), the better the distribution, but with diminishing returns beyond N=16.
Accuracy trade-off: Random selection means counters won't be perfectly balanced. With N=4, one counter might see 280 requests while another sees 220 (for a 1,000 req/min total limit). The variance decreases as request volume increases (law of large numbers). For limits >1,000 requests, this is negligible.
Implementation (Redis key selection):
import random
import hashlib
def get_counter_key(user_id: str, num_shards: int = 4) -> str:
shard = random.randint(0, num_shards - 1)
return f"ratelimit:{user_id}:{shard}"
# Each counter enforces limit/num_shards
Solution 2: Adaptive Sharding Based on Observed Load
Monitor per-key request rates in real-time. When a key exceeds a threshold (e.g., 1,000 req/sec), dynamically increase its shard count.
Architecture:
- Gateway instances track request counts per user in a local sliding window (last 10 seconds)
- When
user_123exceeds 1,000 req/sec locally, publish a "hot key" event to a coordination service (Redis pub/sub, or a gossip message) - All gateway instances receive the event and increase shard count for
user_123from 4 to 16 - After traffic drops below threshold for 60 seconds, revert to 4 shards
Advantage: Normal users (99% of traffic) use 1 counter (no overhead), while hot users automatically scale to 16+ counters. This is more memory-efficient than probabilistic splitting for all users.
Complexity: Requires coordination (pub/sub or gossip), state management (tracking which users are currently "hot"), and careful tuning of thresholds to avoid flapping (repeatedly increasing/decreasing shard count).
Production example: Cloudflare's rate limiter uses adaptive sharding, reporting that it reduces hot-key incidents by 95% compared to static sharding. Their threshold is 10,000 req/sec per key, splitting into up to 64 shards for extreme cases.
Solution 3: Client-Side Backoff
Not all hot-key problems need server-side solutions. If the hot traffic is from a misconfigured client (retry loop, missing exponential backoff), the server should signal the client to slow down.
HTTP 429 with Retry-After header:
HTTP/1.1 429 Too Many Requests Retry-After: 60 X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1640000000
Well-behaved clients (AWS SDKs, Stripe's client libraries) respect Retry-After and implement exponential backoff. This prevents a single client from overwhelming the system, regardless of server-side sharding.
Graceful Degradation and Failure Modes
Rate limiters sit in the critical path of every API request. When they fail, you must choose: fail open (allow all traffic, risking overload) or fail closed (reject all traffic, causing an outage). Neither is acceptable, so production systems need graceful degradation.

Redis Failure: Fallback Strategies
Redis is a single point of failure in centralized architectures. When Redis becomes unavailable (network partition, instance failure, or overload), the rate limiter must continue functioning.
Strategy 1: Fail-open with local rate limiting
When Redis is unreachable, fall back to in-memory token buckets on each gateway instance. Each instance enforces limit/N (where N is instance count) to approximate the global limit.
def allow_request(user_id: str, limit: int) -> bool:
try:
return redis_rate_limit(user_id, limit)
except RedisConnectionError:
# Fallback: local rate limiting at limit/num_instances
return local_rate_limit(user_id, limit // NUM_GATEWAY_INSTANCES)
Degradation: If you have 10 gateway instances and traffic is evenly distributed, users can still consume ~1× their quota (each instance allows limit/10, totaling the full limit). If traffic is unevenly distributed (sticky sessions), users on lightly-loaded instances get more quota.
Strategy 2: Fail-closed with allowlist
For security-critical endpoints (authentication, payment), fail closed: reject all requests except for an allowlist of critical users (internal services, health checks).
CRITICAL_USERS = {"health_check", "internal_service"}
def allow_request(user_id: str, limit: int) -> bool:
try:
return redis_rate_limit(user_id, limit)
except RedisConnectionError:
return user_id in CRITICAL_USERS
Outage impact: This causes a user-visible outage for all non-allowlisted traffic. Use only when allowing excess traffic is worse than rejecting legitimate traffic (e.g., a payment API where overload could cause financial loss).
Strategy 3: Circuit breaker with exponential backoff
Don't hammer a failing Redis instance. After 3 consecutive failures, open the circuit: stop querying Redis for 10 seconds, then try one request (half-open state). If it succeeds, close the circuit; if it fails, wait 20 seconds (exponential backoff).
This prevents cascading failures where gateway instances saturate a struggling Redis instance with retry traffic.
Clock Skew: The Silent Killer
Distributed rate limiting relies on synchronized clocks. NTP keeps clocks within ±100ms on well-configured servers, but misconfigurations or NTP failures can cause skew of seconds or minutes.
Impact on token bucket: If a gateway instance's clock is 10 seconds fast, it refills tokens 10 seconds early. A 100 req/min limit becomes 110-120 req/min. If the clock is 10 seconds slow, tokens refill late, and users are incorrectly rate-limited.
Impact on fixed/sliding windows: A 60-second window becomes 50 or 70 seconds if clocks drift by 10 seconds. At window boundaries, this amplifies the 2× burst problem to 2.2× or more.
Mitigation:
- Monitor clock skew across instances (alert if drift >100ms)
- Use a centralized time source (AWS Time Sync Service, Google's Public NTP)
- Design algorithms to tolerate small skew: token bucket is more resilient than fixed windows because refill rates are continuous, not boundary-dependent
Network Partitions: CAP Theorem Strikes Back
When a network partition splits your gateway instances into two groups, each group must decide independently whether to allow requests. This is the CAP theorem's partition tolerance requirement.
Scenario: You have gateway instances in us-east-1 and eu-west-1, with Redis in each region replicating to the other. A transatlantic cable cut partitions the regions.
Outcome with asynchronous replication:
- Both regions continue serving traffic (availability)
- Each region enforces limits based on its local Redis (partition tolerance)
- A user can consume 2× quota (1× in each region) during the partition (lost consistency)
Outcome with synchronous replication:
- Writes require acknowledgment from both regions
- During partition, writes fail in both regions (lost availability)
- Reads can continue with stale data, but you can't update counters (users exhaust quotas and can't make more requests)
Production choice: Almost all systems choose availability (asynchronous replication) and accept temporary quota violations during partitions. Partitions are rare (hours per year) and brief (minutes to hours), so the inconsistency window is small.
Memory Exhaustion: Unbounded User Growth
If you store counters for every user who makes a request, an attacker can exhaust memory by generating requests with millions of unique user IDs.
Attack: Send requests with User-ID: random_uuid() in a loop. Each request creates a new token bucket (16 bytes). At 1 million requests/sec, that's 16 MB/sec, or 1 GB in 60 seconds.
Defense 1: Lazy expiration with TTL
Set a TTL on counters (e.g., 1 hour). Redis automatically evicts expired keys, bounding memory usage. For 1 million active users per hour and 16 bytes per user, that's 16 MB, well within limits.
Defense 2: LRU eviction
Configure Redis with maxmemory and maxmemory-policy allkeys-lru. When memory is full, Redis evicts the least recently used keys. This sacrifices accuracy (evicted users get a "free" reset of their quota) for availability (the system doesn't crash).
Defense 3: Pre-authentication rate limiting
Rate limit by IP address or other pre-authentication ident
