System design deep dive
Rate Limiting: deep dive
Rate limiting caps how fast any one client can call your system, protecting it from abuse, runaway scripts, and noisy neighbours while keeping the service fair for everyone. In interviews, the depth comes from choosing an algorithm and making it correct across a fleet of servers.
How a distributed rate limiter works, step by step
- A request arrives at the gateway or service middleware. The limiter derives a key: API key for authenticated traffic, IP address for anonymous traffic, plus the route, for example
rl:key_abc:/search. - It looks up the rule for that key, such as 10 requests per second with a burst of 20.
- It performs an atomic check-and-update in Redis using a Lua script, so two gateway instances cannot both spend the last token.
- If allowed, the request proceeds and the response includes headers like
X-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Reset. - If denied, the limiter returns
429 Too Many Requestswith aRetry-Afterheader, and logs the event for abuse monitoring. - If Redis itself is unreachable, the limiter follows a configured policy: usually fail open (allow traffic) for public APIs, or fall back to a coarse local limit.
Algorithms compared
| Algorithm | State per key | Burst behaviour | Notes |
|---|---|---|---|
| Fixed window counter | 1 counter | Up to 2x limit across a window boundary | Cheapest; INCR plus EXPIRE |
| Sliding window log | Timestamp of every request | Exact | Memory grows with the limit; costly at high rates |
| Sliding window counter | 2 counters | Close to exact | Weights previous window by overlap; good default |
| Token bucket | Tokens plus last refill time | Allows bursts up to bucket size | Used by AWS and Stripe style APIs |
| Leaky bucket | Queue or level plus time | Smooths output to a constant rate | Good for protecting a fragile downstream |
class TokenBucket {
private final double capacity; // max burst, e.g. 20
private final double refillPerSec; // steady rate, e.g. 10
private double tokens;
private long lastRefillNanos;
TokenBucket(double capacity, double refillPerSec) {
this.capacity = capacity;
this.refillPerSec = refillPerSec;
this.tokens = capacity;
this.lastRefillNanos = System.nanoTime();
}
synchronized boolean tryAcquire() {
long now = System.nanoTime();
double elapsedSec = (now - lastRefillNanos) / 1e9;
tokens = Math.min(capacity, tokens + elapsedSec * refillPerSec);
lastRefillNanos = now;
if (tokens >= 1) {
tokens -= 1;
return true;
}
return false;
}
}The distributed version stores tokens and lastRefill in a Redis hash and runs the same arithmetic inside a Lua script, which Redis executes atomically. Note that the bucket is refilled lazily on each request rather than by a background timer, so idle keys cost nothing but memory, and a TTL cleans them up.
When to rate limit (and when something else fits better)
Rate limit public APIs, login and OTP endpoints (to slow credential stuffing), expensive operations such as search or report generation, and outbound calls to third parties that enforce their own quotas. Rate limiting is not a substitute for capacity planning or autoscaling, and it is a blunt tool against distributed attacks from millions of IPs, where edge DDoS protection does better. For internal overload protection, load shedding based on server health (reject when queue depth or CPU is too high) often works better than fixed per-client numbers.
Common mistakes in interviews
- Keeping counters in each server's memory behind a load balancer, which multiplies the effective limit by the number of servers.
- Using a non-atomic read-then-write on Redis, creating a race that lets bursts through.
- Not deciding what happens when the limiter's store is down.
- Limiting only by IP, which punishes users behind a shared NAT or corporate proxy and is trivially bypassed by attackers with many IPs.
- Forgetting to tell clients when to retry, which causes aggressive retry loops that make overload worse.
Interview questions
›Why does the fixed window algorithm allow double the limit?
With a limit of 100 per minute, a client can send 100 requests at 12:00:59 and another 100 at 12:01:00, since each falls in a different window. That is 200 requests in two seconds. Sliding windows or token buckets avoid this edge effect.
›Where should the rate limiter live?
Usually at the API gateway or edge so rejected traffic never reaches services, using a shared Redis cluster for counters. Services can add finer limits of their own for expensive operations. Client SDKs can also self-throttle, but they cannot be trusted for enforcement.
›How do you rate limit across multiple data centers?
A single global counter adds cross-region latency to every request. Common approaches are to split the quota per region, or to count locally and asynchronously sync totals between regions, accepting a small overshoot in exchange for low latency.
›Token bucket or leaky bucket?
Token bucket lets clients burst up to the bucket size and then sustain the refill rate, which matches how real users behave. Leaky bucket enforces a smooth constant outflow, which is better when the downstream, such as a legacy system or SMS provider, cannot handle bursts at all.
›Should over-limit requests be dropped or queued?
For interactive APIs, reject quickly with 429 so clients can back off; queuing just turns a fast error into a slow timeout. For background work like webhooks or bulk imports, queuing the excess and processing at the allowed rate is friendlier and loses nothing.
