SysDesignPrep.com
System design interview question

Design a Rate Limiter

A distributed service that decides, in under a millisecond, whether a request from a client is allowed under its quota.

Last updated 2026-09-22. Difficulty: medium. Patterns: algorithms, counters, redis, edge. Reported at Anthropic and 7 more with Pro.

Walk through a strong candidate's answer, turn by turn.

The interviewer asks, the candidate answers and draws, and you press Next. Pause to answer yourself at the key decisions, and ask the coach anything along the way.

Functional requirements

  • Limit requests per client per rule. Keyed by API key, user id, or IP. Rules like "100 requests per minute per API key on /search".
  • Multiple rules per request. A request can be subject to a per-user, a per-endpoint, and a global rule; all must pass.
  • Return informative rejections. HTTP 429 with Retry-After and X-RateLimit-Limit / Remaining / Reset headers so clients can back off correctly.
  • Rules configurable without redeploy. Operators change limits per tier or customer; changes take effect within seconds.
  • Allow short bursts. A client at 100/min should be able to send 20 in one second, then be throttled, rather than exactly 1.6 per second.
  • Observability. Per-rule accept and reject counts, and a way to see which clients are being throttled.
  • Out of scope. DDoS mitigation at the network layer, WAF rules, and billing quotas measured in days or months (those are a separate metering system).

Non-functional requirements

  • Decision latency (p99 < 1 ms added). The limiter sits on every request; its latency is paid by everyone. It must be a local computation or a single fast cache round trip.
  • Throughput (1 M decisions/s). Across the fleet. Each decision is cheap, so this is about not creating a central bottleneck.
  • Accuracy (within ~5 % of the limit). Slight over-admission under contention is acceptable; systematic under-admission (rejecting legitimate traffic) is not.
  • Availability (fail open). If the limiter's store is unreachable, requests must be allowed (with alerts), not rejected. A broken limiter must not become an outage.
  • Consistency across nodes (shared counters per key). A client hitting 10 gateway instances is limited as one client, not ten.
  • Memory (bounded per key). Millions of keys live at once; per-key state must be a few bytes and expire on its own.
  • Multi-region. Limits are enforced per region by default; global limits are a stated approximation.

Back-of-envelope estimates

  • Decisions per second: ~1 M. Assume the API fleet serves 300 k requests/s at peak and each request evaluates ~3 rules: ~1 M decisions/s. The store must handle that, or the check must be local.
  • Active keys: ~10 M. Say 5 M distinct clients active in any hour × a couple of rules each = ~10 M live counters. Keys expire after their window so the set self-cleans.
  • Memory per key: ~100 B. Token bucket: tokens (8 B) + last refill timestamp (8 B) + Redis key overhead (~60–80 B) ≈ ~100 B. Sliding-window log would be 8 B per request in the window: 100× more for a 100/min rule.
  • Store memory: ~1 GB. 10 M keys × 100 B = ~1 GB. One Redis node holds it; shard for throughput, not for size.
  • Redis ops per second: ~1 M (or ~0 with local buckets). One Lua script call per decision = 1 M ops/s → ~10 Redis shards at 100 k ops/s each. With local token buckets that sync periodically, the store sees only sync traffic: 10 M keys ÷ 1 s sync interval is worse, so sync per active key per second, roughly 100 k/s.
  • Rule config size: ~10 k rules · < 1 MB. Per-tier defaults plus per-customer overrides: a few thousand to ten thousand rules, each ~100 B = < 1 MB. Small enough to push to every node and hold in memory.
  • Latency budget breakdown: ~0.3 ms Redis RTT. Same-AZ Redis round trip ~0.2–0.5 ms; the Lua script itself is microseconds. That fits a 1 ms budget once, not three times, so multiple rules per request are evaluated in one script call or with local buckets.

Components

  • API client: Any caller: browsers, mobile apps, partner servers. Identified by API key or session; falls back to IP for anonymous traffic. Well-behaved clients read the rate-limit headers and back off before being rejected.
  • Load balancer: Spreads traffic across gateway instances. Not sticky: the same client lands on different instances, which is why limits cannot live only in instance memory.
  • API gateway (limiter middleware): Where the limiter runs, as middleware before routing. Extracts the client identity, looks up applicable rules from its in-memory rule cache, evaluates them, and either forwards the request or returns 429 with headers. Emits decision metrics.
  • Rule cache (in-process · versioned): Each gateway holds the full rule set in memory: (scope, key pattern, limit, window, burst). Refreshed by subscribing to a config change channel or polling a version every few seconds. A rule lookup is a map access, never a network call.
  • Local buckets (in-process LRU): Optional first tier: per-instance token buckets for hot keys that absorb most decisions with zero network cost, periodically reconciled against the shared store. Trades a little accuracy for latency and store load.
  • Counter store (Redis Cluster · Lua): The shared source of truth for counters, sharded by key. Each decision is one atomic Lua script that refills the bucket, checks, decrements, and sets a TTL in a single round trip. Keys expire after the window so memory is bounded.
  • Rule config service: Operator UI and API for rules: per-tier defaults, per-customer overrides, per-endpoint limits. Validates, versions, stores in a small database, and publishes a change notification.
  • Config DB (Postgres): Durable rules with history. Small and rarely written; read once per gateway refresh.
  • Config channel (Redis pub/sub or Kafka): Broadcasts "rules changed, version N" so gateways refetch within seconds instead of polling on a long interval.
  • Upstream services: The actual APIs being protected. They also benefit from the limiter's headers being set consistently and never see rejected traffic.
  • Metrics pipeline (counters → TSDB): Per-rule accept and reject counters, top throttled clients, store latency and fail-open events. Aggregated per instance and flushed every few seconds; never on the request path.
  • Dashboards & alerts: Operators watch reject rates by rule and customer, and get paged on fail-open (store unreachable) because that means limits are not being enforced.

User flows

  1. A request is checked and allowed. The hot path. Identify the client, find the rules, run one atomic script per key, forward with headers. Under a millisecond added.
    1. Request arrives at a gateway instance; the middleware extracts the client identity. Identity precedence: API key, then authenticated user id, then IP. Anonymous IP-based limits are looser and separate, because NAT puts many users behind one address.
    2. Middleware looks up applicable rules in the in-memory rule cache. Match by endpoint pattern and client tier. Typical result: a per-key rule (1 000/min), a per-endpoint rule (100/min on /search), and a global safety rule. Map lookups, no I/O.
    3. For each rule, run the token-bucket script against the counter store in one round trip. The Lua script computes tokens = min(capacity, tokens + elapsed × rate), checks tokens ≥ 1, decrements, writes back, and sets a TTL of the time to refill fully. Atomic on the shard, so concurrent gateways cannot double-spend. Multiple rules for the same client can be evaluated in a single script call when their keys hash to the same slot (use a hash tag).
    4. All rules allowed: forward upstream and add rate-limit headers to the response. Headers reflect the most restrictive rule so clients can pace themselves. Remaining is approximate by design.
    5. Decision is counted asynchronously for metrics. In-process counters per (rule, decision), flushed every few seconds. No per-request network call for telemetry.
  2. A client exceeds its limit. The same path with the other outcome. What matters is what the client is told so it can recover without hammering the API.
    1. A burst of requests from one key lands across several gateway instances. Because the counter lives in the shared store, it does not matter that the 21 requests hit 6 different instances; they all decrement the same bucket.
    2. The script finds the bucket empty and returns a rejection with the time until the next token. retry_after = (1 − tokens) / rate. The script does not decrement below zero and does not extend the TTL for rejected calls, so a client that keeps retrying does not keep itself locked out longer than the window.
    3. Gateway returns 429 with Retry-After and does not call upstream. Rejections are cheap: no upstream work, a tiny response. This is the whole point: the limiter protects the expensive tier by doing the cheap thing early.
    4. Reject counters increment; a sustained reject rate on one key shows up on the throttled-clients dashboard. A single client being throttled is normal. A whole tier being throttled usually means a rule change was wrong or a client library has a retry bug.
    5. A well-behaved client sleeps for Retry-After and resumes; a badly behaved one keeps getting cheap 429s. For abusive clients, escalate: a secondary rule counts 429s per key and, above a threshold, applies a longer penalty window or blocks at the load balancer.
  3. An operator changes a limit. Rules are data, not code. A change propagates to every gateway within seconds without a deploy.
    1. Operator raises a customer's search limit in the config UI. Validation: window and limit are positive, burst ≤ limit, no overlapping rule with a different value for the same scope. Stored with a new version number and an audit record.
    2. Config service publishes a change notification with the new version.
    3. Each gateway sees the notification and fetches the rules snapshot for that version. Fetch is a single request returning the full compiled rule set (under 1 MB) with an ETag. Gateways also poll every 30 s as a fallback in case a pub/sub message was missed.
    4. The gateway swaps its in-memory rule set atomically; the next request uses the new limit. Swap a pointer, never mutate in place. Existing buckets keep their tokens; only capacity and rate change, so a raised limit takes effect immediately and a lowered one drains naturally.
    5. Metrics show the propagation: the fraction of gateways on version 1187 reaches 100 % within seconds. Each gateway tags its metrics with the rule version. A gateway stuck on an old version is a visible anomaly.
  4. The counter store is unreachable. The limiter must never become the outage. Decisions degrade to local approximations and operators are paged.
    1. Redis shard times out or returns errors for a key. Timeout is short (a few ms) so the request is not delayed. A circuit breaker per shard opens after a burst of failures so subsequent decisions do not even try.
    2. Gateway falls back to its local bucket for that key, scaled by the instance count. Each instance allows roughly limit ÷ N per window, where N is the number of healthy gateways from service discovery. Approximate but bounded: a client cannot exceed the limit by more than the imbalance across instances.
    3. If no local state exists either, allow the request (fail open) and tag it. The response carries X-RateLimit-Policy: degraded so upstream services and clients can tell. Rejecting here would turn a cache blip into an API outage for everyone.
    4. Fail-open events are counted and alert immediately. This alert is high priority precisely because nothing is visibly broken to users. Limits are silently not being enforced.
    5. Store recovers; the breaker closes; local buckets reconcile with shared counters. Reconciliation is simple: the shared bucket is authoritative; local state is dropped. A short period of slight over-admission during recovery is acceptable.
  5. One key generates 50 k requests per second. A single hot client (or a misconfigured integration) concentrates load on one counter shard. The design shifts that key's decisions to local buckets.
    1. Gateways notice that one key dominates decision volume. A per-instance top-K sketch of keys by decision count, checked every second. Above a threshold the key is marked hot.
    2. For a hot key, each gateway serves decisions from a local token bucket sized as its share of the limit. Share = limit × (this instance's recent traffic for the key ÷ total), estimated from the reconciliation data. This removes 50 k ops/s from one Redis shard and makes decisions in-process.
    3. Local buckets reconcile with the shared counter every 100–500 ms. One script call per instance per interval instead of one per request: report tokens consumed, receive the global remaining and the instance's new share. Accuracy stays within a few percent.
    4. The 429s for the hot key are served locally too, so the abusive client never touches the store. Rejections are the cheapest response the system produces. Making them local means an abusive client costs almost nothing.
    5. When the key cools, it leaves the hot set and returns to the shared path. Hysteresis: the key must stay below the threshold for several intervals before demotion, to avoid flapping between modes.

Deep dives

Choosing the algorithm

Token bucket, leaky bucket, fixed window, sliding log, sliding window: which one and why?

All of these answer "has this client done too much recently", but they differ in burst behaviour, accuracy at window edges, and memory. The classic failure is fixed window: a client can send the full limit at 11:59:59 and again at 12:00:00, doubling the intended rate. Sliding log is exact but stores a timestamp per request. Token bucket is tiny, allows a configurable burst, and is trivially expressed as an atomic script.

The product requirement that decides it here is "allow short bursts, then throttle", which is exactly what a token bucket models: capacity is the burst, refill rate is the sustained limit.

  • Token bucket chosen
  • Sliding window log situational: low limits where exactness matters, such as login attempts
  • Sliding window counter (weighted two fixed windows) situational: when the API contract is "N per window" and burst tuning is not needed
  • Fixed window counter rejected
  • Leaky bucket (queue) rejected: traffic shaping on outbound calls, not admission control

The answer: Token bucket per (client, rule), implemented as a Redis Lua script that refills based on elapsed time, checks, decrements, and sets a TTL in one atomic step. Rules specify limit, window, and burst; capacity = burst, rate = limit / window. Expose the standard headers computed from the bucket state. Offer sliding-window-log as an option for a handful of sensitive, low-limit rules like authentication attempts, where exactness beats cost.

A rule says 100 per minute with burst 20. A client sends 20 instantly, then what?

Then it gets one token every 600 ms. Over any full minute it still averages 100. Over the first second it sent 20, which is 12× the average rate, and that is the point of burst. If the interviewer wants the instantaneous rate capped too, add a second, tighter rule: 20 per second.

Why not compute the token bucket in the gateway without Redis?

Because the same client hits many gateways. Per-instance buckets multiply the effective limit by the instance count and are wrong the moment traffic is uneven. Local buckets work as a first tier for hot keys or as a fallback, but the shared store is what makes ten gateways behave as one limiter.

Clock skew between gateways: does it break the bucket?

The script uses the Redis server's clock (redis.call TIME) rather than a timestamp passed by the gateway, so all decisions for a key see one monotonic clock. If you pass the client time you inherit skew; never do that.

How does the script avoid a race when two gateways decrement the same bucket at once?

Redis executes a Lua script atomically on its shard: no other command interleaves. Both scripts run in sequence, the second sees the first's decrement. The race exists only if you do GET then SET from the client side, which is why the logic lives in the script.

Where the limiter runs

Gateway middleware, a sidecar, a dedicated service, or the CDN edge?

The limiter needs three things: the client identity, the rules, and a shared counter. Where it runs determines latency (how many hops before a cheap rejection), blast radius (what breaks if it breaks), and who owns it. The ideal is to reject as early and as cheaply as possible without adding a network hop to every allowed request.

A dedicated rate-limit service that every gateway calls adds a hop and a dependency to every request; it is the common design in interviews and often the wrong one in practice, because the gateway can talk to the counter store directly with the same logic as a library.

  • Library or middleware in the gateway, shared store behind it chosen
  • Dedicated rate-limit service (gRPC) situational: many heterogeneous callers that cannot share a library (Envoy's ratelimit service model)
  • Sidecar (Envoy / service mesh filter) situational: the platform already runs a mesh
  • CDN edge rules situational: as the first line for anonymous and volumetric abuse, in addition to the gateway limiter

The answer: Middleware inside the API gateway, packaged as a library, talking directly to the counter store; rules pushed to every instance and cached in memory. Add coarse IP and path limits at the CDN edge as an outer layer for anonymous abuse. Keep a dedicated service only if the organisation has many gateways in different languages; even then, the service should be a thin wrapper around the same store and script.

What is the failure mode of a dedicated rate-limit service, and how do people mitigate it?

It becomes a synchronous dependency of every request. When it is slow, every API is slow; when it is down, callers must choose between failing open and failing closed. Mitigations are aggressive timeouts, fail-open defaults, and local caching of recent decisions, which is most of the way back to a library design.

Limits per API key versus per user versus per IP: how do you pick the identity?

Prefer the most specific authenticated identity: key for machine clients, user for sessions. IP only for anonymous traffic and abuse, with high limits because of NAT and mobile carriers. A single request can be checked against more than one identity; the cost is one script call per rule, or one batched call when keys share a hash tag.

Should upstream services trust the gateway or also limit?

Internal services should have their own coarse protections (concurrency limits, load shedding) because not all traffic comes through the public gateway. Business rate limits belong at the edge; resource protection belongs at the service.

Distributed counters and accuracy

Ten gateways, one client. How exact does the shared count need to be, and what does exactness cost?

A perfectly exact global limit requires every decision to serialise on one counter. Redis gives this per key, cheaply, as long as one shard can absorb the key's rate. The trouble starts with hot keys (one client at 50 k requests per second) and with multi-region deployments where the shared store is far away.

The insight is that limits are protective, not billing. Being 5 % over on a busy key harms nobody. So accuracy can be traded for locality: local buckets sized as shares of the limit, reconciled periodically, with the shared counter as the authority.

  • Shared atomic counter per key, local buckets for hot keys and fallback chosen
  • Always local with periodic sync situational: very latency-sensitive gateways where 10–20 % over-admission is acceptable
  • Sticky routing by client to one gateway rejected
  • Strongly consistent counter in a database rejected

The answer: Redis Cluster as the shared counter with the Lua token bucket; keys hashed so a client's rules for the same identity land on one slot (hash tag on the identity) and can be checked in one call. Detect hot keys per instance and switch them to local buckets with shares reconciled every few hundred milliseconds. On store failure, fall back to local shares and then fail open. Document the accuracy contract: exact for normal keys, within ~5 % for hot keys, best effort during store outages.

How do you shard the counter store and what happens on resharding?

Redis Cluster hashes keys to 16 384 slots across shards; a client's keys share a hash tag so they co-locate. Resharding moves slots live with brief redirection; a counter in flight during a move might be lost, which costs one window of leniency for that client. Acceptable, and far better than any design that pauses decisions.

Two regions, one global limit of 1 000 per minute. Options?

Split the limit statically (500 each) if traffic is balanced; split dynamically by recent share if not; or pick one region as the authority and accept cross-region latency for that key. Exact global limits across regions are not worth their latency; say so and offer per-region enforcement as the default.

Memory: what if a bug makes keys never expire?

Every script sets PEXPIRE on every write, so expiry does not depend on a separate cleanup path. Belt and braces: Redis maxmemory with an LRU or TTL eviction policy, and an alert on key count growth. Losing an evicted counter is one window of leniency, not an incident.

The client contract: headers and 429 semantics

What exactly should a throttled client be told, and why does it matter for the system's own load?

A limiter that returns a bare 429 trains clients to retry blindly, which turns throttling into a retry storm. Good headers turn clients into cooperative participants: they know how much budget is left and exactly when to try again. The draft IETF RateLimit header fields and the widely used X-RateLimit-* headers cover this.

There is a subtlety with multiple rules: which rule's numbers do you report? And with token buckets: "remaining" is not a count in a window but tokens in a bucket, which needs a sensible presentation.

  • Limit / Remaining / Reset headers on every response, Retry-After on 429, report the most restrictive rule chosen
  • 429 only, no headers rejected
  • Queue the request until a token is available rejected: never at a public gateway; sometimes inside a client SDK

The answer: On every response: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (unix seconds) for the most restrictive applicable rule, plus a RateLimit-Policy header listing all rules so advanced clients can see the full picture. On 429: Retry-After in seconds (rounded up), a JSON body naming the rule, and no upstream call. Publish client guidance: honour Retry-After, add jitter, and treat Remaining as advisory. Escalate persistent offenders with a penalty rule that lengthens their lockout.

Remaining says 5, the client sends 5 in parallel, 2 get 429. Bug?

No. Remaining was true at the instant of the previous response; other requests in flight consumed tokens. Document it as advisory. Clients that need certainty should serialise or keep a margin.

How do you stop a retry storm from a popular open-source client with a bad backoff?

Retry-After plus a penalty rule that counts 429s per key: after N rejections in a minute, the effective lockout doubles each time, capped. Combine with a gateway-level cap on connections per key. And reach out to the maintainers; the fix belongs in the client, the penalty just buys time.

Should the limiter distinguish reads from writes?

Often yes: writes are more expensive and more dangerous under abuse. Model it as separate rules per method or endpoint group, or as different token costs within one bucket (a write costs 5 tokens). The script already takes a cost parameter.

Operating it: config, rollout, and observability

A rule change can lock out a customer or open the floodgates. How do you change rules safely and know what the limiter is doing?

Rate limit rules are production configuration with immediate user-visible effect. They need the same care as code: validation, versioning, gradual rollout, and a fast revert. And because the limiter's correct behaviour is invisible (requests just work), observability has to be deliberate: you need to know when it is not enforcing, not only when it rejects.

  • Versioned rules, push-based refresh with poll fallback, shadow mode for new rules chosen
  • Rules baked into gateway config, deployed with code rejected
  • Rules read from the database per request rejected

The answer: Rules live in a small database behind a config service with validation and audit. Every change bumps a version, is published on a channel, and gateways fetch the compiled snapshot; a 30 s poll catches missed notifications. New or tightened rules start in shadow mode, reporting would-be rejections per customer for a day before enforcing. Metrics: decisions per rule per outcome, top throttled keys, store latency, fail-open count, and rule version per instance. Alerts: fail-open above zero, reject rate for a tier jumping after a version change, any instance more than one version behind.

A customer says they are being limited far below their tier. Where do you look?

Their key's decisions by rule in the metrics: which rule rejected and how often. Common causes: a per-endpoint rule tighter than the tier rule, an IP rule catching them behind a shared NAT, a client bug retrying without backoff, or a rule override from a past incident nobody removed. The audit log on rules answers the last one.

How do you test a rate limiter?

Unit test the script against a deterministic clock. Load test with synthetic clients at known rates and assert admitted counts within tolerance across window boundaries. Chaos test the store: kill a shard under load and verify fail-open plus alerts. And keep a canary key with a tiny limit that a probe hits continuously; if the probe ever stops seeing 429s, enforcement is broken.

What is the cost of the limiter itself at 1 M decisions per second?

Roughly ten small Redis shards and a few percent of gateway CPU. Trivial next to the upstream capacity it protects. The interesting cost is the engineering time on hot-key handling and multi-region semantics, which is why the accuracy contract should be explicit and modest.

Related