Engineering Atlas › System Design Interviews

Design a distributed rate limiter

19 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope: L3/L4 DDoS scrubbing, billing-grade metering, and adaptive load shedding (related, but a separate mechanism).

Back-of-envelope

API

service RateLimiter {
  rpc ShouldAllow(CheckRequest) returns (CheckResponse);
}
message CheckRequest {
  string domain = 1;                       // "public-api"
  map<string, string> attributes = 2;      // api_key, user_id, ip, route
  uint32 cost = 3;                         // default 1; bulk endpoints cost more
}
message CheckResponse {
  bool allowed = 1;
  string limited_by = 2;                   // rule name, e.g. "per_user_route"
  uint32 remaining = 3;
  uint32 reset_after_ms = 4;
  uint32 retry_after_ms = 5;
}

The HTTP response to the client looks like this:

HTTP/1.1 429 Too Many Requests
Retry-After: 2
RateLimit-Policy: "per_user";q=100;w=1
RateLimit: "per_user";r=0;t=2

Status 429 comes from RFC 6585, which makes Retry-After optional and leaves how you identify callers and count requests entirely to you. The quota headers follow the IETF RateLimit fields draft: RateLimit-Policy names each policy with its quota q and window w, and RateLimit reports remaining units r and seconds until reset t. Earlier drafts used separate RateLimit-Limit, -Remaining and -Reset fields, and many APIs still send X-RateLimit-*, so clients should tolerate both.

The admin API is PUT /rules/{domain}/{name} with a body such as { fields: ["user_id","route"], algorithm: "token_bucket", capacity: 100, refill_per_sec: 10, mode: "enforce"|"shadow" }.

Data model

Rules live in a config store (etcd, or a database with a cache in front). They are versioned and pushed to every limiter node, where they are compiled into a matcher indexed by domain.

A rule is {name, domain, fields[], algorithm, params, mode, priority}. The number of rules is small, a few thousand at most, so every node keeps all of them in memory.

Counters live in a Redis Cluster. The key is rl:{rule}:{v1|v2}, for example rl:per_user_route:{u_42}|POST /charges. The partition key is the hash slot of the part inside the braces. Redis hashes only that substring, so every counter for one user lands on one shard. That makes a multi-tier check for that user a single script call.

The value depends on the algorithm:

Every counter key gets PEXPIRE set to about twice its window or refill horizon. Idle keys then vanish on their own, and memory tracks active keys rather than all keys ever seen.

High-level design

flowchart LR
  C[Client] --> GW["API gateway / Envoy"]
  GW -->|"ShouldAllow (gRPC)"| RLS["Rate limit service"]
  RLS --> RC["Rule cache (in memory)"]
  RLS -->|"EVALSHA one script per shard"| R[("Redis Cluster")]
  CFG["Rule store (etcd)"] -->|watch| RC
  GW -->|allowed| API["Backend services"]
  GW -->|denied| E429["429 + headers"]
  RLS -.->|decisions, shadow hits| M["Metrics / logs"]

Request path.

  1. The gateway extracts the attributes and calls ShouldAllow. It can also link the limiter in as a library to save a network hop.
  2. The service matches the request's attributes against the rule set and builds one key per matching rule.
  3. It groups the keys by Redis shard and sends one script call per shard. Each script checks all of its keys and, only if all of them pass, consumes from all of them.
  4. The gateway forwards the request or returns 429 with headers.

Config path. Rule edits are written to etcd. Limiter nodes watch for changes and swap in a new matcher atomically. A new rule starts in shadow mode, so you can see how many requests it would have rejected before it rejects any.

Deep dives

1. Which algorithm

Choice: token bucket by default, sliding-window counter for per-IP abuse limits, and a log only where limits are tiny and exactness matters, such as 5 password attempts in 15 minutes.

2. Atomic and distributed enforcement

Why GET then SET fails. Doing GET, deciding in the service and then SET loses updates under concurrency. Two nodes each read 9 tokens, and both write back 8.

Ways to make it atomic:

Shard placement. Hash tags keep all of a user's tiers on one shard, so the check-all-then-consume step is one atomic script. The global and per-IP tiers live on other shards. For those, the service checks them in a second call and refunds the first if the second denies. The refund window allows a very small overshoot, and that is acceptable here.

Very high rates. At 1M req/s with hot global keys, have each limiter node lease tokens in batches, for example take 50 tokens with one script call and spend them locally. This cuts store calls by about 50×. The cost is that up to nodes × batch tokens can be outstanding, so batch size is the knob that trades accuracy against load.

Multiple regions. Either split the quota per region in proportion to traffic, or run local limits and reconcile counts asynchronously every second or so. A global synchronous counter puts a cross-region round trip on every request, which fails the latency requirement.

3. Multi-tier keys and the order of checks

Rules form a hierarchy:

Evaluation is check-all-then-consume. If the per-user tier passes and consumes a token, but the tenant tier then rejects, the user has lost a token for a request that never ran. Under sustained rejection that drains honest budgets. Order the checks from most specific to least, because the specific tiers reject most often, which saves remote work.

Response headers report the tier with the lowest remaining fraction. Bulk endpoints charge cost = 10 instead of needing separate rules. Tenant overrides sit at a higher priority on the same rule name, so an enterprise customer's higher limit replaces the default rather than adding to it.

Down the stack

Layer 1The contract

  • For a token-bucket rule (B, r) on key k, over any interval of length t within one region, allowed cost is at most B + r·t, plus at most nodes × lease_batch when leases are on.
  • A denied request does not consume budget at any tier.
  • A decision takes at most 1 ms at p99. If the store does not answer within about 5 ms, the service falls back to a per-node local bucket set to limit / node_count and records a fail_open metric.
  • A rule change takes effect within about 5 seconds.

Layer 2The mechanism

The token-bucket script receives KEYS = [k1..kn] and ARGV = [now_ms, cost, cap1, rate1, ...]. It works in two passes:

  1. For each key it runs HMGET k tokens ts, computes tokens = min(cap, tokens + (now − ts) × rate / 1000) and checks tokens ≥ cost.
  2. If every check passed, it runs HSET k tokens (tokens−cost) ts now and PEXPIRE k horizon for each key.

It returns either the remaining tokens or the index of the rule that denied.

The service loads the script once with SCRIPT LOAD and calls it by SHA (EVALSHA), so each call carries 40 bytes of hash instead of the script body. The script cache is not persisted and can be empty after a restart or a replica promotion, so the client must handle NOSCRIPT by reloading, and inside a pipeline it should send plain EVAL (Redis docs). Every script must name its keys in KEYS, which is what lets Redis Cluster route it and reject keys that span slots.

Envoy's global rate limiting takes the same descriptor-based shape. The proxy sends a RateLimitRequest holding a list of descriptor entries, and a rate limit service answers OK or OVER_LIMIT for each descriptor. Envoy then returns 429, gives the call a 20 ms timeout by default, and lets traffic through on errors unless failure_mode_deny is set (Envoy filter docs, proto). Fail-open is the proxy default, not just a choice in this design.

Layer 3The machine

  • Redis execution. Redis runs commands on one thread. A 3-key script takes about 10 to 20 µs of CPU, which is where the 50k to 150k calls per second per core figure comes from. I/O threads can parse sockets in parallel, but scripts still run one at a time.
  • Network. In one availability zone a round trip costs about 100 to 300 µs, mostly kernel network stack and NIC interrupt work at each end. Persistent pooled connections avoid a TCP handshake per check. A request and response each fit in one segment, well under the roughly 1,460-byte MSS.
  • Which clock to use. Use the Redis server's clock (TIME), or pass in the caller's clock and accept skew. Within a service, measure elapsed time with CLOCK_MONOTONIC. Wall clocks can step backward under NTP, which would mint negative elapsed time. Clamp now − ts at 0.

Why it's built this way

  1. Every request pays for the limiter, so it needs a sub-millisecond check. That rules out cross-region coordination and disk-backed stores.
  2. A sub-millisecond check needs in-memory state with tiny entries, so the design uses token buckets (two numbers) rather than logs.
  3. Many stateless API nodes share each key, so the state must live centrally and be updated atomically. That leads to a single-threaded store and a script.
  4. One central store has limits on throughput and availability. That leads to sharding by hash tag, local leases for hot keys, and failing open.
  5. Wrong limits hurt customers more than missing ones, which is why shadow mode, override priorities and limit headers exist.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Token bucketBursts plus a steady rate, 2 numbers per keyBurst size is one more knob to explainPublic API quotas
Sliding-window counter2 integers, smooth, no boundary spikeApproximate if traffic inside the window is unevenPer-IP abuse, coarse limits
Sliding logExact over any windowMemory and CPU grow with the limitSmall, security-sensitive limits
Fixed windowSimplest, one INCR2× burst at boundariesInternal, non-critical limits
Central RedisAccurate, simpleExtra hop, an availability dependencyMost deployments
Local leasesFew store calls, survives store blipsOvershoot up to nodes × batchVery high rate global keys
Fail openAPI stays upLimits not enforced while the limiter is downQuotas and fairness
Fail closedProtects the downstreamLimiter outage becomes an API outageGuarding a fragile dependency

Leadership lens

Check yourself

  1. Why is a sliding log reserved for small limits such as login attempts, rather than used for a 1,000 per minute API quota?

    Show answer

    A log stores one sorted-set entry per request at roughly 64 bytes, so 1,000 per minute is about 64 KB per key and about 640 GB for 10M keys. A token bucket needs about 100 bytes per key, roughly 3 GB for 10M keys and 3 rules.

  2. Why must a multi-tier check be check-all-then-consume rather than consuming tier by tier?

    Show answer

    If the per-user tier consumes a token and the tenant tier then rejects, the user loses budget for a request that never ran, and sustained rejection drains honest budgets. The script checks every key first and consumes from all of them only if all pass.

  3. The Redis shard holding a set of counters goes down. What should the limiter do?

    Show answer

    By default fail open to a per-node local bucket of about limit / node_count, record a fail_open metric and alert, so a limiter outage does not become an API outage. Rules protecting a fragile dependency can be set to fail closed for that rule only.

  4. What do local token leases buy at very high rates, and what do they cost?

    Show answer

    Each node takes a batch of tokens (for example 50) in one script call and spends them locally, cutting store calls by about 50 times. The cost is possible overshoot of up to nodes × batch tokens, so batch size trades accuracy against store load.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →