CS Fundamentals Trainer · Guides
Design a rate limiter
Where it lives, the algorithms and their trade-offs, working code, Redis at scale, and the numbers.
30 min read
A rate limiter caps how many requests a client may make in a period of time. It protects you from abuse, keeps one noisy customer from starving the others, and stops a retry storm from turning a small problem into an outage. It is one of the most common system design interview questions, and a piece you will build for real sooner or later.
1. Pin down the problem
Before drawing boxes, ask what is being limited and why. The answers change the design.
- Who is limited? An IP address, a user, an API key, a tenant, or all of them with different rules.
- What happens at the limit? Reject with
429 Too Many Requests, slow the request down, or queue it. - How strict must it be? An abuse limiter can be off by a few percent. A paid quota that customers are billed for cannot.
- How much traffic? Thousands of requests per second means the counter store is on the hot path of every call. Section 6 puts numbers on this.
- One server or many? Most of the interesting problems start with "many".
The limiter must add very little latency, use little memory, and keep working if its own store fails. Decide up front whether it fails open (let traffic through) or fails closed (block it). Open is the right default for a public API: a limiter outage should not become an API outage. Security limits fail closed: if the store behind "5 login attempts per account" is down, letting everyone try passwords without limit defeats the purpose. Often the answer is per rule, not per system.
2. Where does it live?
There are three places to put the check, and the choice is mostly about who controls the code.
Diagram: Three places to enforce a limit
- At the API gateway is the usual answer. One place, no code in each service, and rejected requests never reach your servers. Cloud gateways and reverse proxies such as Nginx and Envoy have this built in.
- In the service gives you rules the gateway can't express, such as "5 password attempts per account", at the price of repeating the logic.
- On the client is a courtesy, not a defence. Anyone can skip it.
3. Four algorithms
Each algorithm answers the same question, "may this request pass?", with a different memory and accuracy trade-off.
Token bucket
A bucket holds up to capacity tokens and is refilled at rate tokens per second. Each request takes one token. With an empty bucket, the request is rejected.
Diagram: Token bucket decision for one request
It allows short bursts (up to the capacity) while holding the long-run average to the refill rate, and it needs only two numbers per client: the token count and the time of the last refill. There is no timer that adds tokens: the refill is lazy, computed from the elapsed time when the next request arrives. Stripe describes using a token bucket in production [1], and the algorithm itself is a classic of network traffic shaping [13].
Leaky bucket
"Leaky bucket" means two different things, and mixing them up is a common interview slip.
- As a queue. Requests join a queue of fixed size and leave at a constant rate. It smooths bursts into an even flow, which suits a backend that needs steady load. It adds latency (requests wait in line), and when the queue is full, new requests are dropped.
- As a meter. A counter fills with each request and drains at a constant rate; a request that would overflow it is rejected. There is no queue and no waiting. This is the same behaviour as a token bucket seen upside down (free space instead of tokens).
This guide uses the queue version when it says "leaky bucket". If you only need to cap the rate and don't need to smooth the traffic, the meter version adds nothing over a token bucket. The two readings are described side by side in [12].
Fixed window counter
Count requests in each clock window (for example, per minute) and reject above the limit. It is the simplest option, with one integer per client and window. Its flaw is the boundary: a client can send the full limit at 12:00:59 and again at 12:01:00, twice the limit in two seconds.
Diagram: Fixed windows allow 200 requests in 20 seconds
Each window sees only 100 requests, so neither rejects anything, yet any 60-second span that covers both bursts holds 200.
Sliding window
A sliding window removes that spike by looking at the last N seconds instead of the current clock minute. There are two versions, and they are not equally accurate.
- A sliding log stores a timestamp per request and counts those inside the window. It is the only exact one [2]. Memory grows with the limit, but for a rule like "100 per hour" that is only 100 timestamps per key, which is affordable. For limits that small, prefer the log.
- A sliding window counter keeps two fixed-window counts and estimates the sliding total as
previous × overlap + current, whereoverlapis the share of the previous window still inside the sliding window. It is an approximation: it assumes the previous window's requests were spread evenly, which is not always true. In return it needs two integers per client regardless of the limit, which is why it wins for very large limits and very many clients. Cloudflare measured it on real traffic and found it wrong for only a tiny fraction of requests [2].
Diagram: The sliding window counter weights the previous window by its overlap
At 12:01:30 the sliding window reaches back to 12:00:30, so half of the previous window is still inside: the estimate is 80 × 0.5 + 40 = 80.
| Algorithm | Memory per client | Bursts | Accuracy |
|---|---|---|---|
| Token bucket | 2 numbers | Allowed up to capacity | Exact for its own rule |
| Leaky bucket (queue) | Queue of fixed size | Smoothed out | Exact, adds latency |
| Fixed window | 1 counter | Up to 2x at the boundary | Rough |
| Sliding log | One timestamp per request | Prevented | Exact |
| Sliding window counter | 2 counters | Mostly prevented | Approximate (even-spread) |
For most APIs, start with a token bucket. Pick a sliding log when the rule must be strict and the limit is small ("100 per hour"), and a sliding window counter when it must be cheap at huge scale.
4. Working code
The diagrams hide the details people forget: the lazy refill, the weighting formula, what exactly gets dropped. Here are all four algorithms for a single process, in four languages (pick a language on the tabs; your choice is remembered for every sample). Each keeps its state in memory and guards it, because two threads could otherwise read the same state and both pass.
Fixed window counter
import threading
import time
class FixedWindowCounter:
def __init__(self, limit, window): # window in seconds
self.limit, self.window = limit, window
self.start, self.count = 0.0, 0
self.lock = threading.Lock()
def allow(self):
with self.lock:
now = time.time()
start = now - now % self.window # start of the current window
if start != self.start: # a new window: the count starts again
self.start, self.count = start, 0
if self.count >= self.limit:
return False
self.count += 1
return True
type FixedWindowCounter struct {
mu sync.Mutex
limit int
window time.Duration
start time.Time
count int
}
func (f *FixedWindowCounter) Allow() bool {
f.mu.Lock()
defer f.mu.Unlock()
start := time.Now().Truncate(f.window) // start of the current window
if !start.Equal(f.start) { // a new window: the count starts again
f.start, f.count = start, 0
}
if f.count >= f.limit {
return false
}
f.count++
return true
}
class FixedWindowCounter {
constructor(limit, windowMs) {
this.limit = limit;
this.windowMs = windowMs;
this.start = 0;
this.count = 0;
}
allow() {
const now = Date.now();
const start = now - (now % this.windowMs); // start of the current window
if (start !== this.start) { // a new window: the count starts again
this.start = start;
this.count = 0;
}
if (this.count >= this.limit) return false;
this.count += 1;
return true;
}
}
public sealed class FixedWindowCounter
{
private readonly object _lock = new();
private readonly int _limit;
private readonly long _windowMs;
private long _start;
private int _count;
public FixedWindowCounter(int limit, TimeSpan window)
{
_limit = limit;
_windowMs = (long)window.TotalMilliseconds;
}
public bool Allow()
{
lock (_lock)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
long start = now - now % _windowMs; // start of the current window
if (start != _start) // a new window: the count starts again
{
_start = start;
_count = 0;
}
if (_count >= _limit) return false;
_count++;
return true;
}
}
}
Sliding log
from collections import deque
class SlidingLog:
def __init__(self, limit, window): # window in seconds
self.limit, self.window = limit, window
self.log = deque() # accepted requests, oldest first
self.lock = threading.Lock()
def allow(self):
with self.lock:
now = time.monotonic()
while self.log and self.log[0] <= now - self.window:
self.log.popleft() # this one left the window
if len(self.log) >= self.limit:
return False
self.log.append(now)
return True
type SlidingLog struct {
mu sync.Mutex
limit int
window time.Duration
log []time.Time // accepted requests, oldest first
}
func (s *SlidingLog) Allow() bool {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
i := 0
for i < len(s.log) && !s.log[i].After(now.Add(-s.window)) {
i++ // this one left the window
}
s.log = s.log[i:]
if len(s.log) >= s.limit {
return false
}
s.log = append(s.log, now)
return true
}
class SlidingLog {
constructor(limit, windowMs) {
this.limit = limit;
this.windowMs = windowMs;
this.log = []; // accepted requests, oldest first
}
allow() {
const now = performance.now();
while (this.log.length && this.log[0] <= now - this.windowMs) {
this.log.shift(); // this one left the window
}
if (this.log.length >= this.limit) return false;
this.log.push(now);
return true;
}
}
using System.Diagnostics;
public sealed class SlidingLog
{
private readonly object _lock = new();
private readonly int _limit;
private readonly long _windowTicks;
private readonly Queue<long> _log = new(); // accepted requests, oldest first
public SlidingLog(int limit, TimeSpan window)
{
_limit = limit;
_windowTicks = (long)(window.TotalSeconds * Stopwatch.Frequency);
}
public bool Allow()
{
lock (_lock)
{
long now = Stopwatch.GetTimestamp();
while (_log.Count > 0 && _log.Peek() <= now - _windowTicks)
_log.Dequeue(); // this one left the window
if (_log.Count >= _limit) return false;
_log.Enqueue(now);
return true;
}
}
}
Token bucket
import threading
import time
class TokenBucket:
def __init__(self, capacity, rate): # rate = tokens added per second
self.capacity, self.rate = capacity, rate
self.tokens, self.last = capacity, time.monotonic()
self.lock = threading.Lock()
def allow(self, cost=1):
with self.lock:
now = time.monotonic()
# lazy refill: add what was earned since the last call, up to capacity
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
self.last = now
if self.tokens < cost:
return False
self.tokens -= cost
return True
type TokenBucket struct {
mu sync.Mutex
capacity float64
rate float64 // tokens added per second
tokens float64
last time.Time
}
func NewTokenBucket(capacity, rate float64) *TokenBucket {
return &TokenBucket{capacity: capacity, rate: rate, tokens: capacity, last: time.Now()}
}
func (b *TokenBucket) Allow(cost float64) bool {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now() // carries a monotonic reading, so Sub ignores clock changes
// lazy refill: add what was earned since the last call, up to capacity
b.tokens = math.Min(b.capacity, b.tokens+now.Sub(b.last).Seconds()*b.rate)
b.last = now
if b.tokens < cost {
return false
}
b.tokens -= cost
return true
}
class TokenBucket {
constructor(capacity, rate) { // rate = tokens added per second
this.capacity = capacity;
this.rate = rate;
this.tokens = capacity;
this.last = performance.now(); // monotonic, in milliseconds
}
allow(cost = 1) {
const now = performance.now();
// lazy refill: add what was earned since the last call, up to capacity
this.tokens = Math.min(this.capacity, this.tokens + ((now - this.last) / 1000) * this.rate);
this.last = now;
if (this.tokens < cost) return false;
this.tokens -= cost;
return true;
}
}
using System.Diagnostics;
public sealed class TokenBucket
{
private readonly object _lock = new();
private readonly double _capacity, _rate; // rate = tokens added per second
private double _tokens;
private long _last = Stopwatch.GetTimestamp();
public TokenBucket(double capacity, double rate)
{
_capacity = capacity;
_rate = rate;
_tokens = capacity;
}
public bool Allow(double cost = 1)
{
lock (_lock)
{
long now = Stopwatch.GetTimestamp(); // monotonic
double elapsed = Stopwatch.GetElapsedTime(_last, now).TotalSeconds;
// lazy refill: add what was earned since the last call, up to capacity
_tokens = Math.Min(_capacity, _tokens + elapsed * _rate);
_last = now;
if (_tokens < cost) return false;
_tokens -= cost;
return true;
}
}
}
Sliding window counter
class SlidingWindowCounter:
def __init__(self, limit, window): # window in seconds
self.limit, self.window = limit, window
self.start, self.current, self.previous = 0.0, 0, 0
self.lock = threading.Lock()
def allow(self):
with self.lock:
now = time.time()
start = now - now % self.window # start of the current fixed window
if start != self.start:
# exactly one window later: current becomes previous; longer: nothing to carry
self.previous = self.current if start - self.start == self.window else 0
self.current, self.start = 0, start
overlap = 1 - (now - start) / self.window
if self.previous * overlap + self.current + 1 > self.limit:
return False
self.current += 1
return True
// Create it with &SlidingWindowCounter{limit: 100, window: time.Minute}.
type SlidingWindowCounter struct {
mu sync.Mutex
limit float64
window time.Duration
start time.Time // start of the current fixed window
current float64
previous float64
}
func (s *SlidingWindowCounter) Allow() bool {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
start := now.Truncate(s.window) // start of the current fixed window
if !start.Equal(s.start) {
// exactly one window later: current becomes previous; longer: nothing to carry
if start.Sub(s.start) == s.window {
s.previous = s.current
} else {
s.previous = 0
}
s.current, s.start = 0, start
}
overlap := 1 - float64(now.Sub(start))/float64(s.window)
if s.previous*overlap+s.current+1 > s.limit {
return false
}
s.current++
return true
}
class SlidingWindowCounter {
constructor(limit, windowMs) {
this.limit = limit;
this.windowMs = windowMs;
this.start = 0;
this.current = 0;
this.previous = 0;
}
allow() {
const now = Date.now();
const start = now - (now % this.windowMs); // start of the current fixed window
if (start !== this.start) {
// exactly one window later: current becomes previous; longer: nothing to carry
this.previous = start - this.start === this.windowMs ? this.current : 0;
this.current = 0;
this.start = start;
}
const overlap = 1 - (now - start) / this.windowMs;
if (this.previous * overlap + this.current + 1 > this.limit) return false;
this.current += 1;
return true;
}
}
public sealed class SlidingWindowCounter
{
private readonly object _lock = new();
private readonly double _limit;
private readonly long _windowMs;
private long _start;
private double _current, _previous;
public SlidingWindowCounter(double limit, TimeSpan window)
{
_limit = limit;
_windowMs = (long)window.TotalMilliseconds;
}
public bool Allow()
{
lock (_lock)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
long start = now - now % _windowMs; // start of the current fixed window
if (start != _start)
{
// exactly one window later: current becomes previous; longer: nothing to carry
_previous = start - _start == _windowMs ? _current : 0;
_current = 0;
_start = start;
}
double overlap = 1 - (double)(now - start) / _windowMs;
if (_previous * overlap + _current + 1 > _limit) return false;
_current++;
return true;
}
}
}
Leaky bucket (the queue version)
Requests wait in a bounded queue and a worker takes one out at a fixed rate. A request that finds the queue full is dropped. (The meter version is the token bucket above.)
import queue
class LeakyBucket:
def __init__(self, capacity, rate, handle): # rate = requests processed per second
self.q = queue.Queue(maxsize=capacity)
self.handle = handle # the function that does the real work
threading.Thread(target=self._drain, args=(1 / rate,), daemon=True).start()
def submit(self, request):
try:
self.q.put_nowait(request) # wait in line...
return True
except queue.Full:
return False # ...or be dropped: the bucket is full
def _drain(self, interval):
while True:
self.handle(self.q.get()) # one request leaks out
time.sleep(interval)
type LeakyBucket struct{ queue chan func() }
func NewLeakyBucket(capacity int, rate float64) *LeakyBucket {
b := &LeakyBucket{queue: make(chan func(), capacity)}
go func() {
tick := time.NewTicker(time.Duration(float64(time.Second) / rate))
defer tick.Stop()
for job := range b.queue {
<-tick.C // one request leaks out per tick
job()
}
}()
return b
}
func (b *LeakyBucket) Submit(job func()) bool {
select {
case b.queue <- job: // wait in line...
return true
default: // ...or be dropped: the bucket is full
return false
}
}
class LeakyBucket {
constructor(capacity, rate) { // rate = requests processed per second
this.capacity = capacity;
this.queue = [];
// one request leaks out per tick
setInterval(() => this.queue.shift()?.(), 1000 / rate).unref();
}
submit(job) {
if (this.queue.length >= this.capacity) return false; // the bucket is full: drop
this.queue.push(job); // wait in line
return true;
}
}
using System.Threading.Channels;
public sealed class LeakyBucket
{
private readonly Channel<Func<Task>> _queue;
public LeakyBucket(int capacity, double rate) // rate = requests processed per second
{
_queue = Channel.CreateBounded<Func<Task>>(capacity);
_ = Task.Run(async () =>
{
var tick = new PeriodicTimer(TimeSpan.FromSeconds(1 / rate));
await foreach (var job in _queue.Reader.ReadAllAsync())
{
await tick.WaitForNextTickAsync(); // one request leaks out per tick
await job();
}
});
}
// false when the bucket is full: the request is dropped
public bool Submit(Func<Task> job) => _queue.Writer.TryWrite(job);
}
A few notes:
- Use a monotonic clock for the bucket (
time.monotonic, Go'stime.NowwithSub,performance.now,Stopwatch), so a system clock change can't mint or destroy tokens. The window counter needs wall-clock time to line windows up; across servers that is a reason to take the time from the store (section 5). - Thread safety. Python, Go and C# can run requests on several threads, so each limiter takes a lock. JavaScript runs one function at a time on its single thread, so the code above needs none; but the moment you run several Node processes or worker threads, each has its own memory and you are back to the shared store of section 5.
- One lock per limiter is fine for a demo. Real code keeps one object per key in a map and locks per key, so different clients don't wait for each other, and evicts idle keys.
- For example, with
limit=100,window=60, 30 seconds into the minute, 80 requests in the previous window and 40 in this one: the estimate is80 × 0.5 + 40 = 80, so (if time stood still) there is room for 20 more: the 20th brings the estimate to 100, which is still within the limit, and the 21st would make it 101 and be rejected.
5. Making it work on many servers
With one server, the counters are a hash map in memory. With ten servers behind a load balancer, each would count only its share, and a client could get ten times the limit.
The standard fix is a shared, fast store, usually Redis, so every server checks the same counter.
Diagram: Two servers share one counter in Redis
Make the check atomic
"Read the counter, then write it back" is not safe when two servers do it at once: both may see room for one more request. The check and the update must be a single step, which in Redis means a single command or a Lua script (a script runs without any other command interleaving) [6].
For a fixed window, INCR is atomic by itself, but INCR followed by a separate EXPIRE is not. If the server crashes between the two calls, the key exists without a TTL and never expires, and that client is blocked forever. Put both in one script:
-- KEYS[1] = counter key, ARGV[1] = window in seconds
local n = redis.call('INCR', KEYS[1])
if n == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return n
For a token bucket, the whole read-refill-decide-write cycle goes into one script:
-- KEYS[1] = bucket key
-- ARGV[1] = capacity, ARGV[2] = refill rate (tokens per second), ARGV[3] = cost
local t = redis.call('TIME') -- the clock of Redis, not of the caller
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local capacity, rate, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(bucket[1]) or capacity -- a new client starts with a full bucket
local ts = tonumber(bucket[2]) or now
tokens = math.min(capacity, tokens + (now - ts) * rate) -- lazy refill
local allowed = tokens >= cost
local retry_after = 0
if allowed then
tokens = tokens - cost
else
retry_after = (cost - tokens) / rate
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) + 1) -- idle keys clean themselves up
return {allowed and 1 or 0, tostring(tokens), tostring(retry_after)}
Why each line is there:
TIMEinside the script. If every application server used its own clock, small differences between them would let tokens appear or vanish. Redis keeps one clock for everyone [7]. (On Redis versions before 5, callredis.replicate_commands()first so a script that reads the time can still be replicated.)- The
EXPIRE. Once a bucket has been idle forcapacity / rateseconds it is full again, which is the same as a brand-new bucket, so deleting the key loses nothing. Without a TTL, a million one-off visitors would fill your memory forever. tostringon the way out. Lua turns numbers with a fraction into integers when it hands them back to Redis, so fractions are returned as strings.retry_afteris exactly how long until the missing tokens are earned, which is what you put in theRetry-Afterheader.
The other three algorithms in Redis
The same rules apply to every algorithm: one script, the time from TIME, and a TTL. The fixed window script is the one above.
Sliding log uses a sorted set whose scores are the request times:
-- KEYS[1] = sorted set of requests, scored by time
-- ARGV[1] = limit, ARGV[2] = window in seconds, ARGV[3] = a unique id for this request
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local limit, window = tonumber(ARGV[1]), tonumber(ARGV[2])
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window) -- drop what left the window
if redis.call('ZCARD', KEYS[1]) >= limit then
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
return {0, tostring(tonumber(oldest[2]) + window - now)} -- seconds until a slot frees up
end
redis.call('ZADD', KEYS[1], now, ARGV[3]) -- the id keeps two requests of the same instant apart
redis.call('EXPIRE', KEYS[1], math.ceil(window))
return {1, '0'}
Sliding window counter keeps one counter per fixed window and weights the previous one:
-- KEYS[1] = key prefix, ARGV[1] = limit, ARGV[2] = window in seconds
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local limit, window = tonumber(ARGV[1]), tonumber(ARGV[2])
local index = math.floor(now / window) -- which fixed window we are in
local current_key = KEYS[1] .. ':' .. index
local previous_key = KEYS[1] .. ':' .. (index - 1)
local current = tonumber(redis.call('GET', current_key) or '0')
local previous = tonumber(redis.call('GET', previous_key) or '0')
local overlap = 1 - (now - index * window) / window
if previous * overlap + current + 1 > limit then
return 0
end
redis.call('INCR', current_key)
redis.call('EXPIRE', current_key, window * 2) -- still needed as "previous" next window
return 1
Redis Cluster expects a script to be given every key it touches. This one builds the key names inside the script, which works on a single node; on a cluster, compute the window index in the client and pass both keys (with the same {hash tag}) in KEYS.
Leaky bucket (queue) is not a counter, so Redis stores the queue itself, a list, and a worker drains it at the fixed rate:
-- KEYS[1] = queue (a list), ARGV[1] = maximum queue length, ARGV[2] = the request
if redis.call('LLEN', KEYS[1]) >= tonumber(ARGV[1]) then
return 0 -- the bucket is full: drop
end
redis.call('RPUSH', KEYS[1], ARGV[2]) -- wait in line
return 1
A worker then runs LPOP once per interval and handles what it gets. Because the work happens in the worker, this version fits job queues better than a synchronous API call. For simply capping a rate, use the token bucket.
Other things that go wrong
- Hot keys. One very busy key (a big customer, or one shared IP) lands on one Redis shard and can saturate it. Shard by key, and for the few giants let each application server hold a local share of the budget and sync with Redis in batches, accepting a small error.
- Failover and replication lag. Redis replication is asynchronous. If the primary dies and a replica that is a little behind takes over, it has forgotten the last few counter updates, so clients briefly get more than their limit. For abuse limits this is fine. For billed quotas, keep the authoritative count in a durable store and treat Redis as a fast, approximate gate in front of it.
- Extra latency and a new dependency. Every request now waits for a network call. Keep Redis in the same zone, set a short timeout (a few milliseconds), and apply the fail-open or fail-closed choice from section 1 when it expires. You can also let each server take a batch of tokens and refill it from Redis only occasionally.
- Regions. If the limiter must work across regions, usually give each region its own budget instead of synchronising a counter across oceans.
6. Numbers
A rough estimate is worth doing out loud in an interview. These are assumptions, to be replaced by your own.
- Traffic. 20 million active clients, with a peak of 200,000 requests per second across the API. Every request does one limiter check.
- Throughput. A single Redis node handles roughly 100,000 simple commands per second on ordinary hardware (the
redis-benchmarktool lets you measure your own [8]), and a Lua script costs somewhat more. At 200,000 checks per second that is 2 to 3 nodes at full load, so plan for 4 to 8 shards, split by hash of the key, to leave headroom and survive a failure. - Memory per key. A token bucket stores a hash of two fields. With the key name and Redis's own per-key overhead, assume about 100 to 150 bytes.
- Total. 20 million keys × about 120 bytes ≈ 2.4 GB for the whole fleet, which fits easily. Only clients seen recently have a key, thanks to the TTL, so the real figure is usually well below that.
How that changes with the algorithm, for the rule "100 per hour" and the same 20 million clients (all estimates; measure your own with MEMORY USAGE <key> on a real key [9]):
| Algorithm | Stored per client | Approx. per key | Approx. total |
|---|---|---|---|
| Fixed window | 1 counter | ~70 bytes | ~1.4 GB |
| Sliding window counter | 2 counters | ~100 bytes | ~2 GB |
| Token bucket | tokens and timestamp | ~120 bytes | ~2.4 GB |
| Sliding log | up to 100 timestamps | ~3 to 5 KB | ~60 to 100 GB |
The sliding log is 25 to 50 times larger, which is why it is chosen only when exactness is worth that cost. The takeaway for an interview: memory is rarely the problem for counters; requests per second to the store is. That is what decides the number of shards.
7. Telling the client
A rejected client should not have to guess.
- Return
429 Too Many Requests, not 403 or 500. The status code is standardised in RFC 6585 [3]. - Add
Retry-Afterwith the seconds to wait (or a date). It is defined in HTTP itself, in RFC 9110 [4], so generic clients and proxies understand it. - Many APIs also send
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset. These are a convention, not a standard: the names and the meaning of "reset" (seconds left or a timestamp) differ between providers. An IETF draft proposes standardRateLimit-*headers [5]. Document whichever you use.
Diagram: A client that respects Retry-After
The jitter matters. If a thousand clients all retry exactly three seconds later, they arrive together and trip the limit again.
8. Questions interviewers like to add
Requests that cost more than one
Not every call is equal: a search may cost 10 times a lookup. In a token bucket this is natural: a request takes cost tokens instead of one (the cost argument in both code samples above). It is rejected if fewer than cost tokens are left. Decide whether a rejected expensive request should still use up the tokens it found (usually not), and give very expensive calls a cost no larger than the capacity, or they can never succeed.
A burst limit and a sustained limit
Real APIs often have two rules at once, such as 10 per second and 1,000 per hour. The first protects against spikes, the second against steady heavy use. Keep one bucket for each, and let the request through only if both allow it.
The trap is consuming from the first bucket and then being refused by the second: the client has paid for a request that never ran. Check both before spending either, or do both inside one Lua script, which is atomic, so there is nothing to roll back. Return the Retry-After of the rule that is rejecting.
Retries and idempotency
Limiters and retries interact in both directions.
- Clients that retry on
429must wait forRetry-Afterand use exponential backoff with jitter, or their retries become the traffic that keeps them blocked. - Should a rejected request count against the limit? Usually yes, otherwise a client hammering you is never slowed. Some systems count only accepted requests; say which one you chose.
- A request that timed out on the client may still have been processed. Retrying a non-idempotent call such as "create a payment" then does it twice. Use an idempotency key (the client sends a unique ID, and the server returns the stored result for a repeat; Stripe's API is the well-known example [10], and there is an IETF draft for a standard
Idempotency-Keyheader [11]). The limiter and the idempotency check are separate layers: a repeated request should not be charged twice for work it did only once.
Testing a limiter
A limiter that passes one-request-at-a-time tests can still be wrong under load. Test it at three levels.
- Logic with a fake clock. Inject the clock so a test can move time forward without sleeping: after 5 requests the sixth fails, after
1 / rateseconds one more passes, an idle bucket never holds more thancapacity. Also test the boundary of a window. - Concurrency. Fire, say, 1,000 simultaneous requests from many threads or processes against a limit of 100, and assert that exactly 100 pass (token bucket: capacity plus what was refilled during the run). A read-then-write race shows up here as 101 or more.
- Distributed. Run several application instances against one real Redis, repeat the test, then kill the primary or add latency to check the fail-open or fail-closed behaviour and what the client sees.
9. Things to mention in an interview
- Rules as data. Store limits per route and per plan in a config or database, cached in memory, so changing a limit needs no deploy.
- Hard and soft limits. A soft limit allows a short overshoot or just logs, which is useful while you are still tuning.
- Different keys. Limit by IP before login, by user after, and by API key for partners. Beware of many users behind one corporate IP address.
- Monitoring. Track how many requests are rejected per rule. A sudden rise is either an attack or a rule that is too tight.
- Not a cure for overload. A limiter protects against one client. When the whole system is overloaded, you also need load shedding and backpressure.
Quick recap
- Clarify who and what is limited, and how strict it must be. Decide fail open or closed per rule.
- Enforce at the gateway.
- Token bucket by default, sliding log for small strict limits, sliding window counter for huge scale.
- Share the counter in Redis, make the update atomic (a Lua script), take the time from Redis, and give every key a TTL.
- Do the arithmetic: the requests per second to the store decide the shard count, not the memory.
- Answer with 429 and
Retry-After, and have clients retry with jitter and idempotency keys. - Test with a fake clock, under concurrency, and against a real Redis.
Sources
- Stripe, Scaling your API with rate limiters. Token bucket, and why they use several limiters together.
- Cloudflare, How we built rate limiting capable of scaling to millions of domains. The sliding window counter and its measured accuracy.
- IETF, RFC 6585, section 4: 429 Too Many Requests.
- IETF, RFC 9110, section 10.2.3: Retry-After.
- IETF HTTPAPI working group, RateLimit header fields for HTTP (draft).
- Redis documentation, Scripting with Lua. Why a script is atomic.
- Redis documentation,
TIME. The server clock. - Redis documentation, Benchmarks. Measuring requests per second with
redis-benchmark. - Redis documentation,
MEMORY USAGE. Measuring bytes per key. - Stripe, Idempotent requests.
- IETF HTTPAPI working group, The Idempotency-Key HTTP header field (draft).
- Wikipedia, Leaky bucket. The queue and the meter versions.
- Wikipedia, Token bucket.
Practise this topic: Resilience & Scalability Overload