Production-Grade Distributed Rate Limiters: Architecture, Algorithms, and Failure Modes

    18 min read
    distributed systems
    rate limiting
    system design
    redis
    scalability

    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.

    High level architecture of a distributed rate limiter showing clients reaching a global load balancer, an API gateway that enforces limits by checking a rate limiter core against a Redis counter store and a policy database before forwarding allowed requests upstream or returning a 429 rejection.

    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.

    Decision flow comparing token bucket and sliding window algorithms, where an incoming request is routed to either a burst tolerant token bucket or a precise sliding window log, both backed by an atomic Redis script that either allows the request upstream or rejects it with a 429.

    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

    AlgorithmMemory/KeyBurst HandlingAccuracyComplexity
    Token Bucket16 bytesConfigurable (capacity parameter)ExactMedium
    Sliding Window Counter16 bytesLimited (2× at boundaries)~ApproximateLow
    Fixed Window8 bytesUncontrolled (2× at boundaries)PoorVery Low
    Sliding Window Log8N bytes (N=requests in window)NoneExactHigh

    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.

    Distributed counter storage showing multiple API gateway nodes routing counter operations through a consistent hash ring to sharded Redis instances, with node local approximate counters that pre aggregate updates and periodically flush them to reduce load on the shards.

    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:

    1. Load balancers distribute traffic evenly (sticky sessions break this)
    2. Limits are conservative (set to 1/N of desired limit, where N is instance count)
    3. 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.

    Globally scalable rate limiter architecture where GeoDNS routes clients to the nearest regional API gateway, each region keeps local Redis counters, regional counters replicate deltas through a cross region sync stream, and a policy control plane pushes limit definitions to every gateway.

    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:

    1. Each gateway instance maintains token buckets in memory
    2. Every 100ms, instance A selects 3 random peers and sends counter deltas: {"user_123": -5, "user_456": -12} (tokens consumed since last gossip)
    3. Peers merge deltas into their local state
    4. 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:

    1. Request arrives at gateway in eu-west-1
    2. Check local cache (1-second TTL): if counter exists and not expired, use it
    3. Cache miss: query Redis in eu-west-1 (1-2ms latency)
    4. Redis miss or need to update: increment counter in Redis, cache locally
    5. 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.

    Multi region synchronization where the US and EU gateways update their local Redis counters, each region publishes increments to a Kafka delta log, and a global reconciler consumes the log to merge a global count back into both regional Redis instances.

    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:

    1. Gateway instances track request counts per user in a local sliding window (last 10 seconds)
    2. When user_123 exceeds 1,000 req/sec locally, publish a "hot key" event to a coordination service (Redis pub/sub, or a gossip message)
    3. All gateway instances receive the event and increase shard count for user_123 from 4 to 16
    4. 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.

    Graceful degradation flow where the API gateway checks the primary Redis counter, a health monitor detects when Redis is down, and the gateway switches to a node local fallback counter that applies an approximate limit and fails open to keep serving upstream traffic.

    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:

    1. Monitor clock skew across instances (alert if drift >100ms)
    2. Use a centralized time source (AWS Time Sync Service, Google's Public NTP)
    3. 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

    Structured data for LLMs, AI agents, and automated crawlers is available at/blog/system-design-distributed-rate-limiter.md. Please reviewrobots.txt andllms.txt before crawling. All referenced data must be credited to roundz.ai with a link tohttps://roundz.ai