Hotshard

Design a Rate Limiter

Cap how many requests each client may make in a given amount of time, decide allow-or-reject for every single request fast enough that a real user never feels the check, and return a clear answer when a client is over the limit.

Predict ~1.5 h · Read ~35 min

The problem i

A rate limiter is the thing that sits in front of a service and says "you have had enough for now." A client — which might be a person's phone, but is just as often a server, a script, or a paying customer's integration — is allowed some number of requests in some window of time. The eighty-first request in a minute where the limit is eighty gets turned away. That is the whole product in one sentence, and on one machine it is almost trivial. Everything hard about it shows up only when there is more than one machine.

Here is the first hard fact, and it is the one the whole design turns on. The limit is global. A client that is allowed eighty requests a minute is allowed eighty across the entire service, not eighty per machine. But a service at scale is a fleet of interchangeable servers behind a load balancer, and the load balancer sprays each client's requests across all of them. So the count that decides "has this client had eighty yet" cannot live inside any one server. It has to live in one place that every server asks. Keeping that one shared count correct, while thousands of servers read it and change it at the same instant, is the actual problem. The algorithm you pick is a detail next to it.

Here is the second hard fact. The check happens on every request, before the request is served, while the user waits. That puts it directly on the latency budget of everything the service does. A limiter that adds ten milliseconds to every call has made the whole product ten milliseconds slower, for the ninety-nine percent of requests that were never going to be blocked. So the check has to be cheap, and it has to be cheap even in the slowest cases — at the ninety-ninth percentile, the slowest one request in a hundred — not just on average.

At the scale we design for, the service handles about three million rate-limit checks a second at peak. That number is a planning figure, chosen because it is large enough to break the obvious first design, and we will watch exactly where it breaks. A check that reaches our shared counter should come back in about a millisecond, because it is a single round trip inside our own datacenter. The client's round trip across the public internet adds about thirty milliseconds on top of that, and that part is physics rather than architecture, so every latency number in this design is measured inside our building.

Functional requirements what it must do

  • Decide allow or reject for each request, against a per-client limit.
  • Count each client against one global limit, no matter which server it hits.
  • Tell a rejected client what happened and when to come back.

Non-functional requirements what it must be

  • The check is nearly free on the allowed path.
  • The limiter stays up, and it fails in a chosen direction.
  • The count is correct under concurrency.

Out of scope left out on purpose

  • A perfectly consistent global count agreed by consensus
  • Stopping a network flood — SYN floods, volumetric attacks at the packet level

How the design works

The finished design, explained one piece at a time: first the contract the system serves, then the smallest version that works, then the version that survives scale — walked use case by use case, one deep dive at a time, with the finished diagram at the end.

The API

The whole product is these endpoints. Every box in the design below exists to serve one of them.

ANY (every request)The rate limiter adds no endpoints of its own. It wraps whatever the service already offers and either lets a request through or answers it itself. On an allowed request the call proceeds as normal, optionally carrying advisory headers that tell the client how much of its budget is left. On a rejected request the limiter answers 429 Too Many Requests itself, without ever touching the service, and includes a Retry-After header saying how many seconds to wait.
contract ▾
# The limiter wraps the service — it adds one contract to every response.

## Allowed — the request proceeds to the service
 200 OK
  X-RateLimit-Limit: 5000        # advisory only (a convention, not a web standard)
  X-RateLimit-Remaining: 4987
  X-RateLimit-Reset: 1699999999

## Rejected — the limiter answers, the service is never touched
 429 Too Many Requests
  Retry-After: 12                # seconds to wait; a well-behaved client waits exactly this long
  Cache-Control: no-store        # a cached "over the limit" would keep rejecting a client now under it
  { "error": "rate_limited", "retry_after": 12 }

# Only 429 + Retry-After are the ratified contract (RFC 6585). The X-RateLimit-* headers are a
# convention several companies invented separately; an IETF working group is drafting a standard.

part 1 The single-server version

Start with the smallest thing that works: one server, answering requests, with the limiter built into it.

First, the words. Rate limiting is capping how many requests a client may make in some window of time. A client is whoever is making the requests — identified somehow, and picking that somehow is the first real decision, so leave it loose for a moment and just call it a client. When a client goes over its limit, the server rejects the request with a 429, the HTTP status code that means too many requests. Those three words carry the whole product: a client, a limit, and a rejection.

The mechanism on one machine is a counter. Keep a small count for each client. Every time a request arrives, find that client's count, check it against the limit, and either increment it and allow the request, or leave it and reject with a 429. Reset the counts every window — say, back to zero at the top of each minute. That is the entire day-one design, it lives in the server's memory, and it is genuinely fast. Looking up a number and comparing it to a limit is a few nanoseconds. The limiter adds nothing a user could feel.

clientservercounter in memory
The whole system on day one.
Following a request
A request
  1. the request crosses the public internet; the server checks this client's count in its own memory and either increments it and allows, or answers 429tens of ms

One hop dominates every path: the client's request crossing the public internet to reach the datacenter. That hop is tens of milliseconds — call it about 30 — and it is set by physical distance, not by anything we build. Everything after it happens inside a single datacenter, where the network between two machines is well under a millisecond — so those numbers are the actual work, not the wait: a durable write to disk about 10 ms, an indexed read about 5 ms. A hop between two datacenters would cost 10–100× the in-datacenter number, which is why the whole path stays in one region.

At launch traffic — a handful of clients, a few requests a second — this machine is bored, and there is nothing wrong with the design. It just does not survive success, and it fails in three separate ways. The first is that the counts live in this one server's memory, so if the server restarts, every client's count resets to zero and everyone gets a fresh full allowance at a bad moment. The second is the arithmetic: three million checks a second is not a busy version of one server, it is a different design that needs many servers.

And the third is the one that matters most, because it is not fixed by the obvious move. When you run many servers to handle the volume, you put a load balancer in front of them, and the load balancer spreads each client's requests across all of them. Now each server has its own counter in its own memory, and each server only sees the slice of a client's traffic that happened to land on it. A client that is supposed to get eighty requests a minute, hitting ten servers, gets roughly eighty from each — eight hundred in total — and every server is certain it is under the limit. The limit is not being enforced at all. That third failure is the reason the rest of this design exists, and the next section is where it gets fixed.

part 2 The version that survives scale

One thing changed before any of the interesting decisions, and it is worth naming first so the diagrams make sense. At this volume the service runs on a fleet of App servers that keep nothing in between requests. Any one of them can answer any request, which makes them interchangeable, and that property is called being stateless. In front of them sits a load balancer, which spreads incoming requests across the servers. The load balancer is commodity infrastructure. It is a redundant pair, one box handles tens of thousands of connections a second, and it needs no decision of its own — it just has to be there, and it must not be the one thing that can die. (On the diagram it is labelled LB, for space; in prose it is the load balancer.)

This section walks the design by use case, in the order you actually have to decide things: first what to count and how to count it, then how to keep that count correct across the fleet, then where the check should physically run, then how to survive one client that tries to melt it, and finally when to throw the whole approach away. The finished diagram waits at the end.

Two ways through this section

The five decisions below are the heart of this design, and there are two ways to take them. In the design room you make each one yourself and the board reacts to your pick. The write-ups after this point cover the same decisions in full — read them instead, or afterwards as reference.

use case 1 The first decisions

The day-one server waved past three questions, and not one of them needs a new machine to answer. They are about how the check behaves, not what runs it, so we settle them before any box on the board moves.

deep dive 1 The first decisions
The question

Before any box moves, three things have to be pinned, and they are the three the day-one server hand-waved: how we tell one client from another, how we count its requests, and what we send back when it is over. None of them need a new machine — all three are the shape of the check itself. Start with the client. The limit is per-client, so everything depends on how a client is identified: this is the limiter key, the value we count against. The obvious key is the caller's IP address — always present, needs nothing — but an IP is not a caller. A whole office, a whole carrier, a whole cloud region can sit behind one address. The opposite key is the authenticated identity — a user id or API key — which names an actual account, so the limit lands exactly where it should.

The day-one server used a plain counter that resets every minute. That is a real algorithm — a fixed window — and it has a specific, well-known flaw. Because the window snaps back to zero at a fixed instant, a client can send its whole allowance in the last second of one window and its whole allowance again in the first second of the next: two windows a fraction of a second apart, so the client just got about twice its limit in an eyeblink. That factor of two is the flaw every better algorithm exists to fix.

The exact fix is a sliding window log: store the timestamp of every request, and on each new request drop the ones older than the window and count what is left. Perfectly accurate — the window really slides, so there is no boundary to game — but the cost is memory. You store one entry per request, per client, even for requests you rejected. Figma measured this and found the log costs about ten times the memory of the cheaper approach. Ten times the memory to remove a flaw that lets someone send double for one second is a bad trade, which is why nobody ships the exact log at scale.

That leaves two that differ in one property: whether a client may burst. A leaky bucket — what nginx does — smooths the outflow to a fixed, steady rate and never lets a burst through. A token bucket does the opposite where it matters: a bucket holds some tokens and refills at a steady drip; each request takes one, and a client that has been quiet can spend a burst all at once, up to the bucket's size, then is held to the drip rate. The acceptable alternate is Figma's sliding-window counter, the approximate cousin of the log — chop the window into slices, keep a count per slice, add up the trailing slices — which you pick if you want smooth enforcement without a burst allowance and can accept up to a minute of leniency at the edge.

Token bucket — allow a burst up to a size, then enforce the average. Token bucket. The deciding reason is that real traffic is bursty and mostly fine — a user opens an app and it makes twelve calls at once; a nightly job wakes up and syncs. Punishing those with a leaky bucket's flat rate makes the product feel broken for behaviour that was never abusive. The token bucket allows the burst up to a set size and enforces the average after it, which is what almost every client actually needs. It is also the answer two independent production systems reached: Stripe runs its rate limiter on the token bucket, and AWS API Gateway does too, exposing exactly two dials — a rate, which is the refill speed, and a burst, which is the bucket size.

And what we say when a client is over. A rejection has to be an answer the client's code can act on, or the client just retries in a tight loop and makes everything worse. The status code is 429 Too Many Requests, and it may carry a Retry-After header saying how many seconds to wait before trying again. Those two things are the whole ratified contract, and 429 responses are explicitly not to be cached — a cached over-the-limit would keep rejecting a client that is now under it. You will also see extra headers like X-RateLimit-Remaining, and they are genuinely useful, but they are a convention several companies invented separately, not a web standard — so send them, but do not call them a standard.

Under the hood — how it works

The token bucket does not store a running countdown something has to tick down every moment — that would need a timer per client, which is millions of timers. It stores two numbers: how many tokens were in the bucket the last time this client was seen, and when that was. On the next request it computes how many tokens should have dripped in since then — elapsed time times the refill rate, capped at the bucket size — adds them, and checks for a token to spend. The refill is lazy, computed on read, so a client that goes quiet for an hour costs nothing until it comes back.

This matters later: a client's whole state is a couple of numbers, about fifty bytes, which is why the memory sizing a few use cases from now comes out so small.

The limiter-key rule, stated once: use the API key or user id when the request carries one, and fall back to the IP only for anonymous traffic, on a tighter limit — precisely because you cannot tell who is behind it. Once you know who is calling you can trust them with far more, because a bad key hurts only its own budget, while a bad IP might be hiding a thousand good callers behind it.

⚡ From production

Stripe documents its rate limiter as a token bucket, and AWS API Gateway exposes the same two dials — a steady-state rate and a burst — which is the token bucket by another name. It is worth killing one myth: Stripe's engineering blog does not describe GCRA or redis-cell; it documents token bucket. Do not attribute an algorithm to a company that did not publish it.

Stripe — Scaling your API with rate limiters
use case 2 One counter, many nodes

With the algorithm settled, the counter needs a home — and the one it has is wrong. A per-server counter hands a client one full limit for every server it happens to hit. The obvious fix is to move the count somewhere shared. This use case shows why that move, on its own, still leaves it wrong.

deep dive 2 The race
The question

Every app server is running the token-bucket check against a counter in its own memory, and that is broken in the way the single-server version warned about: a client hitting ten servers gets ten separate counters and ten times its limit. So the counter cannot live in the servers — it has to live in one place they all share. That much is forced. What is not obvious is that moving the counter to a shared place does not, by itself, make it correct. Put the counters in one shared, fast, in-memory store every app server talks to. Redis is the obvious choice and the right one: it holds a value by key in memory, a read or write is well under a millisecond, and the count for this limiter key is exactly a value by key. Now every server checks the same shared count, so the ten-servers problem is gone. Is the count correct?

Watch the race. Server A reads the count from the shared store: one token, allow. Server B reads at the very same instant — also one token, because A has not written its change back yet — so B also decides allow. Then both write the new count back. Two requests allowed on a budget of one, and it happened precisely because two servers read the same value before either of them wrote. At millions of checks a second, two requests for the same key landing between each other's read and write is not rare, it is constant.

The reflex fix is worth naming because it is the single most common wrong answer: Redis has an atomic increment, INCR, so use that — increment the count atomically and separately set the key to expire with EXPIRE. It looks correct and it is not, for two reasons. The small reason is that INCR and EXPIRE are two separate round trips, so a crash or interleave between them can leave a counter that was incremented but never given an expiry — and a counter that never expires never resets, so that client is limited forever. The large reason is that atomic increment solves the wrong half of the problem. Our check is not add one. It is read the count, decide whether it is under the limit, and only then add one. INCR makes the adding atomic, but the deciding still happens in the app server, after a separate read — and that is exactly where the race lives. Two servers can both read a safe value, both decide to allow, and both then atomically increment. The increment being atomic did not help, because the decision was already made on a stale read.

So just put the counter in Redis is not the fix either. The fix is to make the whole thing — read, decide, update — happen as one indivisible step, with nothing able to slip in between the read and the write.

One atomic Lua script — read, decide, write as one indivisible step. Run the entire read-decide-update as one atomic Lua script. Redis executes a small Lua script sent with EVAL as a single, indivisible operation: the script reads the count, checks it against the limit, and writes the new count, and while it runs nothing else touches that key. Server A's whole check completes before server B's read can even begin, so B reads the value A already wrote, sees zero tokens, and rejects. One request allowed on a budget of one, which is correct. The two round trips also collapse into one — the script sets the count and its expiry together, so there is no window where a counter exists without a reset.

Both sizings now, because they are about to disagree in an instructive way. Capacity first: a client's whole state is those two numbers plus the key and a short expiry — call it fifty bytes — so even ten million clients active at once is about five hundred megabytes, which fits one node with room to spare. Throughput is the other story: every request is one check, and a check is one round trip to this store, so three million checks a second against a single node that does about a million operations a second does not fit. This store is going to have to be split — not because the data is big, but because the traffic is. Two sizings, same box, opposite directions; when they disagree you take the larger, so the split is the next-but-one use case. For now one thing is settled and it is the important one: the count is correct.

Under the hood — how it works

Why a Lua script is atomic at all is a single sentence, and it is the thing that makes Redis the right store for this job. Redis runs commands on one thread. It does not process two commands at the same time; it takes one, runs it to completion, then takes the next. A Lua script sent to Redis is, to Redis, one command. So while the script runs, no other command runs — not for that key, not for any key. There is no lock to take and no coordination to arrange, because there is nothing to coordinate with.

The single thread that would be a scaling limit for a general database is exactly the property that makes the rate-limit check correct for free. It is also the ceiling to respect: one Redis node does on the order of a million operations a second because it is doing them one at a time, and that number is the reason the store has to be split a couple of use cases from now.

⚡ From production

Redis's own documentation for building a rate limiter names both halves of what this use case argued: that per-process counters break behind a load balancer, because the same client bypasses the limit by hitting different instances, and that scripting the check with EVAL keeps the read-decide-update cycle atomic so concurrent requests cannot double-spend. The store vendor and the fix are the same source.

Redis — Rate limiting pattern
clientLBredundant pairApp serversstateless · token bucketCounterstore · Redisatomic · Lua
The board after this deep dive — “A request” traced, hop by hop. The numbered steps below walk the same path.
A request
  1. the request crosses the public internettens of ms
  2. the load balancer hands it to any app server — they are stateless~1 ms
  3. the app server runs the read-decide-update as one atomic Lua script against the shared count, and gets back allow or reject — a script out, an answer back~1 ms
use case 3 Where the check runs

The counter is shared and the count is finally right. But notice where the check still runs: inside every app server, on the request's way in. That was never a real decision — it is just where the code sat when the counter was local. This use case asks where the check actually belongs.

deep dive 3 The gateway
The question

The count is correct and it lives in one shared store. But look at who is calling that store: every app server, on every request, before doing the actual work. We put the check there because that is where the code already was, back when the counter lived in the server's memory. Now that the counter has moved out, the check does not have to run in the app servers at all. A rate-limit check has one job — reject the requests that are over the limit as early and as cheaply as possible, so the service behind it never has to deal with them. So where along the path from the client to the app servers should that check physically run?

The first option is where it is right now: in the app servers themselves, in-process. Simple, and it has one real virtue — the app server knows everything about the request, so it can make rich decisions. But a request that is going to be rejected has already travelled all the way to an app server, taken a connection, and woken application code, just to be told no. The servers you are trying to protect are doing the work of turning traffic away, which is the work you least wanted them doing under load.

The second option is a dedicated rate-limit service: a separate fleet whose only job is the check, called by the app servers before handling a request. Clean to reason about and easy to scale on its own, and some large systems run exactly this. The cost is a network round trip — every request makes an extra hop to the rate-limit service and back before anything happens, on the latency budget of every call, including the ones that were always going to be allowed. You have bought organizational tidiness with latency every user pays.

The third lives inside a service mesh: a sidecar proxy deployed next to every service instance, each running the check. It has the gateway's core virtue — the check runs in a proxy, not in application code — but spread out, one proxy per service instead of one shared layer. That is the right shape when there is no single front door to put a check in front of, which is exactly what a mesh is for. For a public API that already funnels every request through one edge, it is more proxies to configure and operate for no added reach.

At the gateway — the edge every request already passes through. Run the check at the gateway — the reverse proxy or edge layer that already sits in front of the app servers, that every request already passes through. The reason is that the gateway is the only place where the check is both early and free. Early, because a rejected request never reaches the servers you are protecting — the entire reason those servers exist without ever seeing blocked traffic. Free, because the request already passes through the gateway, so the check adds no hop that was not already happening. This is what nginx does, what AWS API Gateway does, what Cloudflare does at the edge. The one honest limit is that the gateway sees the request as HTTP and not much more — the API key, the IP, the path, the headers, but not deep application state. For rate limiting that is exactly enough, because the limiter key is right there in the request.

And the failure choice the brief insisted on. The gateway is stateless, so a dead one just leaves the load balancer's rotation and the rest carry on. The counter store is the interesting failure: what happens to a request when the gateway cannot reach the counter store at all? There are only two answers, and you must pick one before it happens, never during. Fail open means allow the request; fail closed means reject it. For a rate limiter the usual choice is to fail open, because rate limiting is a protection, not a gate — taking the entire service down because the limiter's counter store hiccupped would be the limiter causing the outage it was meant to prevent. So most systems let requests through when the counter is unreachable and accept that during that window the limit is not enforced. The only wrong move is to not have made the decision.

Under the hood — how it works

There is a refinement the best production systems use, and it matters here because it sets up the next use case. The gateway does not have to ask the shared counter store on literally every request. It can keep a small, cheap token bucket in its own memory, in front of the shared one. Envoy runs exactly this two-tier arrangement: a local token bucket in each proxy that absorbs bursts, in front of a global rate-limit service backed by Redis. The local bucket catches the easy cases — a client wildly over its limit gets rejected by the gateway's own memory without a round trip to the shared store — and only the requests that pass the local check go on to consult the shared count for the exact global decision.

The local tier is allowed to be slightly loose, because the global tier is still the source of truth. This is cheap insurance, and when one client turns into a flood, this local bucket is the thing standing between that flood and the shared counter — which is exactly the problem the next use case takes on.

⚡ From production

Envoy's rate limiting is the two-tier pattern this dive recommends made concrete: a local token bucket in each proxy absorbs bursts, in front of a global rate-limit service backed by Redis. The proxy tier is where the check runs — application code never sees a blocked request — and the local bucket is what stands between a flood and the shared counter, which is exactly the next use case.

Envoy — Global rate limiting architecture
clientLBredundant pairApp serversstatelessCounterstore · Redisatomic · LuaGatewaylocal bucket + global check
The board after this deep dive — “A request” traced, hop by hop. The numbered steps below walk the same path.
A request
  1. the request crosses the public internettens of ms
  2. the load balancer hands it to the gateway — the first thing inside our building that touches a request~1 ms
  3. the gateway runs the atomic check against the shared count and gets back allow or reject — the same hop pair, now here instead of in the app servers~1 ms
  4. allowed: the request goes on to any app server. Rejected: the gateway answers 429 itself and the request never continues~1 ms
use case 4 The hot key

Nothing is left to add now, and everything on the board is correct. That is the cue to go looking for what breaks — starting with a throughput number we let slide two use cases back, and ending on the one problem that sharding still cannot solve.

deep dive 4 The hot key
The question

The board is built and it is correct. Before trusting it, try to break it — and the place it breaks is the number we deferred two use cases ago. The counter store is still a single node, a single node does about a million operations a second, and we are sending it three million checks a second. So it is already over its ceiling. The ordinary fix is to split it: three million against a million per node is about three nodes' worth of work, so the counter store becomes about three shards, and with three copies of each for redundancy that is around nine nodes. The hard constraint most sharding does not have is that a single limiter key's whole count must live on one shard — you cannot split one client's count across two, or no single shard knows the client's real total. So shard by the limiter key, using consistent hashing, and the three million checks spread flat because different clients hash to different places.

Now break it on purpose. Sharding by the key spread the load beautifully, on one assumption: that no single key carries too much of it. But the whole point of a limiter key is that it can be one client, and one client can go viral or go rogue — one IP running a scraper, one leaked API key hammered by a botnet. That is a single limiter key, which means it is a single shard, taking the entire flood by itself. It can send a million checks a second on its own — one whole shard's budget — from one key. This is a hot key, and it is the thing sharding cannot help with, because every one of those checks is for the same key, and the same key hashes to the same shard no matter how many shards you have.

The reflex is add more shards, and it is wrong here for an exact reason: it is the right answer to aggregate load, which is why it was the right answer thirty seconds ago when the whole store was over its ceiling. It does nothing for a single hot key, because a single key is one shard's problem forever. Every one of the hot key's checks is for the same key, the same key hashes to the same shard, and adding shards just moves the hot key from one overloaded shard to a different overloaded shard.

The second reflex is the one that works for hot keys in other designs — make copies of the key across several nodes and spread the reads — and it is wrong here too, in an interesting way. Copying a key works when the key is hot because it is being read a lot, because every reader can use any copy. Our hot key is hot because it is being written — every request increments the count — and you cannot spread a count across copies, because then no copy knows the true total, which is the same constraint that forced all of one key onto one shard in the first place. A count is the one thing that cannot be copied to make it faster.

The move that fits is to stop the flood before it reaches the shard. A flood is a client massively over its limit, and you do not need the exact count to know the answer is no — so the gateway's local token bucket rejects it from its own memory, and a small local reject list keeps a persistent offender from reaching the shard at all. The offender that ignores Retry-After is exactly the one the local reject list is for.

Reject the offender locally — local bucket, a reject list, and client backoff. Reject the offender before it reaches the counter, and let good clients back off. A hot key that is a genuine flood is, by definition, a client that is massively over its limit — and once a client is that far over, you do not need the exact count to know the answer is no. So the two tiers from the last use case earn their keep. The gateway's local token bucket catches the flood in its own memory and rejects it without ever touching the shared shard — the flood dies at the front door. For a persistent offender the gateway keeps a small list of keys that are currently and badly over their limit, learned from the counter store, and rejects those keys locally for a short while without asking the shard at all. That list is a few entries, it costs nothing to check, and it keeps one leaked key from taking down the shard that also holds a million innocent clients' counts. The third piece is cooperation: the 429 and Retry-After from the first use case tell a well-behaved client to slow down, so a lot of would-be flood never gets sent.

And the rule that transfers out of this problem into whatever your interviewer actually asks. A key that is hot because it is being read is fixed by making more copies of it — every reader can use any copy. A key that is hot because it is being written cannot be spread at all, because then no copy knows the true total. Our hot key is hot because it is being written — every request increments the count — so copies are the wrong move and splitting is the wrong move; you reject the offender instead. Read-hot and write-hot are opposite problems, and reaching for the wrong fix makes things worse rather than simply not helping.

Under the hood — how it works

The list of currently-hot keys is not a config file somebody edits, because the key that goes viral this afternoon was not hot this morning. The counter store already knows which keys are far over their limits — it is the thing counting them — so it surfaces the worst offenders, and the gateways refresh their small local reject lists from it every few seconds. A gateway working from a slightly stale list is fine in both directions: if it is missing a key that just turned hot, that key's checks still reach the shard and get rejected there, which is a little more load but still correct; if it is still rejecting a key that has calmed down, that client waits a few extra seconds for the list to refresh, which is harmless.

The replicas are the failure story as much as the throughput story. The counter store holds its counts in memory, so a shard whose only node died and restarted would come back empty and hand every client on it a fresh full allowance until the counts rebuilt — a burst leaking through. A replica carries a copy of the counts and takes over when its primary dies, so losing one node resets nobody; only losing a whole shard and all its copies at once drops those counts, and that leaks a burst rather than blocking one. That is the fail-open direction, which for a protection layer is the tolerable way to break.

⚡ From production

The documented production experience is exactly this failure mode: thousands of requests from the same IP or user hitting the same rate-limit check at once overwhelm individual Redis shards, and a single instance becomes the bottleneck around a million checks a second — the same ceiling we sized against. The mitigations named are the ones this dive picked: consistent-hash sharding so a client's checks always hit the same shard, temporary blocklisting of the offender, and client-side backoff.

Hello Interview — Design a Distributed Rate Limiter
clientLBredundant pairApp serversstatelessCounterstore · Redisatomic · Luasharded · by limiter key · ~3 nodesGatewaylocal bucket + global checkreject list
The board after this deep dive.
use case 5 When exact is the wrong goal

Everything so far assumed one place to keep the count. Now the job goes global — the same limiter running in datacenters all over the world — and a single shared count is no longer something every check can reach in time.

deep dive 5 Approximate counting
The question

Step back and look at what we built. It is correct: one shared count per client, made atomic by a Lua script, sharded for throughput, hardened against a hot key. For a service in one region at a few million checks a second, this is the right design and it is done. Now someone hands you Cloudflare's problem, and the whole thing falls over — not because it is wrong, but because its central assumption stops holding. Cloudflare runs a rate limiter across the entire planet: several billion requests a day, served from hundreds of datacenters, and it has absorbed a single-domain attack of about four hundred thousand requests a second. Our design says every check is a round trip to one shared, exact count. Where does that count live when the requests arrive in a hundred datacenters at once?

Pick any one datacenter to hold the exact count, and every check from every other datacenter is now a round trip across the world — tens or hundreds of milliseconds, on every request, the exact latency the brief said the limiter must not add. That is one exact option, and it is unbearably slow at planet scale.

Replicate the count to every datacenter so each check is local, and it stops being one count — keeping the copies in agreement is the cross-datacenter coordination we ruled out in the brief as the wrong trade. That is the other exact option, and it is either not actually one count or it pays the coordination cost we already refused. The exact, centralized count that was correct in one region is, at planet scale, either unbearably slow or not actually one count, and no amount of sharding fixes it, because sharding spreads load within a region and this problem is between regions.

So the move is to give up exactness on purpose: each datacenter keeps its own local count, never synchronized, and estimates the rate with a two-window weighted guess. At internet scale, approximate and fast beats exact and coordinated.

Approximate per-datacenter counting — local counts, never synchronized. Stop trying to be exact, and stop coordinating at all. This is the opposite move to everything so far, and it is the point of the whole use case. Cloudflare's answer is that each datacenter — each point-of-presence — keeps its own local count, and the counts are never synchronized across datacenters. Anycast routing keeps a given client landing at the same nearby datacenter, so that one datacenter sees almost all of that client's traffic and its local count is close to the truth. Almost, not exactly. And each datacenter smooths its own count over time so the fixed-window flaw does not come back: it keeps the count for the current window and the count for the previous window, and estimates the rate as the current window's count plus a shrinking fraction of the previous window's, weighted by how far into the current window we are. Their own worked example: with eighteen requests so far this minute, forty-two in the previous minute, and fifteen seconds elapsed, the estimate is eighteen plus forty-two times three-quarters, which is forty-nine and a half. A weighted guess, two numbers per client, and no coordination anywhere.

Now the number that justifies the whole approach. This count is approximate, so how wrong is it? Cloudflare measured it against four hundred million real requests and found it made the wrong decision about three thousandths of one percent of the time, with an average difference from the true rate of about six percent. For a rate limiter, being wrong three times in a hundred thousand, in exchange for zero coordination and a check that never leaves the local datacenter, is a trade you take every time. This is the same trade as the sliding-window log from the first use case, made one more time and much larger: there, the exact log cost ten times the memory to remove a flaw the product could live with, so nobody shipped it; here, the exact global count costs cross-planet coordination to remove an error of six percent the product can live with, so Cloudflare does not ship it. A rate limit is a safety margin, not a bank ledger.

Under the hood — how it works

Do not read this use case as the design we built was wrong. It was right for its scale. The exact, centralized, sharded counter is the correct answer for a service inside one region handling a few million checks a second, which is the overwhelming majority of real systems, and it is what an interview usually wants.

The approximate per-datacenter model earns its keep only when the traffic genuinely spans many datacenters and the volume makes cross-region coordination the bottleneck — which is a real place, and it is Cloudflare's, but it is not most services'. The point of ending here is to know that the exactness you fought for in the middle of this design is itself a choice, and that at a large enough scale the right move is to give it up on purpose.

⚡ From production

The two-window estimate, the anycast-keeps-a-client-local trick, and the measured error are all from Cloudflare's engineering blog on building rate limiting for millions of domains. The worked example, the four-hundred-million-request validation, the three-thousandths-of-a-percent wrong-decision rate and six percent average error, the several billion requests a day and the four-hundred-thousand-a-second attack are all disclosed there. It is a rare thing: a production system describing, with numbers, why it chose approximate over exact.

Cloudflare — How we built rate limiting capable of scaling to millions of domains
Where this design sits on consistency (and CAP)

The check chooses availability under a partition, on purpose. When the gateway cannot reach the counter store, the design fails open: it allows the request rather than refusing it. The reasoning is that a rate limiter is a protection, not a gate — it exists to keep the service healthy under load, and taking the whole service down because the limiter's counter store hiccupped would be the limiter causing the outage it was meant to prevent. So during that window the limit is simply not enforced, and that is the cheaper wrong answer. Fail closed is a legitimate choice for a limiter guarding something that must never be over-run, but it has to be picked before the outage, never discovered during it.

The count itself chooses consistency, within one region. The whole point of the atomic Lua script was to make the read-decide-update indivisible, so two servers cannot both spend the last token — a correct, single, shared count. That correctness costs a round trip to the shared store on every check, and it is worth paying at the scale where the store lives in one datacenter, because the check still comes back in about a millisecond.

At planet scale that trade flips, and the last use case is where. Once the requests arrive in a hundred datacenters, an exact shared count is either a cross-planet round trip on every request or a set of copies that must coordinate to agree — and both of those are the availability-or-latency cost the brief refused. Cloudflare's answer gives up the exact count on purpose: each datacenter keeps its own approximate local count, never synchronized, and is wrong about three times in a hundred thousand in exchange for a check that never leaves the building. Consistency in one region, availability across the planet.

There is a name for the underlying choice. When a network fault splits a system in two — that split is the P, for partition — each operation has exactly two honest options: answer anyway from what this half knows and risk being wrong, which is the A, for availability, or refuse until the halves agree, which is the C, for consistency. A partition is not something you choose; it is weather. The useful way to apply the CAP theorem is per operation, by asking what a wrong answer would actually cost. Read this design that way: an unenforced limit for a few seconds costs almost nothing, so the check fails open; a double-spent token within a region is the bug the whole design exists to prevent, so the count is made exactly correct there; and a cross-planet round trip on every request is unaffordable, so at global scale the count is allowed to be a little wrong. Each trade was picked on purpose.

CAP theorem — the partition trade-off, per operation
The finished board

That is the whole machine, and every box on it was placed by one of the deep dives above. If you carry away one sentence, make it the one the last use case turned on: decide how exact the answer has to be before you decide how to compute it. A rate limit does not need to be exact — it needs to be about right and very fast — and almost everything hard here came from a place where someone tried to make it more exact than it needed to be.

clientLBredundant pairApp serversstatelessCounterstore · Redisatomic · Luasharded · by limiter key · ~3 nodesGatewaylocal bucket + global checkreject list
The finished design — every box placed by one of the deep dives above.
What's on the diagram
client
The caller — a person's phone, but just as often a server, a script, or a paying integration. It makes the requests the limiter counts, and reads the 429 and Retry-After when it is turned away.
LB
Load balancer. Spreads incoming requests across the gateways behind it so no single one is overloaded, and it runs as a redundant pair so its own death is a non-event.
App servers
App servers. The protected backend. They keep nothing between requests, so any one can answer any request — and because the check runs at the gateway, blocked traffic never reaches them at all.
Counter store · Redis
Counter store. Every client's token-bucket count, held in Redis. The check runs as one atomic Lua script — Redis's single thread makes read-decide-update indivisible — and the store is sharded by the limiter key, about three nodes each replicated, because three million checks a second is more than one node's million.
Gateway
Gateway. The edge every request already passes through, and where the rate-limit check runs — so a rejected request is turned away at the front door for no extra hop. It keeps a small local token bucket and a reject list, so a flood dies here without ever touching the shared count.
What this design never covered — raise it yourself
Client-side backoff and cooperation

Half of rate limiting is the client's job. A good client reads the Retry-After on a 429 and waits, and better clients back off with increasing delays and a little randomness so a thousand rejected clients do not all retry in the same instant. The strong answer names this as the other half of the contract: the server rejects, and a well-behaved client makes the rejection cheap by not hammering. The offender that ignores it is what the gateway's local reject list is for.

Load shedding — the mechanism people confuse with rate limiting

These look alike and are not. Rate limiting caps one client against its own limit, all the time. Load shedding drops low-priority work across the whole fleet during an incident, regardless of who sent it, to keep the service alive. Stripe runs both, as separate mechanisms. The tell that they are different: load shedding needs a priority signal — a sense of which work matters right now — that the rate limiter neither has nor needs. Conflating them in an interview misses that.

Usage tiers and quota plans

Real products do not give every client the same limit. A free tier, a paid tier, and an enterprise tier get different numbers, and enterprise customers negotiate their own. The strong answer is that this changes almost nothing in the design: the limit becomes a lookup on the limiter key instead of a constant, and everything else — the token bucket, the shared count, the gateway check — is identical. What it adds is a small, cached store of what this key's limit is, read alongside the count.

Multi-region, the exact version

We cut the planet-scale approximate model as a reframe, but there is a middle ground an interviewer may push on: a service in a few regions that still wants an exact-ish limit. The honest answer is that you pick per region. Either pin a client to a home region so its exact count lives in one place, and accept a cross-region hop when it roams, or run an independent limit per region and accept that the global limit is the sum, which is looser. There is no free exact global count across regions, which is the whole reason Cloudflare stopped trying for one.

Go deeper — the primitives this design leaned on
  • Rate limitingtoken and leaky bucket, and the window counters, in isolation
  • Event loopwhy a single-threaded Redis runs a Lua script with nothing able to interleave
  • Consistent hashingthe ring that sends a key to the same shard every time and moves little when a shard is added

Feedback on this problem →