The prompt
"Design a rate limiter" usually means a distributed one: many API servers, one set of limits. The algorithm is a small part of the answer; the rest is where state lives, how rules change, and what the limiter does when it breaks.
Snip's public API runs on 40 gateway servers and peaks at 200,000 requests a second from 2 million API keys. Each plan has limits ("basic: 10 requests a second, bursts of 20; 100 link creations a minute"), and unauthenticated traffic is limited by IP. The limiter may add at most a couple of milliseconds to a request.
1. Requirements
Functional
- Limit by API key, IP address and route, with limits that depend on the customer's plan.
- Reject excess requests with 429 Too Many Requests, a
Retry-Afterheader, and headers saying how much quota is left. - Change limits and add rules without deploying the API.
Non-functional
- Adds under 2 ms at p99.
- Limits hold across all 40 servers, give or take a few percent.
- The limiter failing must not take the API down.
2. Estimates
- State: 2 million keys × about 3 rules each = 6 million buckets. At about 100 bytes each with Redis overhead, that's 600 MB: it fits in memory, comfortably.
- Operations: 200,000 requests × 2 rules on average = 400,000 checks a second. If one Redis primary handles about 50,000 scripted checks a second within the latency target (measure your own), that's 8 primaries, plus replicas.
So the counter store is a small Redis cluster. The cost to design around is the round trip on every request.
3. Entities and API
Rule { name, match: (api_key | ip | route), per_plan: { rate, burst }, mode: enforce | shadow }
Bucket { rule:key → tokens, updated_at } in Redis, sharded by key
check(request) → { allowed, remaining, retry_after } called by the gateway, in process
HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 04. High-level design
4 steps. Use Next and Back to follow the request.
Text version
- The edge drops floods from single IP addresses before they cost anything (14.04).
- The gateway has every rule in memory, pushed by the rules service whenever a rule changes. It works out which rules match this request.
- It checks all matching buckets in one atomic script call to the Redis primary that owns the key.
- If every rule allows the request, it goes through, with headers saying how much quota is left. Otherwise the gateway answers 429 itself.
- API client → CDN / edge
- CDN / edge → Gateway
- Gateway → Counter store: 1 script call
- Gateway → Snip API: if allowed
- Rules service → Gateway: push
5. Deep dives
Deep dive
Where do the counts live?
No round trip, but with 40 servers a key gets up to 40 times its limit, and the true limit changes whenever the fleet resizes.
Shared, but two gateways can both read 9 of 10, both allow, and both write 10: under concurrency the limit leaks.
The script reads the bucket, refills it for the elapsed time, takes a token and writes it back as one step, so concurrent checks can't interleave. Keys are spread across primaries by hash (06.06 Designing a distributed cache), and the script uses the Redis server's clock, not 40 different gateway clocks.
Deep dive
One request, several rules
A request allowed by the key's limit but rejected by the route limit has already spent a token from the key's bucket. Rejected requests eat the customer's quota.
Simpler, but different rules protect different things; dropping one leaves a gap.
Done inside one script, so it's atomic too. The response reports the lowest remaining quota and the longest wait.
The decision logic, run here in process with an in-memory map in place of Redis:
type Req = { apiKey: string; ip: string; route: string };
type Rule = { name: string; match: (r: Req) => string | null; perSecond: number; burst: number };
type Bucket = { tokens: number; at: number };
export class Limiter {
private buckets = new Map<string, Bucket>();
constructor(private rules: Rule[]) {}
check(req: Req, now: number) {
let retryAfter = 0;
const touched: Bucket[] = [];
for (const rule of this.rules) {
const key = rule.match(req);
if (key === null) continue; // the rule doesn't apply to this request
const b = this.buckets.get(`${rule.name}:${key}`) ?? { tokens: rule.burst, at: now };
b.tokens = Math.min(rule.burst, b.tokens + ((now - b.at) / 1000) * rule.perSecond);
b.at = now;
this.buckets.set(`${rule.name}:${key}`, b);
if (b.tokens < 1) retryAfter = Math.max(retryAfter, Math.ceil((1 - b.tokens) / rule.perSecond));
touched.push(b);
}
if (retryAfter > 0) return { allowed: false, retryAfter };
for (const b of touched) b.tokens -= 1; // spend only when every rule allows it
return { allowed: true, remaining: Math.min(...touched.map((b) => Math.floor(b.tokens))) };
}
}Tested with a key rule of 5 a second (bursts of 10) and a creation rule of 1 a second per IP (bursts of 3): 12
requests at once gave 10 allowed and 2 rejected with retryAfter: 1; one second later the next request was allowed
with 4 remaining. Twenty link creations at once from one IP gave 3 allowed and 17 rejected, and afterwards the
key's own bucket still had 6 tokens: the 17 rejected requests hadn't spent any.
Deep dive
When the limiter is slow or down
A slow Redis now slows every API call, and an outage of the limiter becomes an outage of the API (08.01 Partial failure).
Give the call a few milliseconds; if it fails, allow the request. Customers notice nothing, and the API's own concurrency limits still protect it.
On failure, fall back to a local bucket per server at the limit divided by the number of servers: rough, but not unlimited. The very busiest keys get a local pre-check too, so one huge customer doesn't make a hot key in Redis (05.02 Hot partitions and celebrity keys).
Changing rules safely. A new rule starts in shadow mode: the limiter evaluates it and logs what it would have rejected, without rejecting anything. Once the logs show it catches abuse and not good customers, it's switched to enforce. Rules are versioned, so a bad change is rolled back in seconds (14.05 Safe deploys).
6. Wrap-up
What breaks first is usually the hot key: one large customer's bucket, checked 20,000 times a second, all on one Redis primary. After that, cost: if the counters dominate the bill, approximate local counting synced every second (14.04 Where to put a rate limiter) removes most of the round trips for limits that tolerate a brief overshoot.
What's expected at each level
Junior
gets a working design
- Picks an algorithm and explains it
- Returns 429 with a Retry-After header
Mid-level
explains the trade-offs
- Keeps counts in a shared store and makes the check atomic
- Estimates memory and operations for the store
- Chooses fail open, with a reason
Senior
drives it and goes deep
- Handles several rules without spending on rejected requests
- Ships rules through config with shadow mode and rollback
- Spots the hot-key problem and the cost of a round trip per request
Seen in production
Stripe
2017API rate limiters
Stripe runs four kinds of limiter in production: a request rate limiter (N requests per second per user), a concurrent requests limiter, a fleet usage load shedder and a worker utilisation load shedder. It uses the token bucket algorithm, implemented in Redis. The request rate limiter rejects millions of requests a month, while the concurrent requests limiter triggered about 12,000 times in the month described.
What we foundToken buckets in Redis, plus concurrency limits and load shedders; the request limiter rejects millions of requests a month.
Scaling your API with rate limiters (Paul Tarjan)stripe.com ↗Cloudflare
2017Sliding window rate limiting
Cloudflare estimates the request rate by weighting the previous window's count by how much of it overlaps the current sliding window and adding the current count. It uses only two numbers per counter, and in an analysis of 400 million requests from 270,000 distinct sources, 0.003% of requests were wrongly allowed or rate limited.
What we foundTwo counters per key approximate a sliding window; on 400 million real requests, 0.003% were wrongly allowed or limited.
How we built rate limiting capable of scaling to millions of domains (Cloudflare Blog)blog.cloudflare.com ↗Snip's gateway checks the per-key rule and then the per-route rule, taking a token from each as it goes. A customer complains that a burst of rejected link creations used up their quota for reads. Why?
Recap
Interview questions
Design a distributed rate limiter for an API.