
Your pager goes off at 2 AM. One million API calls per second are hammering your service. You stumble out of bed, check the logs, and realize a “clever” client discovered they can send 200 requests in two seconds by exploiting the boundary between your fixed windows. You regret not implementing sliding windows. This is the reality of distributed rate limiting: the algorithm you choose doesn’t just affect fairness, it determines whether you sleep through the night.
The Algorithm Reality Check
Every rate limiting algorithm encodes a different model of time, fairness, and burst tolerance. Choose wrong, and you’re not just being unfair, you’re creating exploitable boundaries that attackers will weaponize.
Fixed Window is the naive approach that will betray you at midnight. It divides time into discrete blocks (e.g., 60-second windows). A client can send 100 requests at 12:00:59 and another 100 at 12:01:00, effectively doubling their allowed rate within a two-second window. This isn’t theoretical, it’s how brute-force attacks bypass login rate limits. Fixed window is computationally efficient (just a counter and a timestamp), but the boundary amplification makes it unsuitable for public-facing APIs unless you enjoy explaining to your CEO why the “100 requests per minute” limit allowed 10,000 requests during the Black Friday sale.
Sliding Window Log fixes the boundary problem by storing timestamps for every request, providing perfect fairness. At any moment, the system evaluates exactly the last N seconds of traffic. But memory usage grows linearly with request volume. During a DDoS attack, your rate limiter becomes a memory bomb as it stores millions of timestamps just to reject them. High-throughput endpoints using sliding logs can experience memory amplification that crashes the Redis node before the application server feels the load.
Sliding Window Counter offers a practical compromise. Instead of storing every timestamp, it blends counters from the current and previous window based on elapsed time, approximating sliding behavior with far lower memory cost. The accuracy is slightly reduced (you’re estimating rather than counting), but boundary amplification is dramatically minimized. For large-scale distributed systems where memory usage and coordination overhead matter, this is often the only viable alternative to sliding logs.
Token Bucket accumulates capacity over time, allowing controlled bursts. Each identity has a bucket with a maximum capacity and a refill rate. If a client is idle, tokens accumulate up to the cap, then get spent in bursts. This matches real API usage patterns, developers want to batch requests occasionally, not drip them evenly. For most public APIs, token bucket is the strongest default: it enforces a predictable long-term rate while allowing short bursts. The failure mode? It doesn’t provide strict fairness where recent history must be exact, and it won’t smooth traffic perfectly for downstream protection.
Leaky Bucket is the misunderstood sibling often confused with token bucket. It doesn’t allow bursts, it smooths traffic to a constant rate using a queue that drains at a steady pace. If the queue fills, requests get rejected. This is useful for traffic shaping and protecting downstream services from retry storms, but terrible for user-facing APIs where occasional bursts are expected. If token bucket is a savings account you can withdraw from in bulk, leaky bucket is a bureaucrat metering out forms at exactly one per second, no exceptions.

The Distributed Consistency Trap
Single-server rate limiting is straightforward. Distributed rate limiting is a consistency problem disguised as a throttling mechanism.
The Split Brain Problem emerges when you deploy your rate limiter across 10 servers with a limit of 10 requests per user. Without shared state, each node maintains its own counter. A user can make 10 requests to Server 1, then 10 more to Server 2, and so on, effectively getting 100 requests instead of 10 because the bouncers aren’t talking to each other. This is the “split brain” problem, and it breaks naive in-memory implementations that seem fine during testing but collapse under real traffic patterns.
To solve this, you need a shared brain. Redis is the de facto choice, fast, in-memory, supports atomic operations via Lua scripts. But Redis introduces its own failure modes that the textbooks gloss over:
- Redis Hot Keys: When every request checks the same counter (say, a popular API key or a shared service account), that key becomes a hot spot. At millions of requests per second, your Redis node melts under the read/write pressure, creating a single point of failure that takes down your entire rate limiting layer.
- Network Latency: Every rate check requires a network round-trip. If checking the limit takes 50ms, you’ve just doubled your API latency for every single request. Local caching helps, but introduces stale data and overage windows.
- Clock Skew: Sliding window algorithms depend on timestamp comparisons. When servers disagree about what time it is, even by milliseconds, your windows slide inconsistently, allowing bursts at exactly the wrong moments.
- Strong vs. Eventual Consistency: Strong consistency ensures accurate limits but increases latency and reduces availability during network partitions. Eventual consistency improves resilience but allows temporary overages. In multi-region deployments, this trade-off becomes existential: do you sacrifice availability for perfect enforcement, or accept that a user might get 120 requests instead of 100 during a regional partition? GitHub and Stripe choose different answers here based on their consistency requirements and business risk tolerance.
Architectural Placement: Where to Stand the Bouncer
Where you place rate limiting logic changes everything from latency characteristics to failure modes.
Client-Side is the “honor system.” You ask developers to throttle themselves locally before sending requests. This works as well as asking kids to ration their own Halloween candy, buggy clients will still DDOS you, and malicious actors will ignore the rules entirely. It’s security through obscurity, useful only as a first-line defense to reduce unnecessary network calls, but never as your actual protection.
Server-Side (in your application code) gives you control but couples policing logic with business logic. Your application server spends CPU cycles counting requests instead of serving data. Under a DDoS attack, your servers might crash just trying to reject the traffic. This approach works for small APIs but doesn’t scale to high-traffic microservices where the overhead becomes significant.
Gateway/Proxy (NGINX, Kong, AWS API Gateway) is the industry standard for microservices. Rate limiting happens at the edge, before requests hit your internal network. Netflix uses this approach, if a device malfunctions and sends 1,000 requests per second, the gateway drops them, saving the backend recommendation engines from melting down. This offloads work from your application servers and provides language-agnostic enforcement, but adds infrastructure complexity and potential cost (AWS API Gateway charges per request).
Sidecar Pattern (Istio/Envoy) brings enforcement close to the pod without code changes. The sidecar intercepts traffic, applies limits, and reports metrics. This is powerful for Kubernetes environments but requires service mesh expertise and careful tuning to avoid becoming the bottleneck.
Advanced Patterns for Real-World Chaos
Basic throttling assumes uniform traffic from uniform users. Reality involves fraud rings, CI/CD pipelines, and Black Friday traffic spikes that make static limits suicidal.
Tiered Rate Limiting implements the VIP list. Dropbox uses this approach, free users get 100 file uploads per day, while Enterprise customers get thousands. Your bouncer checks subscription tiers before applying limits. This aligns technical enforcement with business models but requires identity resolution at the edge and careful handling of tier transition moments (what happens when a user upgrades mid-window?).
Geographic Rate Limiting applies different rules based on origin. PayPal uses this to combat fraud, requests from regions with high fraud rates get stricter limits or additional verification steps, while trusted regions get standard quotas. This requires GeoIP lookups before enforcement decisions, adding latency but reducing chargebacks.
Adaptive Rate Limiting is how how big tech really survives traffic spikes. Instead of static limits, thresholds adjust based on system load. Cloudflare uses this to shed load during crises, when CPU hits 80% or error rates spike, the limiter tightens automatically, preventing cascading failures before they start. This requires integrating your rate limiter with monitoring metrics that catch hidden architectural failures, not just request counters. Static limits assume your infrastructure is healthy, adaptive limits assume Murphy’s Law is always in effect.
Whitelist/Blacklist Handling: Twilio whitelists their own CI/CD pipelines to prevent automated tests from triggering rate limits and crashing the deployment pipeline. Meanwhile, they blacklist known botnets and spam sources. This requires list management that updates faster than attackers rotate IPs, often integrating with shifting platform trust architecture away from CAPTCHA toward behavioral risk scoring.
Implementation War Stories
The gap between theory and production is filled with race conditions and retry storms.
The Lua Script Reality
Redis operations need to be atomic to prevent race conditions. Two simultaneous requests can both read “remaining: 1”, both decrement, and both pass, resulting in 2 requests when only 1 was allowed. The fix is Lua scripts that execute atomically on the Redis server:
-- Token bucket implementation in Redis Lua
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local last_update = redis.call('get', key .. ':last_update') or now
local tokens = redis.call('get', key .. ':tokens') or capacity
local time_passed = now - last_update
local new_tokens = math.min(capacity, tokens + time_passed * rate)
if new_tokens >= 1 then
redis.call('set', key .. ':tokens', new_tokens - 1)
redis.call('set', key .. ':last_update', now)
return 1 -- Allowed
else
redis.call('set', key .. ':tokens', new_tokens)
redis.call('set', key .. ':last_update', now)
return 0 -- Denied
end
This script ensures that checking availability and consuming tokens happens as a single atomic operation, eliminating the race condition that plagues naive read-then-write implementations.
The Retry Storm Problem
When a downstream service fails, upstream services often retry aggressively with exponential backoff. Without coordination between retry logic and rate limiting, you amplify the failure. If Service A calls Service B with 3 retries, and B is already failing under load, you’ve tripled the traffic on a struggling system. This is where production realities of the outbox pattern meet rate limiting, async retry queues need their own throttling mechanisms separate from the main API limits, or your recovery becomes a self-inflicted DDoS.
The “Riot Control” Scenario
During the Vercel next-forge implementation, engineers discovered that rate limiting at the edge must handle fan-out patterns carefully. One external request might trigger 50 internal microservice calls. If each microservice has its own rate limiter without awareness of the fan-out, legitimate requests get throttled mid-transaction, leaving data in inconsistent states. The solution is tiered enforcement: strict limits at the external edge, permissive limits (or circuit breakers) internally, with careful tracking of request chains.
Conclusion
Rate limiting in distributed systems isn’t about picking an algorithm from a textbook and calling it a day. It’s about accepting hard trade-offs: consistency versus availability, accuracy versus memory, fairness versus throughput. The token bucket is usually the right default for user-facing APIs, but only if you solve the distributed coordination problem first with atomic operations and careful Redis architecture.
For internal traffic shaping, leaky bucket prevents downstream stampedes. For strict security boundaries, sliding window log provides the accuracy you need, at a cost. And please, for the sake of your on-call rotation, retire fixed windows from your public API strategy before someone discovers they can brute-force your authentication endpoints at the window boundary.
The modern rate limiter isn’t just a counter, it’s a distributed systems problem with business logic, security implications, and infrastructure costs. Treat it as such, or prepare for more 2 AM pages.




