Track 1 · Foundations
mediumRate Limiter
Decide, in under a millisecond and across a whole fleet, whether this request is allowed through. The algorithm choice and the counter-sharing problem are the two real questions.
Suggested architecture
Scenario
A limiter's own cost is one atomic increment per request, so the counter store sees everything. What it rejects never reaches the upstream — which is the point, and the number to watch.
Everything arriving at the gateway, allowed or not.
Share of requests over their limit. The limiter still pays for each one; the upstream does not.
Stateless apart from a local rules cache.
Redis nodes holding the windows. Shard by client key so a hot tenant lands on one node — and watch that node.
Click a component for its role, common technology choices and tradeoffs, and what it is carrying at this scale. Hover a connection to see what flows along it. Drag to rearrange — layout changes are local and reset on reload.
Every figure here is a rough estimate from simple capacity arithmetic, not a benchmark. Each part carries its own assumption about what one copy can do — real numbers depend on your hardware, payloads and access pattern. The point is which component moves first as you turn the dials, not the digits themselves.
In plain words
A rate limiter decides, for every request, whether this caller has done too much recently — and says no if so. It protects a system from abuse, from a buggy client in a loop, and from itself. The two questions are how to count (the algorithm) and where the count lives when there are many servers doing the counting. The rate limiting concept covers the algorithms in depth; this problem is about building the service.
The unusual constraint
- per request, to the counter store
- 1 op
- budget for the whole check
- < 1 ms
- per key for a token bucket
- 2 numbers
- keys × rules = counters to hold
- 40 K
How a request flows
Decision 1: the algorithm
| Algorithm | Memory per key | On a burst | The catch |
|---|---|---|---|
| Fixed window | 1 counter | Allows 2× at a boundary | Cheap, and visibly wrong at edges |
| Sliding window log | 1 timestamp per request | Exact | Memory grows with traffic — unusable at volume |
| Sliding window counter | 2 counters | Close to exact | Assumes even spread within the previous window |
| Token bucket | 2 numbers | Controlled burst, then steady | Burst size is a second dial to reason about |
| Leaky bucket | 1 queue | Perfectly smooth output | Queues requests — adds latency, no burst allowed |
Token bucket is the usual answer: two numbers, refilled lazily from elapsed time, and its burst allowance matches how real clients behave — quiet, then a flurry. Sliding window counter is the other good answer when you must forbid bursts entirely.
Decision 2: where the count lives
Ten gateway instances each enforcing 100/min locally enforce 1,000/min. The count must be shared, and the read-decide-write must be one operation.
-- KEYS[1] = bucket:{api_key} ARGV = capacity, refill_per_sec, now_ms
local capacity, refill, now = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local b = redis.call('HMGET', KEYS[1], 'tokens', 'updated')
local tokens = tonumber(b[1]) or capacity
local updated = tonumber(b[2]) or now
-- tokens that arrived while nobody was looking
tokens = math.min(capacity, tokens + (now - updated) / 1000 * refill)
local allowed = 0
if tokens >= 1 then tokens = tokens - 1; allowed = 1 end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'updated', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / refill * 1000) * 2) -- idle keys clean themselves up
return { allowed, math.floor(tokens) }Every gateway runs the script above against the same key. Correct; one round trip per request; Redis is now a hard dependency.
Each gateway limits to its share and swaps totals every few hundred ms. No round trip, survives Redis being down, briefly over-permissive. Fine for abuse; wrong for a billed quota.
Hash the API key at the load balancer so it always lands on one gateway; local counts are exact. Uneven load and a rebalancing mess when gateways change.
Decision 3: what to do when Redis is down
Redis blips for 10 seconds → every request is rejected → a healthy API is fully down because its protection is. Right only when the limit is about money or correctness: a metered, paid API.
Redis blips → let everything through, emit a loud metric, accept ten seconds of being unprotected. Right for abuse prevention, which is most limiters.
async function check(key: string): Promise<Decision> {
try {
return await redis.evalsha(BUCKET_SHA, [`bucket:${key}`], [CAPACITY, REFILL, Date.now()]);
} catch (err) {
metrics.increment("ratelimit.store_unavailable");
return { allowed: true, remaining: -1 }; // open, and someone gets paged
}
}Decision 4: what to send back
A 429 with Retry-After is the baseline. Returning X-RateLimit-Limit,
X-RateLimit-Remaining and X-RateLimit-Reset on every response — not
just rejections — is what actually makes clients well-behaved: a client that
can see its remaining budget slows down before it is rejected at all. Add a
little randomness to Retry-After, or every rejected client retries in the
same second.
Where this design breaks
- Keying on IP. Everyone behind one NAT (one public address shared by a whole office or carrier) shares a bucket; an attacker with many addresses gets many buckets. Key on the API key or user; IP is a last resort.
- Hot keys. One enormous customer's counter is one Redis key on one
shard. Split it per gateway (
bucket:{key}:{gw}) and give each a share. - Many rules per key. 100/min, 5,000/hour, 50,000/day is three scripts. Send them as one batch (a pipeline) so it is still one round trip.
- Retry storms. Every rejected client retrying at exactly
Retry-Afteris a synchronised wave — see retries, backoff and jitter.
Take this with you
- The one idea: the check runs on every request, so it must cost one cheap atomic operation — and the count must be shared, or N servers enforce N× the limit.
- In an interview, name token bucket, explain the fixed-window edge case, and say where the counter lives and what happens when that store is down.
- At work, return the
X-RateLimit-*headers on every response, and decide on purpose whether your limiter fails open or closed.