← System design

Infrastructure

Design Rate Limiter

A rate limiter answers one question on every request: has this caller used up its allowance? Answering it on one server is a counter in memory. Answering it across two hundred gateway instances in three regions, in about a millisecond, without becoming the thing that takes the API down, is where the design work is. The limiter has to be fast, shared, roughly fair and very hard to break.
Core: token bucket (GCRA) in one atomic call1.2M requests a second at peakUnder 2 ms added per requestFails open, never takes the API down

01

What the limiter must do

02

What the limiter must do

Functional
  • Limit requests per API key, user, IP address or tenant, with different limits per endpoint.
  • Support several limits on the same caller at once, for example 50 per second, 2,000 per minute and 1M per day.
  • Allow short bursts above the steady rate, up to a configured bucket size.
  • Reject excess requests with HTTP 429 and headers telling the client when to retry.
  • Change limits per customer or plan without a deploy, effective within a minute.
  • Shadow mode: evaluate a new rule and log what it would block without blocking anything.
Non-Functional
  • Adds under 2 ms at p99 to each request.
  • If the limiter's storage fails, the API keeps serving: fail open with local protection.
  • Limits hold across every gateway instance; a client cannot multiply its allowance by spreading requests over hosts.
  • Overshoot of up to about 5% under contention is acceptable; large overshoot is not.
  • Scales with traffic by adding storage shards and gateway instances, with no single hot node.
Ask what the limit protects

Find out whether the goal is fairness between customers, protecting a fragile backend, enforcing a paid quota, or stopping abuse. Each points to a different design. A billing quota must be close to exact and can tolerate a few milliseconds. Backend protection cares about concurrency more than rate. Abuse defence at the edge is coarse and must be extremely cheap. Also ask where the limit is enforced: at the edge, the API gateway, or inside each service.

02

Sizing it

03

Sizing it

Assume a public API platform that peaks at 1.2 million requests per second, spread across 220 gateway instances in three regions. During any hour, about 40 million distinct callers are active, counting API keys, signed-in users and anonymous IPs. On average each request is checked against two rules.

QuantityWorkingResult
Peak request rateassumption1.2M / s
Limiter checks at peak1.2M x 2 rules, batched into one round trip per request2.4M rule evaluations / s, 1.2M calls
Per-shard capacityone Redis primary running a short scriptabout 120,000 calls / s
Shards needed1.2M / 120,000, x 2 headroom, across 3 regionsabout 24 primaries, each with a replica
State per rule per callerkey name about 40 B + one 8 B timestamp + overheadabout 90 B
Hot state in memory40M callers x 3 rules x 90 Babout 11 GB, well spread
Latency budgetsame-zone Redis round trip about 0.3 ms, script about 0.05 msunder 1 ms typical, 2 ms p99
Rejected requests on a normal dayabout 1.5% of trafficabout 18,000 / s at peak
Decision log volumesample 1% of allows, keep all rejectsabout 30,000 events / s
The constraint that matters

Memory and storage are trivial: the entire state fits on one large machine. The design pressure comes from doing a network round trip on every request without adding noticeable latency, and from the fact that the limiter sits in front of everything, so its availability caps the API's availability unless you plan for it failing.

03

Interfaces

04

Interfaces

The limiter has two interfaces. Internally, gateways call a check function with the caller's identity and the route. Externally, clients see the outcome through status codes and headers. A small admin API manages rules.

POSTinternal: limiter.checkCalled by the gateway on every request
{ "subject": { "api_key": "ak_live_7Hc", "ip": "203.0.113.24", "tenant": "t_4410" }, "route": "POST /v1/payments", "cost": 1 // heavy endpoints can cost more } // response, decided by the strictest matching rule { "allowed": true, "rule": "payments-per-key-second", "limit": 50, "remaining": 17, "reset_ms": 660 }
GET/v1/payments (rejected)What the client receives over the limit
HTTP/1.1 429 Too Many Requests RateLimit-Limit: 50 RateLimit-Remaining: 0 RateLimit-Reset: 1 Retry-After: 1 { "error": "rate_limited", "rule": "payments-per-key-second" }
POST/admin/v1/rulesCreate or update a rule
{ "id": "search-per-tenant-minute", "match": { "route": "GET /v1/search*", "plan": "growth" }, "key_by": "tenant", "rate": 3000, "per": "1m", "burst": 600, "mode": "shadow" // log only, flip to "enforce" later }
Headers are part of the design

A well-behaved client that knows when its allowance resets will wait instead of retrying in a loop. Retry-After turns rejected traffic from a storm into a schedule. Without it, clients guess, usually too early, and each guess is another request you must reject.

04

Where it sits and how it is built

05

Where it sits and how it is built

Request path through edge, gateway and shared countersDiagram
flowchart LR
	C["Client"] --> EDGE["Edge: coarse per-IP limits"]
	EDGE --> GW["API gateway instance"]
	GW --> RC["Local rule cache"]
	GW --> LF["Local fallback limiter"]
	GW -->|"one script call"| RS[("Redis cluster, sharded by key")]
	GW -->|allowed| SVC["Backend services"]
	GW -->|rejected| R429["429 with headers"]
	CFG["Rule config service"] -. "push changes" .-> RC
	GW -. "decision events" .-> LOG[["Event stream"]]
	LOG --> DASH["Dashboards and alerts"]
	SVC --> CL["Concurrency limiter in service"]

Enforcement happens in layers, each with a different job. The edge, a CDN or load balancer, applies very coarse per-IP limits and absorbs floods before they reach our network. The API gateway holds the real limiter: it identifies the caller, finds the rules that match the route and plan, and makes one call to a shared Redis cluster to check and update every matching bucket atomically. Inside important services, a separate concurrency limiter caps how many requests run at once, which protects the service when requests suddenly get slow rather than numerous.

Rules live in a config service backed by a database, and every gateway keeps them in memory, refreshed by a push channel with a periodic full reload as a backstop. So looking up which rules apply costs nothing at request time. The only network hop is the Redis call, kept in the same availability zone, and pipelined so that all of one request's rules travel together.

Gateway limiter

Middleware that builds keys like rl:payments-per-key-second:ak_live_7Hc, calls Redis, and sets headers. It holds a strict 5 ms timeout on that call.

Redis cluster

Keys are spread by hash slot, so load spreads evenly across shards. Each check is a short Lua script, which makes read, compute and write a single atomic step.

Rule config service

Stores rules with versions, validates them, and pushes changes to every gateway. Shadow mode lets a new rule run against real traffic before it can block anyone.

Local fallback limiter

An in-memory bucket per key per instance, set to the global limit divided by the instance count. It is used only when Redis is slow or down, so failing open still has a ceiling.

Decision stream

Every reject and a sample of allows go to an event stream. Support teams can see why a customer was limited, and alerts fire if reject rates spike for everyone at once, which usually means a bad rule.

Service concurrency limits

A cap on in-flight requests per service, adapted to observed latency. It catches overload that a rate limit cannot see, such as a slow database making each request hold resources longer.

05

Deep dive: choosing and implementing the algorithm

06

Deep dive: choosing and implementing the algorithm

What you are choosing between

Every algorithm answers "how many requests has this caller made recently" in a different way, trading memory per key, accuracy at window edges and how bursts are treated. In an interview, be able to describe all five briefly and defend one.

AlgorithmState per keyEdge accuracyBurstsWhere it fits
Fixed window counterOne integerUp to 2x at a boundaryUncontrolled at edgesDaily quotas where precision at midnight does not matter
Sliding window logOne timestamp per requestExactExactLow limits that must be precise, such as login attempts
Sliding window counterTwo integersClose, assumes even spreadSmoothedGeneral per-minute limits
Leaky bucket queueA queue of pending requestsExact output rateDelayed, not allowedSmoothing traffic toward a fixed-rate downstream
Token bucket as GCRAOne timestampExact for rate and burstExplicit burst sizePer-second and per-minute API limits
Our choice

A token bucket implemented as the generic cell rate algorithm. It gives exact rate and burst control with a single number per key, and the update is a few arithmetic operations, which suits an atomic script.

How GCRA works

A token bucket is usually explained as a bucket that refills at a steady rate and spends one token per request. GCRA stores the same thing differently: instead of a token count plus a last-refill time, it stores one value, the theoretical arrival time (TAT), meaning the moment at which the bucket would be completely full again if no more requests arrived. Each request pushes TAT forward by one emission interval, which is the period divided by the rate. A request is allowed if, after pushing, TAT is no further ahead of now than the burst allowance.

Take a limit of 50 per second with a burst of 10. The emission interval is 20 ms and the burst tolerance is 10 x 20 ms = 200 ms. A caller who has been quiet has TAT in the past, so the first request sets it to now plus 20 ms. Ten rapid requests push it to now plus 200 ms, all allowed. The eleventh would push it to 220 ms ahead, beyond the tolerance, so it is rejected and the caller is told to retry in 20 ms. As time passes, now catches up with TAT and capacity returns smoothly, with no window boundary to exploit.

GCRA check as one atomic Redis scriptCode
-- KEYS[1] = bucket key, ARGV = interval_ms, tolerance_ms, cost local t = redis.call('TIME') -- server clock, not the gateway's local now = t[1] * 1000 + math.floor(t[2] / 1000) local interval = tonumber(ARGV[1]) local tolerance = tonumber(ARGV[2]) local tat = tonumber(redis.call('GET', KEYS[1]) or now) local new_tat = math.max(tat, now) + interval * tonumber(ARGV[3]) if new_tat - now > tolerance then return {0, new_tat - now - tolerance} -- rejected, retry after this many ms end redis.call('SET', KEYS[1], new_tat, 'PX', math.ceil(new_tat - now)) return {1, math.floor((tolerance - (new_tat - now)) / interval)} -- allowed, remaining
Decision path for one requestDiagram
flowchart TD
	REQ["Request reaches gateway"] --> ID["Identify caller: key, user, IP, tenant"]
	ID --> RULES["Match rules from local cache"]
	RULES --> HOT{"Key flagged as very hot?"}
	HOT -->|yes| LOCAL["Spend from locally leased tokens"]
	HOT -->|no| LUA["Run GCRA script for all rules"]
	LUA --> TO{"Redis answered within 5 ms?"}
	TO -->|no| FB["Use local fallback bucket"]
	TO -->|yes| DEC{"Every rule allowed?"}
	LOCAL --> DEC
	FB --> DEC
	DEC -->|yes| PASS["Forward with RateLimit headers"]
	DEC -->|no| REJ["429 with Retry-After"]

Correctness under concurrency

The naive implementation reads a counter, decides in the gateway and writes the new value back. Two gateways doing that at the same instant both read the same value and both allow, which lets a determined client exceed its limit by the number of instances. Running the whole decision as a Lua script on the shard that owns the key removes the race, because Redis executes each script to completion before the next command. Using the shard's clock inside the script also removes clock skew between gateways from the equation.

For a handful of extremely hot keys, such as one huge tenant sending 80,000 requests per second, even a single shard round trip per request is wasteful and puts all that load on one node. For those keys the gateway leases a batch of tokens, say 200 at a time, using the same script with a larger cost, and spends them locally. Overshoot is bounded by the unspent leases across instances, which for this tenant is a fraction of a percent of its limit.

06

Decisions and their costs

07

Decisions and their costs

Chosen
Limit at the gateway, cap concurrency in services

The gateway sees every request and the caller's identity, so per-customer limits belong there and only need to be built once. Services add their own in-flight caps because they know their own capacity, which the gateway cannot.

Each layer limits what it understands
Rejected
Every service implements caller limits itself

Teams end up with different algorithms, different keys and different headers. A client's limits would depend on which service it happens to call, and rejected traffic still reaches every service before it is turned away.

Inconsistent and late
Chosen
Fail open with a local ceiling

If Redis is unreachable, the gateway switches to per-instance buckets sized at the global limit divided by instance count. The API stays up, limits stay roughly right, and an outage of the limiter is not an outage of the product.

Availability first, still protected
Situational
Fail closed

Rejecting everything when the limiter is down turns a cache problem into a full outage. The exception is security-sensitive limits, such as password attempts, where letting traffic through unchecked is worse than refusing it.

Only for login and similar
Chosen
Regional counters with split budgets

Each region enforces its share of a global limit, sized from that caller's recent traffic mix and rebalanced every 10 seconds. Checks stay in-region at sub-millisecond latency, at the cost of some slack when traffic shifts suddenly.

Fast, approximately global
Rejected
One global counter for every request

Exactly right, and 70 to 150 ms slower per request for anyone far from the counter's home region. It also makes a single region a dependency for the whole world.

Latency and blast radius
Chosen
GCRA token bucket

One 8-byte value per key, exact burst control, no window edges, and a natural retry time to put in headers. It is a little harder to explain than a counter, which is a small price.

Precise and compact
Alternative
Sliding window counter

Two counters per key, weighted by how far into the current window you are. Easy to reason about and close enough for most uses, but its accuracy relies on traffic being spread evenly within each window.

A fine second choice

07

Failure modes

08

Failure modes

🏁
Read-then-write races across gateways

An early version reads the count with one command and writes with another. Under load, a client firing from many connections gets several times its limit, because instances keep reading the same stale value.

Do the read, the decision and the write in one atomic script on the shard that owns the key, using the shard's own clock.
🔌
A Redis shard becomes unavailable

A primary fails and its replica takes several seconds to promote. Every key on that shard times out, and if the gateway waits for the default client timeout, API latency jumps for a twenty-fourth of all callers.

Keep the limiter's timeout at a few milliseconds, then use the local fallback bucket for that key. A circuit breaker per shard stops retries from piling up, and the failover happens quietly behind it.
🌀
Clients retry in a tight loop

A customer's batch job gets 429s and retries immediately on every thread. Rejected requests now outnumber real ones ten to one, and the limiter itself is the busiest thing in the system.

Always send Retry-After. Make rejections as cheap as possible, with no backend call. Publish client libraries that back off with jitter, and for persistent offenders apply a stricter penalty rule at the edge.
🌍
A client multiplies its limit across regions

With fully independent regional counters, a client that sends traffic to all three regions gets three times its allowance.

Split each global limit into regional budgets based on where the caller's traffic actually arrives, and reconcile every few seconds through a small aggregation job. Strict limits, such as billing quotas, are checked against a single home region.
🏫
The wrong key punishes innocent users

An anonymous per-IP limit blocks a whole university, or a mobile carrier that puts thousands of phones behind one address, while a bot spreading over many IPs is never limited.

Key by the most specific identity available: API key, then user, then device, and IP only as a last resort with generous limits. Raise limits for known shared ranges and use behavioural signals for bots.
📝
A bad rule blocks a big customer

Someone ships a rule with a missing zero. Within a minute, one of the largest customers sees most requests rejected.

New rules start in shadow mode, changes are versioned with instant rollback, and an alert fires when any single rule's reject rate jumps sharply after a change.

08

Mistakes to avoid

⚠

Mistakes to avoid

🚫
Keeping counters only in each server's memory

With 220 gateway instances behind a load balancer, a client effectively gets 220 separate allowances, and the limit becomes meaningless.

Instead: keep shared state in a fast central store and use in-memory buckets only as a fallback.
🚫
Storing a timestamp for every request on high-volume limits

A sliding log for 1,000 requests per minute per caller means thousands of entries per key across millions of keys.

Instead: use a token bucket or window counter that keeps a constant few bytes per key.
🚫
Rejecting without telling the client when to come back

Clients guess, retry too soon and turn one rejected request into a stream of them.

Instead: return 429 with Retry-After and the remaining allowance on every response.

09

Interview advice

09

Interview advice

1

Ask what is being protected. Fair use, backend safety, billing and abuse each lead to different keys, accuracy and failure behaviour. Starting there shows you design for a purpose, not for a buzzword.

2

Compare the algorithms quickly, then commit. Spend a minute on all five and their trade-offs, then pick one and implement it in detail. Long surveys without a decision score poorly.

3

Show the race and fix it. Describe two gateways reading the same count, then move the decision into an atomic script. Interviewers almost always probe this.

4

Decide fail open or fail closed out loud. Explain why most limits fail open with a local ceiling, and why login limits might fail closed.

5

Handle multiple regions. Mention split budgets and periodic reconciliation, and acknowledge the small overshoot it allows.

6

Mention concurrency limits. A rate limit does not protect a service whose requests just got ten times slower. A cap on in-flight work does. Few candidates make this distinction.

7

Finish with the client contract. Show the 429 and the headers you send, and how a well-behaved client uses them.

Response a limited client should see429 Too Many Requests
RateLimit-Limit50Requests allowed in the current policy window
RateLimit-Remaining0What is left right now; also sent on successful responses
RateLimit-Reset1Seconds until enough capacity returns for one request
Retry-After1The standard header that generic HTTP clients and proxies respect
Why headers earn points

Sending the remaining allowance on every response, not just on rejections, lets clients slow down before they hit the wall. That cuts reject volume more than any server-side tuning, and it is the detail interviewers say they rarely hear.

10

How the limiter grows

11

How the limiter grows

Stage 1: one or two servers
In-process buckets

A library in the app keeps a token bucket per key in memory. With two instances, limits are off by up to 2x, and nobody minds at this size.

Stage 2: a fleet behind a load balancer
Shared Redis and an atomic script

Per-instance limits become meaningless. Move state into one Redis primary with a replica and run the check as a script. Add 429 headers and a simple rules file.

Stage 3: hundreds of thousands of requests a second
Sharded cluster, config service and fallbacks

The single Redis primary runs hot. Shard by key, move rules into a config service with shadow mode, add local fallback buckets and token leasing for the hottest keys.

Stage 4: several regions
Regional budgets and edge enforcement

Counters live in each region with budgets rebalanced every few seconds, coarse limits move to the edge, and services add adaptive concurrency limits for overload protection.

Watch and read

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.