Design a Rate Limiter
A rate limiter enforces a ceiling on how many requests a client can make in a given time window. It protects backend services from overload, prevents abuse and DDoS amplification, and ensures fair resource distribution across tenants. The core challenge is making the check atomic, consistent across multiple API Gateway nodes, and fast enough to add no perceptible latency to the request path.
Design it yourself
Don't just read it — drag components onto a canvas and get Aria's interviewer review.
Requirements
Functional
- Reject requests that exceed a configured limit (e.g. 100 req/min per user)
- Return HTTP 429 Too Many Requests with a Retry-After header on rejection
- Support multiple limit dimensions: per-user, per-IP, per API key, and global
- Configurable limits per endpoint (e.g. /login stricter than /search)
- Clients can query remaining quota via X-RateLimit-Remaining headers
Non-Functional
- 1M requests/sec across all API Gateway nodes
- Rate limit check adds < 5ms p99 latency to the request path
- 99.99% availability — a rate limiter outage must not block legitimate traffic
- Consistent enforcement across all gateway nodes (no per-node state drift)
- Limits must be adjustable at runtime without redeployment
Capacity Estimation
| Inbound RPS | 1M req/sec across 20 gateway nodes = 50K req/sec/node |
| Redis ops per request | 1–2 commands (INCR + EXPIRE, or Lua script) |
| Redis throughput needed | 1M–2M commands/sec (Redis handles ~1M/sec single-threaded; use cluster) |
| Memory per user (token bucket) | ~100 bytes (counter + timestamp in Redis hash) |
| Memory for 10M active users | 10M × 100B ≈ 1 GB — fits in a single Redis instance |
| Latency budget | Redis round-trip ~0.5ms LAN; Lua script <1ms; well within 5ms budget |
High-Level Components
API Gateway
The first layer that receives every inbound request. Extracts the rate limit key (user ID, API key, or IP) from the request, calls the Rate Limit Service before proxying to the backend, and attaches X-RateLimit-* response headers.
Rate Limit Service
A thin sidecar or library embedded in the gateway. Implements the chosen algorithm (token bucket, sliding window counter) and delegates all state reads/writes to Redis. Stateless itself — can scale horizontally without coordination.
Redis Cluster (Counter Store)
Single source of truth for all rate limit counters and token state. Uses atomic Lua scripts or MULTI/EXEC transactions to avoid race conditions. TTLs on all keys ensure automatic cleanup of inactive clients.
Config Service
Stores limit rules (endpoint → limit → window) in a fast read path (Redis hash or etcd). Gateway nodes poll or subscribe for rule changes, allowing runtime limit adjustments without restarts.
Reject Handler
When a request is rejected, the gateway returns HTTP 429 with Retry-After (seconds until next token refill or window reset) and X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset headers. No backend call is made.
Architecture Diagram
Deep Dives
Token Bucket Algorithm
The token bucket is the most widely used rate limiting algorithm. Each client has a bucket with a maximum capacity of N tokens. Tokens are added at a constant refill rate (e.g. 10 tokens/sec). Each request consumes one token. If the bucket is empty the request is rejected.
Burst handling: Unlike a strict per-second counter, the token bucket allows short bursts up to the bucket capacity. A client that was idle for 5 seconds can send 50 requests instantly (capacity = 50). This is desirable — it matches real user behaviour.
Leaky bucket comparison: The leaky bucket processes requests at a constant outflow rate, smoothing bursts. It acts as a queue: excess requests are held (or dropped if the queue is full). Leaky bucket is better for smoothing traffic to a downstream service that cannot handle spikes. Token bucket is better for per-client quota enforcement because it is simpler to reason about and handles bursty-but-within-quota clients gracefully.
Redis implementation: Store two fields per client key: `tokens` (current count) and `last_refill` (epoch seconds). On each request, compute how many tokens to add since the last refill, cap at capacity, then subtract 1 if tokens > 0.
Java (Jedis) — Token bucket enforced atomically via Lua script
// Token Bucket in Redis via Lua script (Jedis)
// Lua executes atomically on Redis — no race conditions
private static final String TOKEN_BUCKET_SCRIPT = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2]) -- tokens per second
local now = tonumber(ARGV[3]) -- current epoch seconds
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local lastRefill = tonumber(bucket[2]) or now
-- Refill tokens proportional to elapsed time
local elapsed = math.max(0, now - lastRefill)
tokens = math.min(capacity, tokens + elapsed * refillRate)
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(capacity / refillRate) + 10)
return { allowed, math.floor(tokens) }
""";
public RateLimitResult checkTokenBucket(Jedis jedis, String clientId,
int capacity, int refillRate) {
String key = "rl:tb:" + clientId;
long now = Instant.now().getEpochSecond();
List<Object> result = (List<Object>) jedis.eval(
TOKEN_BUCKET_SCRIPT,
List.of(key),
List.of(String.valueOf(capacity),
String.valueOf(refillRate),
String.valueOf(now))
);
boolean allowed = ((Long) result.get(0)) == 1L;
long remaining = (Long) result.get(1);
return new RateLimitResult(allowed, remaining);
}Fixed Window Counter
The simplest algorithm. Divide time into fixed windows of size W seconds (e.g. 60s). Each client gets a counter for the current window. On each request: INCR the counter; if it exceeds the limit, reject. The counter expires automatically at the end of the window.
Implementation: Redis `INCR` returns the new value atomically. On the first increment, `EXPIRE` sets the window TTL. Only two commands are needed.
Boundary burst problem: Suppose the limit is 100 req/min. A client sends 100 requests at 00:59 (last second of window 1) and 100 requests at 01:01 (first second of window 2). Both windows show counts of 100 — within limit. But in the 2-second span around the boundary, the client sent 200 requests. This 2× burst at window boundaries can overwhelm backends.
Despite this flaw, fixed window is perfectly acceptable for coarse-grained limits (e.g. 1000 req/hour for an API key) where boundary bursts are operationally acceptable. Choose it when simplicity matters.
Java (Jedis) — Fixed window counter with automatic TTL cleanup
// Fixed Window Counter (Jedis)
public RateLimitResult checkFixedWindow(Jedis jedis, String clientId,
int limit, int windowSeconds) {
// Key includes the window start time, e.g. "rl:fw:user42:1710000060"
long windowStart = (Instant.now().getEpochSecond() / windowSeconds) * windowSeconds;
String key = "rl:fw:" + clientId + ":" + windowStart;
// INCR is atomic — safe across multiple gateway nodes
long count = jedis.incr(key);
if (count == 1) {
// First request in this window — set expiry
jedis.expire(key, windowSeconds + 5); // +5s buffer for clock skew
}
boolean allowed = count <= limit;
long remaining = Math.max(0, limit - count);
long resetAt = windowStart + windowSeconds;
return new RateLimitResult(allowed, remaining, resetAt);
}Sliding Window Log
The sliding window log solves the boundary burst problem by tracking the exact timestamp of each request. The "window" slides with every incoming request rather than resetting at fixed intervals.
Algorithm: Maintain a sorted set (ZSET) per client keyed by request timestamp. On each request: 1. Remove all entries older than `now - windowSeconds` (ZREMRANGEBYSCORE). 2. Count remaining entries (ZCARD). 3. If count < limit, add current timestamp (ZADD) and allow. 4. Otherwise reject.
Accuracy: Perfect — there is no boundary burst. The count always reflects exactly how many requests were made in the last W seconds.
Memory cost: Every request is stored individually. A client making 100 req/min continuously keeps 100 entries in Redis. At 10M active clients × 100 entries × 16 bytes/entry ≈ 16 GB — significantly more than the counter-based approaches. This is the primary drawback.
Use sliding window log when accuracy is critical and the request rate per client is low (e.g. an internal admin API, not a public endpoint serving millions of users).
Java (Jedis) — Sliding window log with Redis sorted set
// Sliding Window Log via Redis ZSET + Lua (atomic)
private static final String SLIDING_LOG_SCRIPT = """
local key = KEYS[1]
local now = tonumber(ARGV[1]) -- current epoch milliseconds
local window = tonumber(ARGV[2]) -- window size in milliseconds
local limit = tonumber(ARGV[3])
local cutoff = now - window
-- 1. Evict old entries
redis.call('ZREMRANGEBYSCORE', key, '-inf', cutoff)
-- 2. Count entries in window
local count = redis.call('ZCARD', key)
local allowed = 0
if count < limit then
-- 3. Log this request (score = timestamp, member = unique request ID)
redis.call('ZADD', key, now, now .. '-' .. math.random(1000000))
redis.call('PEXPIRE', key, window + 1000)
allowed = 1
count = count + 1
end
return { allowed, limit - count }
""";
public RateLimitResult checkSlidingLog(Jedis jedis, String clientId,
int limit, int windowMs) {
String key = "rl:sl:" + clientId;
long now = Instant.now().toEpochMilli();
List<Object> result = (List<Object>) jedis.eval(
SLIDING_LOG_SCRIPT,
List.of(key),
List.of(String.valueOf(now),
String.valueOf(windowMs),
String.valueOf(limit))
);
boolean allowed = ((Long) result.get(0)) == 1L;
long remaining = (Long) result.get(1);
return new RateLimitResult(allowed, remaining);
}Sliding Window Counter
The sliding window counter is a hybrid that approximates sliding window accuracy at the memory cost of fixed window counters. It is the recommended algorithm for high-scale public APIs.
Algorithm: Maintain two fixed-window counters: `prev` (the completed window before the current one) and `curr` (the active window). For a request arriving at time T within the current window:
``` overlap_ratio = (window_size - time_elapsed_in_curr_window) / window_size estimated_count = prev_count × overlap_ratio + curr_count ```
If `estimated_count < limit`, allow the request and increment `curr_count`.
Why it works: The formula assumes requests in the previous window were uniformly distributed. This is a reasonable approximation for high-frequency APIs. The error is bounded — the maximum over-estimation is small in practice.
Memory: Two counters per client (prev + curr). At 100 bytes/client × 10M clients = 1 GB. Same order of magnitude as fixed window, far less than sliding log.
Cloudflare uses this exact approach to rate limit billions of requests per day.
Java (Jedis) — Sliding window counter with weighted overlap approximation
// Sliding Window Counter — Lua script (Jedis)
private static final String SLIDING_COUNTER_SCRIPT = """
local curr_key = KEYS[1] -- current window counter key
local prev_key = KEYS[2] -- previous window counter key
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2]) -- window size in seconds
local now = tonumber(ARGV[3]) -- current epoch seconds
local window_start = math.floor(now / window) * window
local elapsed = now - window_start -- seconds into current window
local overlap = (window - elapsed) / window -- fraction of prev window still relevant
local curr_count = tonumber(redis.call('GET', curr_key)) or 0
local prev_count = tonumber(redis.call('GET', prev_key)) or 0
local estimated = prev_count * overlap + curr_count
if estimated >= limit then
return { 0, 0 } -- rejected
end
-- Allow and increment current window counter
local new_count = redis.call('INCR', curr_key)
if new_count == 1 then
redis.call('EXPIRE', curr_key, window * 2) -- survives one full extra window as "prev"
end
local remaining = math.floor(limit - estimated - 1)
return { 1, remaining }
""";
public RateLimitResult checkSlidingCounter(Jedis jedis, String clientId,
int limit, int windowSeconds) {
long now = Instant.now().getEpochSecond();
long windowStart = (now / windowSeconds) * windowSeconds;
long prevStart = windowStart - windowSeconds;
String currKey = "rl:sc:" + clientId + ":" + windowStart;
String prevKey = "rl:sc:" + clientId + ":" + prevStart;
List<Object> result = (List<Object>) jedis.eval(
SLIDING_COUNTER_SCRIPT,
List.of(currKey, prevKey),
List.of(String.valueOf(limit),
String.valueOf(windowSeconds),
String.valueOf(now))
);
boolean allowed = ((Long) result.get(0)) == 1L;
long remaining = (Long) result.get(1);
return new RateLimitResult(allowed, remaining);
}Distributed Rate Limiting & Atomicity
A rate limiter deployed across N gateway nodes must share state in Redis to enforce a global limit. The critical challenge is making the read-increment-check sequence atomic so two concurrent requests cannot both read "49 of 50" and both be allowed, driving the actual count to 51.
Naive MULTI/EXEC (optimistic locking): Use Redis WATCH to monitor the counter key. If another client modifies the key between WATCH and EXEC, the transaction aborts and must be retried. Under high contention this causes a thundering herd of retries.
Lua scripts (recommended): A Lua script executes as a single atomic unit on the Redis server. No WATCH, no retries, no race conditions. All of the algorithms in the previous sections use Lua for this reason. The script runs in a single-threaded Redis event loop — it is the standard production approach.
Redis Cluster sharding: At 1M RPS a single Redis node (~1M commands/sec) becomes the bottleneck. Shard by client ID: `hash(clientId) % num_shards`. Each shard handles a partition of client keys. Lua scripts still work — they execute on the shard that owns the key.
Availability under Redis failure: If Redis is unreachable, the rate limiter has two options: fail-open (allow all traffic, risk overload) or fail-closed (reject all traffic, block legitimate users). Most production systems fail-open with alerting, accepting a brief window of unthrottled traffic rather than causing an outage.
Local cache as fallback: Each gateway node keeps a local in-memory counter (e.g. Guava Cache) as a second line of defence. If Redis is down, the local counter enforces limit/N (where N = number of nodes). It is approximate but prevents complete exposure.
Java (Jedis) — Race condition illustration and Lua-based atomic fix
// Race condition demo: what NOT to do (non-atomic)
// Two threads can both read 49 and both increment to 50 — count exceeds limit
public boolean checkUnsafe(Jedis jedis, String key, int limit) {
long count = Long.parseLong(jedis.get(key) != null ? jedis.get(key) : "0");
if (count < limit) {
jedis.incr(key); // NOT atomic with the read above!
return true;
}
return false;
}
// Correct: MULTI/EXEC with WATCH (optimistic locking — fragile under contention)
public boolean checkWithWatch(Jedis jedis, String key, int limit) {
jedis.watch(key);
long count = Long.parseLong(jedis.get(key) != null ? jedis.get(key) : "0");
if (count >= limit) {
jedis.unwatch();
return false;
}
Transaction tx = jedis.multi();
tx.incr(key);
tx.expire(key, 60);
List<Object> result = tx.exec();
// result is null if another client modified key between WATCH and EXEC
return result != null;
}
// Best: Lua script — single atomic operation, no retries needed
private static final String ATOMIC_INCR_SCRIPT = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local count = redis.call('INCR', key)
if count == 1 then
redis.call('EXPIRE', key, window)
end
if count > limit then
return 0 -- rejected
end
return limit - count -- remaining
""";
public RateLimitResult checkAtomic(Jedis jedis, String clientId,
int limit, int windowSeconds) {
String key = "rl:fw:" + clientId + ":" +
(Instant.now().getEpochSecond() / windowSeconds);
long result = (Long) jedis.eval(
ATOMIC_INCR_SCRIPT,
List.of(key),
List.of(String.valueOf(limit), String.valueOf(windowSeconds))
);
boolean allowed = result >= 0;
long remaining = Math.max(0, result);
return new RateLimitResult(allowed, remaining);
}Key Trade-offs
Token bucket vs sliding window counter
Token bucket naturally models "quota" (a user has N credits) and allows bursts. Sliding window counter is better for protecting an endpoint from aggregate overload because it more accurately tracks request rate over time. Use both layers: token bucket per user, sliding window counter per endpoint.
Local (in-process) vs distributed (Redis) state
Local state allows each gateway node to enforce limit/N independently, which means a client can send N × limit requests by round-robining across nodes. Distributed state in Redis enforces the global limit accurately. The trade-off is one Redis round-trip (~0.5ms) added to every request. At < 5ms latency budget this is acceptable.
Strict enforcement vs approximate counting
Exact enforcement (sliding window log) requires O(limit) memory per client per window and O(limit) ZADD/ZREMRANGEBYSCORE Redis operations. At 10M active clients × 100 req/min this is 16 GB and 1B Redis write ops/min — cost-prohibitive. The sliding window counter approximation has <1% error in practice and uses O(1) memory per client.
Fail-open vs fail-closed on Redis unavailability
Fail-closed blocks all traffic — a Redis outage becomes a full API outage, which is worse than temporary unthrottled traffic. Fail-open maintains availability. Mitigate abuse risk by enabling each gateway node's local counter (enforcing limit/N) as a fallback and alerting on-call immediately.
Interview Tips
- 1Clarify the key dimension first — are limits per user, per IP, per API key, or all three? Each needs a different Redis key namespace and the answer changes the architecture.
- 2Know all four algorithms cold and be able to state the trade-off in one sentence each: fixed window (simple, boundary burst), token bucket (burst-friendly quota), sliding log (accurate, memory-heavy), sliding window counter (hybrid — Cloudflare's approach).
- 3Interviewers frequently ask "how do you make this atomic?" — answer with Lua scripts immediately. Explain that Lua executes in Redis's single-threaded event loop so no locking is needed, unlike MULTI/EXEC which has retry overhead under contention.
- 4Address the distributed consistency problem proactively: a single rate limiter embedded in one gateway node does not scale. Redis is the shared counter store; each stateless gateway node just calls Redis.
- 5Discuss the failure mode explicitly. "What happens when Redis goes down?" is a favourite follow-up. Articulate the fail-open vs fail-closed trade-off and mention local fallback counters.
- 6Mention the response headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After). This shows you understand the user-facing contract, not just the backend mechanics — interviewers appreciate the product awareness.
Discussion
Discussion
Sign in to join the discussion.