Designing for Scale · Distributed Rate Limiter
How to stop abuse without stopping legitimate traffic. An engineering breakdown of Token Bucket, Leaky Bucket, Sliding Window algorithms, and how to execute them across a fleet of servers without locking Redis.
The problem this post solves
A single server can easily restrict a user to ten requests per second by keeping a counter in memory. When you have a hundred servers behind a load balancer, and a user's requests are spread randomly across them, local memory is blind.
To enforce a global limit, the servers must share state. But if every API request requires a synchronous network call to update a shared counter, the rate limiter becomes a latency bottleneck that is more fragile than the API it is trying to protect.
This post covers how to choose the right limiting algorithm, and how to implement it across a distributed fleet without crushing the cache tier under lock contention.
First principles
- 1IP addresses are not people.forced by · Entire corporate campuses, universities, and mobile carrier networks often share a single public IP via NAT.
- 2If you rate limit purely by IP, you block innocent bystanders.forced by · One bad actor on a university Wi-Fi network will consume the quota for every student.
- 3Identities cross IPs constantly.forced by · A malicious user can rotate through thousands of proxy IPs to bypass an IP block.
The Algorithms
Before deciding where the data lives, you must decide how the math works. The four standard algorithms serve different traffic shapes.
Token Bucket
You have a bucket that holds a maximum of tokens. Every seconds, a refiller adds tokens to the bucket. Every request takes 1 token. If the bucket is empty, the request is dropped.
Leaky Bucket
Requests enter a queue (the bucket). The server processes requests from the queue at a strict, constant rate. If the queue is full, new requests overflow and are dropped. Pick when: You are protecting a legacy backend that will literally fall over if it receives more than exactly 50 requests per second. It smooths out bursts into a steady trickle.
Fixed Window Counter
Time is divided into fixed windows (e.g., 12:00:00 to 12:01:00). A counter increments for each request. At the minute mark, the counter resets to zero. The flaw: A user can exhaust their entire minute quota at 12:00:59, and then exhaust the next minute's quota at 12:01:01. The server receives twice the permitted load in a two-second burst at the boundary.
Sliding Window Log
Instead of a simple counter, keep a timestamp for every request the user makes. When a new request arrives, delete all timestamps older than the window, then count what's left. The flaw: It requires storing potentially thousands of timestamps per user in memory. It is perfectly accurate but memory-heavy.
- If limit is 100/min. Previous minute had 80 requests. Current minute has 20 requests.
- If we are 30 seconds (50%) into the current minute, we assume 50% of the previous minute's requests fall into the sliding window.
- Calculated load: (80 * 0.5) + 20 = 60 requests.
Architecture: The Distributed Counter
We assume Token Bucket. The state must live in a centralized, fast, in-memory store like Redis.
The Naive Implementation (Race Conditions)
GETtokens for User A. (Returns 5)- If tokens > 0, decrement by 1.
SETtokens to 4 for User A.
If two API servers process a request for User A at the exact same millisecond, they both GET 5, and they both SET 4. One token was stolen.
The Lock Implementation (Slow)
- Acquire distributed lock for User A.
GETtokens, decrement,SETtokens.- Release lock.
This solves the race condition but ruins performance. Redis spends all its time managing locks instead of serving counters.
The Scaled Implementation (Lua Scripts)
Redis is single-threaded. If you send it a short script, it will execute the entire script atomically. No other command can interrupt it.
The Lua script reads the current tokens, calculates the refill based on the current timestamp, decrements the token, saves the state, and returns the result in one single network hop, completely eliminating race conditions without locks.
Where should the Rate Limiter live?
For scale, the standard pattern is a hybrid. The API Gateway handles brute-force IP limiting (e.g., maximum 500 req/sec per IP) using local memory. The application middleware handles identity-based business limits (e.g., 10 API calls per minute for a free tier user) backed by Redis Lua scripts.
Failure modes
Failing closed. If the Redis instance holding the counters goes down, what happens? If the rate limiter fails closed, it blocks all traffic. The system goes down. A rate limiter should almost always fail open—if it cannot reach Redis, it allows the request through. It is better to temporarily serve too much traffic than to voluntarily drop all revenue.
Hardcoded limits. Baking "100 per minute" into the codebase requires a deployment to mitigate an attack. Limits must be dynamically configurable at runtime.
Silent drops. If a client hits the limit, they need to know why and when they can try again. Always return HTTP 429 Too Many Requests and include X-RateLimit-Reset headers.
What to carry forward
- Explain why IP-based limiting is insufficient for application security.
- Choose between Token Bucket and Leaky Bucket based on traffic smoothing requirements.
- Solve the read-modify-write race condition in distributed counters without locking.
- Design a fallback strategy that preserves availability when the limiting infrastructure fails.