Design problems
Classic building blocksMediumSDE-2 30 min

Design a rate limiter service

Asked at
AmazonCREDGooglePhonePeRazorpayStripe

The interviewer asks

“Design a rate limiting service used by all public APIs of a large company. It should support rules like '100 requests per minute per API key' and '10 OTP requests per hour per phone number', and work across hundreds of API servers.”

What a strong answer looks like

A strong answer first clarifies where limiting happens (API gateway vs inside services), what the keys are (user, API key, IP, tenant, endpoint) and whether limits must be exact or approximately right. Pick an algorithm on purpose: token bucket for bursty APIs, sliding window counter for fair per-minute quotas. Keep state in a shared Redis cluster with atomic Lua scripts, sharded by limit key, and keep the decision on the request path under a millisecond or two. Rules live in a config store and are pushed to gateways. Then handle the hard parts: hot keys, Redis failure (fail open vs fail closed per rule), multi-region limits, and clear client signals (429, Retry-After, X-RateLimit headers). Senior candidates add local pre-aggregation to cut Redis load and cost-based limits for expensive endpoints such as LLM calls.

Concepts to mention

token bucket vs sliding window counter vs fixed windowatomic check-and-increment with LuaRedis Cluster sharded by limit key with hash tags429 with Retry-After and rate limit headersrules pushed via config service, shadow modefail open vs fail closed per rulelocal pre-aggregation for hot keysmulti-region quota splittingwhere it sits: gateway or sidecar

1 Requirements

Functional

  • Limit requests by configurable key: API key, user ID, IP, phone number, tenant, endpoint, or a combination
  • Support rules such as N requests per second/minute/hour/day, with burst allowance
  • Return HTTP 429 with Retry-After and X-RateLimit-Limit/Remaining/Reset headers when limited
  • Rules can be changed without deploys and take effect within 30 seconds
  • Support different tiers (free 100/min, paid 10,000/min) and per-customer overrides
  • Shadow mode: evaluate a new rule and log would-be blocks without blocking

Non-functional

  • Added latency p99 under 2 ms per request
  • Handle 1M requests/s across all gateways, with about 50M active keys
  • Rate limiter failure must not take down the APIs (configurable fail-open per rule)
  • Accuracy: within ~1-5% of the limit for general APIs; strict for security limits like OTP and login
  • Availability 99.99% for the decision path

Out of scope (say it out loud)

  • Full DDoS protection at the network layer (handled by the CDN / WAF)
  • Billing and quota invoicing (monthly quotas are a related but separate system)
  • Bot detection with ML

2 Estimates

Decision QPS1M/s (peak)Given. Every API request needs at least one check; some need 2-3 (per key + per IP + per endpoint) → up to ~2.5M Redis ops/s
Active keys~50M10M API keys/users active per hour + IP keys + phone keys for OTP
Memory for counters~10 GB (with overhead)Sliding window counter: 2 buckets × ~16 B + key ~40 B + Redis overhead ~60 B ≈ 150 B/key × 50M ≈ 7.5 GB
Redis cluster size~25-30 primariesOne Redis primary does ~100k Lua script calls/s comfortably; 2.5M ops/s ÷ 100k ≈ 25, plus headroom and a replica each
Latency budget< 2 ms p99Same-AZ Redis round trip ~0.3-0.5 ms + Lua execution ~20 µs; pipelining multiple rule checks in one round trip

3 API

EndpointWhat it does
gRPCRateLimit.ShouldAllow(descriptors[], cost=1)Called by gateway or sidecar. Descriptors like [{api_key: k1}, {ip: 1.2.3.4}, {route: /v1/payments}]. Returns allow/deny, remaining, reset_at for the tightest rule.
PUT/admin/v1/rules/{rule_id}Create/update a rule: match descriptors, limit, window, algorithm, mode (enforce|shadow), fail_mode (open|closed).
GET/admin/v1/rules?service=List rules; used by gateways to sync rule config.
GET/admin/v1/usage/{key}Support/debug: current counters and recent 429s for a key.

4 Data model

rulesPostgres + pushed to gateways via etcd/config service
rule_id (PK)servicematch (descriptor pattern)limitwindow_sburstalgorithmmodefail_modepriorityupdated_at

Low write volume, needs audit history. Gateways hold the full rule set in memory and refresh on change notification.

countersRedis Cluster
key = rl:{rule_id}:{descriptor_value}:{window_start}value = countTTL = 2 × window

Hash tag on descriptor value so all windows for one key land on the same shard. TTL means no cleanup job.

token_bucketsRedis Cluster
key = tb:{rule_id}:{descriptor}hash {tokenslast_refill_ms}

Refill computed lazily on each call inside a Lua script, so no background refiller is needed.

limit_eventsKafka → ClickHouse
tsrule_idkeydecisionmode

429s and shadow-mode hits for dashboards, tuning and customer support.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAHTTP requestShouldAllow (gRPC)Lua: check+incrif allowedsave rulepublish versionwatch rules429 + shadow hitsAPI clientsAPI gateway + RLfilterRate limit serviceBackend servicesRules admin APIKafka: limit eventsRedis ClustercountersConfig push (etcd)ClickHouse dashboardsRules DB (Postgres)
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Check a request

  1. 1Request with API key k1 arrives at the gateway; the RL filter builds descriptors (api_key=k1, ip, route).
  2. 2The filter calls the rate limit service (or a co-located sidecar library) with all descriptors in one call.
  3. 3The service matches descriptors to rules held in memory, e.g. 'paid tier: 10,000/min per api_key' and 'POST /payments: 50/s per api_key'.
  4. 4For each matched rule it runs a Lua script on the Redis shard for that key; scripts for different shards run in parallel, same shard pipelined.
  5. 5The sliding-window script reads current and previous window counts, computes the weighted count, and increments only if under the limit (atomic).
  6. 6The tightest result wins: if any enforce-mode rule denies, the gateway returns 429 with Retry-After and X-RateLimit headers.
  7. 7If allowed, the request goes to the backend; shadow-mode denials are only logged to Kafka.
  8. 8If Redis does not answer within 5 ms, the rule's fail_mode decides: fail open for general APIs, fail closed (or local limiter) for OTP/login.

Change a rule

  1. 1An engineer updates a rule in the admin API, first in shadow mode.
  2. 2The rule is saved in Postgres with an audit row, and a new rules version is written to etcd.
  3. 3All rate limit service instances watch etcd, load the new rule set within seconds, and swap it atomically in memory.
  4. 4Dashboards show shadow hits; after review the rule is switched to enforce.

7 Deep dives

Choosing the algorithm

Fixed window counter (count per calendar minute) is the cheapest: one INCR with TTL. Its flaw is the boundary burst: a client can send 100 at 12:00:59 and 100 at 12:01:00, so 200 in two seconds. Sliding window log stores every request timestamp in a sorted set and is exact, but memory grows with the limit (10,000/min means 10,000 entries per key), so it is too expensive at 50M keys. Sliding window counter keeps the current and previous window counts and estimates: count = current + previous × (overlap fraction). It is two small counters per key and Cloudflare reported that on real traffic only about 0.003% of requests were wrongly allowed or limited; this is the best default for 'N per minute' quotas. Token bucket stores tokens and last refill time; each request refills tokens lazily (elapsed × rate, capped at burst) then takes one. It allows controlled bursts and supports cost-based limits (an LLM call costs tokens equal to estimated usage). Leaky bucket smooths output to a constant rate and is really a queue, useful for protecting a downstream with fixed capacity, such as an SMS provider. Say which you choose and why; many systems use token bucket for per-second burst limits and sliding window for per-minute or per-hour quotas.

Atomicity and the race condition

A naive implementation does GET count, compare, then SET count+1 from the API server. Two gateways can both read 99, both allow, both write 100, and the limit leaks. Under 1M req/s this is not rare. The fix is to make check-and-increment one atomic operation on the server holding the data. In Redis, a Lua script runs atomically on the shard: it reads the counters, decides, increments and sets TTL in one step, with no other command in between. For a simple fixed window, INCR is atomic by itself (INCR, then if result == 1 set EXPIRE; compare result to the limit). Keep all keys used by one script on one shard using a hash tag like rl:{k1}:..., because Redis Cluster rejects multi-key scripts across slots. Clock source matters too: use Redis server time (TIME command inside the script) or a monotonic time sent by the caller, not each gateway's wall clock, so windows align. Avoid distributed locks here: a lock per request would double the latency and become its own bottleneck.

Scaling past one Redis round trip per request

At millions of requests per second, even 0.5 ms per Redis call adds up, and very hot keys (one big customer sending 50k/s) overload a single shard. Two techniques help. First, local pre-aggregation: each gateway keeps a local counter and syncs with Redis every 50-100 ms or every N requests, adding its local count in one INCRBY. Decisions between syncs use the last global count plus local count. You trade a small overshoot (bounded by gateways × sync interval × rate) for 10-100× fewer Redis calls; Stripe and Envoy-style designs use variants of this. Second, for very large limits, split the key into K sub-counters (k1#0..k1#7) spread across shards, each with limit/K, chosen at random per request; the sum is approximately right. For strict limits (OTP: 5/hour/phone) keep the exact central check, since the volume per key is tiny. A clear senior point: accuracy needs differ by rule, so the design supports both an exact path and an approximate fast path.

8 Trade-offs

Centralised service vs library in each gateway

A central gRPC service is easy to update and to reason about, but adds a network hop. A library or Envoy filter inside each gateway talking directly to Redis removes a hop and a failure point, but spreads rule logic across many deployments. A common middle ground is a sidecar or in-gateway filter with rules pushed from a central config service, which is how Envoy's global rate limit works (it calls an external rate limit service) and what most API gateways offer.

Fail open vs fail closed

If Redis is down, failing closed blocks all traffic, turning a cache outage into a full outage. Failing open keeps the business running but removes protection exactly when you may be under attack. Choose per rule: fail open for general API quotas, fail closed or fall back to a conservative local in-memory limit (limit ÷ number of gateways) for OTP sends, login attempts and payment creation, where abuse costs real money (each OTP SMS costs ~₹0.15-0.25).

Exact vs approximate counting

Exact central counting gives correct limits but costs one Redis round trip per check and suffers on hot keys. Approximate local batching scales far better but can overshoot by a few percent. For customer-facing paid quotas, a small overshoot is usually acceptable; for fraud and security limits it is not.

9 Bottlenecks

Hot key on one shard

A single enterprise customer at 100k req/s sends all increments to one Redis shard. Use local pre-aggregation or split counters across shards. Also add a per-gateway local limiter as a first line, so obviously over-limit traffic is dropped without any Redis call.

Attack traffic with random keys

A DDoS with millions of random IPs creates millions of new keys and fills Redis memory. Keep TTLs short, put IP-level limiting at the CDN/WAF layer, and cap memory with an eviction policy (volatile-ttl) so the limiter degrades gracefully.

10 Failure handling

Redis shard failure

Redis Cluster promotes a replica within seconds. Counters written since the last replication may be lost, so a few clients get a little extra quota; acceptable. During failover, the per-rule fail_mode applies and a local in-memory limiter keeps the worst abuse in check.

Bad rule pushed (limit set to 0)

This would block all traffic for a service. Prevent with validation (limits above a floor for broad rules), shadow mode first, staged rollout of rule versions, and a one-click rollback to the previous version in etcd.

Multi-region traffic

Each region has its own Redis. A global limit of 1,000/min can be split per region by traffic share (e.g. 600 Mumbai, 400 Hyderabad) and rebalanced every minute, or regions sync counts asynchronously and accept short-term overshoot. A single global Redis would add 30-100 ms cross-region latency, which breaks the 2 ms budget.

11 What impresses

  • Explains the fixed-window boundary burst and quantifies sliding-window memory trade-offs
  • Identifies the read-modify-write race and fixes it with a Lua script, not a lock
  • Different fail modes for OTP/login vs general APIs, with the cost reason
  • Local batching with a bounded overshoot formula
  • Shadow mode and staged rule rollout to avoid self-inflicted outages
  • Cost-based limits (weight requests by cost, e.g. LLM tokens) instead of only counting requests

12 Mistakes

  • Keeping counters in each server's memory and calling it a global limit
  • GET then SET from the application, creating a race condition
  • Using a sliding window log for large limits and running out of memory
  • Not deciding what happens when Redis is unavailable
  • Making every check cross-region
  • Forgetting to tell clients when to retry (Retry-After, headers)

13 Follow-ups

Q1How do you limit by cost, for example LLM tokens per minute, when cost is only known after the response?
Q2How would you implement a global limit across three regions?
Q3How do you stop a single misbehaving tenant from affecting others in a multi-tenant SaaS?
Q4How would you test that the limiter is accurate under load?
Q5What changes if limits must be exact for billing?
Q6How do clients avoid thundering herd retries after a 429?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free