sehaj/learn
System design/15 Classic designs

15.03 · design · 20 min

Design: a rate limiter

A rate limiting service for a whole API: rules that change without a deploy, shared counters in a sharded Redis tier with one atomic call per check, several rules on one request without double-spending, and what happens when the limiter itself is slow or down.

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.

Design it yourself firstmidseniorshared countersatomic operationsfail open

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.

20:00

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-After header, 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

text
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: 0

4. High-level design

Rate limiting at Snip's gateway
1 script callif allowedpushAPI clientCDN / edgeper-IP floodsGatewaylimiter libraryCounter storeRedis, 8 primariesSnip APIRules serviceversioned config
The limiter is a library inside the gateway, not a separate hop: rules live in memory, counts in a sharded Redis tier.

4 steps. Use Next and Back to follow the request.

Text version
  1. The edge drops floods from single IP addresses before they cost anything (14.04).
  2. The gateway has every rule in memory, pushed by the rules service whenever a rule changes. It works out which rules match this request.
  3. It checks all matching buckets in one atomic script call to the Redis primary that owns the key.
  4. 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?

BadIn each gateway's memory

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.

GoodIn Redis, read then write

Shared, but two gateways can both read 9 of 10, both allow, and both write 10: under concurrency the limit leaks.

GreatIn Redis, one atomic script per check

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

BadCheck each rule in turn, spending as you go

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.

GoodUse only the strictest rule

Simpler, but different rules protect different things; dropping one leaves a gap.

GreatCheck every rule, then spend from all of them only if all allow

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:

ts
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

BadWait for Redis, and reject if it fails

A slow Redis now slows every API call, and an outage of the limiter becomes an outage of the API (08.01 Partial failure).

GoodA short timeout, then fail open

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.

GreatFail open to local limits, and keep hot keys local

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

2017

API 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

2017

Sliding 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 ↗
Predict first

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?

Commit to an answer before you read on.

Recap

Interview questions

MidfreqMust-knowDesign

Design a distributed rate limiter for an API.

Report a mistakeEdit this page