Problem library

Track 1 · Foundations

medium

Rate 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.

6 parts · 1 workload
algorithmsdistributed counterslatency budget

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.

Open in playground →This diagram is a playground design: the sliders write onto it and the same engine judges it. Open it to change anything, run a spike, or price it.
Entering
~50K req/s
API requests
41 ms
Busiest
API Gateway 50%
synchronousasynchronousfallback / miss path
requestatomic incrpolicy refreshallowed
Components

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

ClientGatewayRedisAPIGET /v1/orders (key: k_7f3a)EVAL bucket.lua k_7f3aone atomic scriptallowed, remaining = 37forward200200 + X-RateLimit-Remaining: 37…request 101 in the same minute…EVAL bucket.lua k_7f3adenied, retry in 23 s429 + Retry-After: 23the API never sees it
The gateway asks Redis once per request. A rejected request costs one Redis call and nothing else.

Decision 1: the algorithm

AlgorithmMemory per keyOn a burstThe catch
Fixed window1 counterAllows 2× at a boundaryCheap, and visibly wrong at edges
Sliding window log1 timestamp per requestExactMemory grows with traffic — unusable at volume
Sliding window counter2 countersClose to exactAssumes even spread within the previous window
Token bucket2 numbersControlled burst, then steadyBurst size is a second dial to reason about
Leaky bucket1 queuePerfectly smooth outputQueues 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.

Token bucket as one atomic Redis scriptRedis
-- 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) }
Shared counter in Redis
the diagram

Every gateway runs the script above against the same key. Correct; one round trip per request; Redis is now a hard dependency.

Local counts, periodic sync

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.

Sticky routing by key

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

Fail closed

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.

Fail open

Redis blips → let everything through, emit a loud metric, accept ten seconds of being unprotected. Right for abuse prevention, which is most limiters.

Fail open, loudlyTypeScript
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-After is 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.