A rate limiter that is fair when it is distributed
Every algorithm here is a few lines on one machine. The design question is what happens when there are twenty machines and they disagree.
The brief
Limit each API client to a fixed number of requests per window.
The service runs as many replicas behind a load balancer, and a client's requests land on whichever replica the balancer picks.
Requirements
Functional
- Reject requests over a client's limit with 429 and a Retry-After
- Different limits for different clients or tiers
- Tell a caller how much budget is left before they run out
Non-functional
- The limiter is on every request, so its own latency is added to every request
- Failing open and failing closed are both defensible — but only one of them is the decision you made on purpose
- A limit of 100/min must mean roughly 100/min across the fleet, not 100/min per replica
Back-of-envelope
Assume
- 20 replicas behind the balancer
- 10,000 active clients in any given minute
- A limit expressed per minute
Therefore
- In-memory per replica: each client sees 1/20th of its traffic per replica, so a 100/min limit becomes an effective 2,000/min across the fleet. The limiter is off by 20×, and it is off by exactly the replica count — which changes every time you scale.
- Shared counter state: 10,000 clients × a counter and a timestamp is kilobytes. State size is not the problem.
- Round trips: one shared-store call per request. That call's latency lands on every request, which is the real cost and the reason people reach for local counters in the first place.
The 20× error is the whole reason this is a design question. It is not a bug you would find in testing — with one replica running locally the limiter is perfectly correct.
The interface
What is stored
key `rl:{clientKey}:{windowStart}` → integer, with a TTL of one windowThe window is in the key, so expiry is the store's job and there is no cleanup job to write, monitor or forget. A counter keyed without the window needs someone to reset it, and that someone is a cron nobody notices has stopped.
clientKey → limit · window · tierRead on every request and changed a few times a month, which is the exact shape that should be cached in the replica for seconds at a time rather than fetched.
sorted set per client, member = request id, score = timestampExact, and priced accordingly: one entry per request rather than one integer per client. The decision above chooses the approximation, and this is what it is approximating.
The design
The decisions
Each of these could go the other way. The choice, the reason, and what it costs — a design that lists only what it chose teaches the choice; one that lists what it gave up teaches the judgement.
What breaks first
In order. Each names what you would actually observe, and each fix carries its cost.
When something fails
Scaling it
Each step is triggered by a number, not a feeling — and carries what it costs.
What gets probed
The design is the easy half. These are where the conversation goes, and each has a defensible answer above.
- You add ten replicas at peak. What happens to every client's effective limit?
- What is the client key — an API key, an IP, a user? What does each one do to a shared corporate NAT?
- A client is rejected and retries immediately, forever. What in your design stops that?
- How would you let one customer burst to 5× for thirty seconds without changing anyone else's limit?