All posts
System Design

Designing a Distributed Rate Limiter: A System Design Walkthrough

Token bucket versus sliding window, Redis-backed counters, race-free atomic increments, and the trade-offs of rate limiting at scale.

SKSushan Khadka
January 28, 2026 (7mo ago)6 min read
Designing a Distributed Rate Limiter: A System Design Walkthrough — article by Sushan Khadka (namelessnerd)

A rate limiter is the bouncer at the door of your API. Get it wrong and you either let a stampede crush your backend or wrongly turn away paying customers at twice your real limit.

Designing a Distributed Rate Limiter: A System Design Walkthrough - diagram by Sushan Khadka (namelessnerd)

Why you need one at all

Rate limiting is not only about stopping abuse, though it does that too. It protects a shared resource from any client — malicious, buggy, or just popular — consuming more than its fair share. A single customer with a runaway retry loop can saturate a database connection pool and take down the service for everyone. A limiter turns "one bad client breaks the system" into "one bad client gets throttled." It is a stability control first and a security control second.

That framing matters because it tells you where to enforce. You want the limiter as close to the edge as possible — an API gateway or reverse proxy — so rejected traffic never reaches your expensive backend at all. Rejecting a request after it has already opened a database transaction defeats half the purpose.

Pick your algorithm

The naive choice is a fixed window counter, and it has a nasty flaw: the boundary-burst problem. A client can spend its full quota in the last second of one window and again in the first second of the next, sneaking through 2x the limit in a blink. If your limit is 100 requests per minute, a client can land 200 requests in a two-second span straddling the boundary.

Here are the four algorithms worth knowing, and when each earns its place:

Token bucket is my default. It is simple, allows controlled bursts, and maps cleanly onto one counter plus a timestamp. The bucket size sets how big a burst you tolerate; the refill rate sets the sustained throughput. Those two knobs express almost any real-world policy — "1000 per hour but allow short spikes" falls out naturally.

Make it distributed

Once you run more than one app server, in-memory counters lie. Client X hits server A five times and server B five times; each sees five and neither trips a limit of eight, so the client sails past at 10. The counter has to live somewhere all servers can see it.

The fix is a shared store, and Redis is the obvious pick: fast, single-threaded (so operations are naturally serialized), atomic, and built for exactly this. An in-memory counter with sub-millisecond latency means the limiter adds negligible overhead to each request.

The trap everyone falls into is doing INCR and EXPIRE as two separate calls. If a process dies or a request races between them, you get a counter that never expires and a permanently throttled client. Two round trips, one race condition — and it fails in the worst possible way, locking out a legitimate user until someone notices and deletes the key by hand.

Kill the race with Lua

Redis runs Lua scripts atomically, start to finish, with no interleaving. Collapse the read, increment, and expiry into one script and the race simply cannot happen.

local n = redis.call("INCR", KEYS[1])
if n == 1 then
  redis.call("EXPIRE", KEYS[1], ARGV[1])
end
if n > tonumber(ARGV[2]) then return 0 end
return 1

One network round trip, one indivisible operation. The if n == 1 guard sets the TTL only on the first request of a window, so the expiry rides with the key from birth. Whether you implement fixed window, sliding window, or token bucket, the pattern is the same: all the read-modify-write logic executes server-side in Redis, atomically, and your app just reads the yes/no answer.

Choosing the key

The algorithm is only half the design; the other half is what you count. The key decides who shares a budget.

Real systems layer these: a generous global per-user limit, plus a strict per-endpoint limit on the sensitive routes. Compose them and each catches a different abuse pattern.

Reject with manners

When a client is over the limit, return HTTP 429 Too Many Requests — not a 500, not a silent drop. Add a Retry-After header so well-behaved clients know exactly when to come back instead of hammering you in a retry loop. It is also good manners to expose the budget on every response so clients can self-pace:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1735689630

A good limiter does not just say no; it says not yet, try again in 30 seconds — and tells honest clients how much room they have left before they hit the wall.

The trade-offs nobody skips

Two decisions will come up in any serious design review:

The takeaway

Read more posts