Search Tech Journey

Find topics, journeys and posts

back to blog
systemsadvanced 25m read

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

From first principles
Start with the question
Why not just block IPs at the load balancer?
  1. 1
    IP addresses are not people.
    forced by · Entire corporate campuses, universities, and mobile carrier networks often share a single public IP via NAT.
  2. 2
    If 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.
  3. 3
    Identities cross IPs constantly.
    forced by · A malicious user can rotate through thousands of proxy IPs to bypass an IP block.
⇒ Therefore
While IP limiting is necessary for massive DDoS defense at the edge, application-level rate limiting must key on the user identity or API token.

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 BB tokens. Every TT seconds, a refiller adds RR tokens to the bucket. Every request takes 1 token. If the bucket is empty, the request is dropped.

The tradeoff
When to use Token Bucket
Token Bucket
+ you gain Allows bursts of traffic. If the bucket is full, a user can fire $B$ requests instantly.
− you pay Requires tracking two variables per user: token count and last refill timestamp.
pick when Default choice for public APIs. Users expect to be able to burst occasionally.

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.

Mental modelSliding Window Counter (The Hybrid)
Combines Fixed Window's memory efficiency with Sliding Window's boundary-smoothing. Look at the previous fixed window's count and the current fixed window's count. Blend them based on how far into the current window we are.
  • 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.
🔔 Fires when you see
Use when you need high accuracy but cannot afford to store thousands of individual timestamps.

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)

  1. GET tokens for User A. (Returns 5)
  2. If tokens > 0, decrement by 1.
  3. SET tokens 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)

  1. Acquire distributed lock for User A.
  2. GET tokens, decrement, SET tokens.
  3. 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?

The tradeoff
Where to enforce the limit
At the API Gateway / Load Balancer
+ you gain Drops bad traffic before it ever touches application code. Simplifies backend services.
− you pay Configuration can be rigid. Difficult to rate limit based on complex business logic (e.g., 'Free users get 10/sec, Pro users get 100/sec').
pick when Default for basic IP and simple token limiting.
As Middleware in the Application
+ you gain Full access to database state. Can apply limits based on subscription tier, remaining credits, or specific query cost.
− you pay Consumes application CPU and database connections just to reject a request.
pick when When limits are deeply tied to business rules and billing.

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

Key points
    You can now
    • 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.