Reliabilityrate limitingtoken bucketleaky bucketfixed windowsliding window

Rate Limiting

A rate limiter decides, per client, tenant or route, whether a request may proceed now; fixed windows are cheap and leak 2× at the boundary, sliding windows are exact or approximate depending on memory, and the token bucket is the default because it allows bounded bursts with O(1) state — enforced at the gateway with atomic counters in Redis and communicated with 429 + Retry-After.

▶ InteractiveInterview questionDebug it
Progress
What problem does this solve?

Aggregate demand can exceed what a service, a dependency, or a fair share per tenant can absorb; the limiter rejects the excess early and cheaply, so capacity is spent on requests that will succeed and one noisy client cannot starve the rest.

Three different problems called by one name

Rate limiting is used for three distinct reasons and the algorithm and key should follow from which one you have. Protection: a service can handle 5,000 requests/s and must reject the 6,000th rather than slow down for everyone — see Backpressure for the same idea inside a pipeline. Fairness: one tenant of a multi-tenant API should not consume the capacity paid for by the others, so the limit is keyed per tenant or API key. Cost and abuse: password attempts, SMS sends and LLM calls cost money or open attack surface, so the limit is keyed per user or per IP and set well below capacity. A notification system applies all three at once — a per-user limit so nobody receives 200 pushes, a per-provider limit because the SMS gateway allows 100 messages/s, and a global limit to protect the workers.

Enforcement at the edge, counters in a shared store
request + API keyatomic check-and-incrementallowedover limitClientAPI Gateway: rate limiterRedis: counters / bucketsService429 + Retry-After
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

The four algorithms

A fixed window counts requests in each calendar interval (key:2026-08-25T10:04) with a counter that expires. It is one INCR and one EXPIRE, and it has a boundary problem: a limit of 100/minute allows 100 requests at 10:04:59 and 100 more at 10:05:00 — 200 in two seconds. A sliding log stores every request timestamp and counts those within the last window; it is exact and it costs memory proportional to the limit per key — fine for 100/minute, not for 10,000/second. A sliding window counter approximates the log with two fixed-window counters weighted by how far into the current window you are: prev × (1 − elapsed/window) + curr; it is O(1) memory and within a few percent of exact. A token bucket holds up to capacity tokens, refills at rate per second, and each request takes one; a full bucket allows a burst of capacity, then a sustained rate. A leaky bucket is the same maths from the other side: requests enter a queue of bounded size that drains at a fixed rate, so output is perfectly smooth and the burst is absorbed as latency rather than passed through.

Token bucket is the usual default because "sustained rate plus a bounded burst" is what most APIs actually want to promise, and its state is two numbers per key. Leaky bucket is right when the *downstream* cannot take bursts at all — an SMS provider that drops anything over 100/s, or a pipeline that must be smoothed. The DSA transfer is direct: the sliding window is Sliding Window (Fixed Size) applied to timestamps, the leaky bucket is a bounded Queue with a metronome consumer, and every one of them keys its state in a Hash Map.

Algorithm comparison at a limit of 100 requests per minute
AlgorithmBurst behaviourAccuracyMemory per keyRedis cost per request
Fixed windowUp to 2× at the window boundaryOff by up to 100% at the edge1 integerINCR + EXPIRE (pipelined or Lua)
Sliding logExact: never more than 100 in any 60 sExactUp to 100 timestamps (sorted set)ZADD + ZREMRANGEBYSCORE + ZCARD
Sliding window counterSlight overshoot, bounded by the weightingWithin a few % of exact2 integers2 × GET + INCR (Lua)
Token bucketAllows a burst of capacity, then rateExact for the bucket model2 numbers: tokens, last refill1 Lua script (read, refill, decrement, write)
Leaky bucketNo burst passes through; excess queues or dropsExact, output perfectly smoothQueue length (bounded)Usually in-process, per worker

A token bucket in twenty lines

The bucket does not need a timer. It refills lazily: on each request, compute how many tokens have accrued since the last refill, cap at capacity, then try to take one. The whole state is tokens and last. In a single process this is the code below; in Redis it is the same code as a Lua script so the read-modify-write is atomic across gateway instances.

Lazy-refill token bucket; capacity = allowed burst, rate = sustained requests per second
1export class TokenBucket {
2 private tokens: number
3 private last: number
4 constructor(private readonly capacity: number, private readonly ratePerSec: number, now = Date.now()) {
5 this.tokens = capacity
6 this.last = now
7 }
8
9 /** Returns true and consumes a token if allowed; otherwise false (caller sends 429). */
10 tryTake(now = Date.now()): boolean {
11 const elapsedSec = (now - this.last) / 1000
12 this.tokens = Math.min(this.capacity, this.tokens + elapsedSec * this.ratePerSec)
13 this.last = now
14 if (this.tokens < 1) return false
15 this.tokens -= 1
16 return true
17 }
18
19 /** Seconds until one token is available — the value for Retry-After. */
20 retryAfterSec(): number {
21 return this.tokens >= 1 ? 0 : Math.ceil((1 - this.tokens) / this.ratePerSec)
22 }
23}

Where to enforce, and distributed counters

Enforce as early as the key allows. Per-IP and per-API-key limits belong in the API gateway or load balancer, where a rejected request costs microseconds and never reaches a service; see API Gateway. Per-tenant and per-operation limits often need context only a service has (plan tier, remaining quota), so they are enforced there. Limits on outbound calls to a provider belong in the client that calls the provider — a token bucket sized to the provider’s contract, shared across workers. Layer them: a gateway limit protects the platform, a service limit enforces the business rule, an outbound limit protects the dependency.

With more than one gateway instance the counter must be shared, and Redis is the usual store because INCR is atomic and keys can expire. The trap is doing it in two round trips: INCR then EXPIRE is a race in which a crash between them leaves a key that never expires and a client locked out forever. Either pipeline them in a MULTI block, or — better — run a small Lua script so the check, refill and decrement are one atomic operation on the server. Redis Cluster shards keys by hash slot, so a per-client key lands on one node and there is no cross-node coordination. Precision has a price: a few percent of over-admission from local, per-instance buckets is often acceptable and removes the Redis round trip from the hot path entirely.

Fixed-window check in one atomic Redis Lua call: no INCR/EXPIRE race
1-- KEYS[1] = "rl:{apiKey}:{windowStart}", ARGV[1] = limit, ARGV[2] = windowSeconds
2local n = redis.call("INCR", KEYS[1])
3if n == 1 then
4 redis.call("EXPIRE", KEYS[1], ARGV[2]) -- set TTL only on first hit, atomically
5end
6if n > tonumber(ARGV[1]) then
7 return {0, redis.call("TTL", KEYS[1])} -- rejected, seconds until the window resets
8end
9return {1, 0} -- allowed

429, Retry-After, and the client’s half of the contract

A rejected request gets 429 Too Many Requests, a Retry-After header in seconds (from the bucket’s refill maths or the window’s TTL), and ideally the RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers on every response so a well-behaved client can pace itself before being rejected. The limiter should be cheap to hit: a 429 that costs the same as a served request has not protected anything, which is why it lives at the edge. Log and count rejections per key; a tenant hitting their limit continuously is either abusing the API or has outgrown their plan, and both are worth knowing.

The client’s obligations are the mirror image: honour Retry-After rather than retrying immediately, back off with jitter if the header is missing, and never treat 429 as a transient error to retry in a tight loop — that converts a polite rejection into a retry storm, which is the scenario in Circuit Breaker arriving from the other direction.

  • Key selection is the design: per IP fails behind NAT and corporate proxies, per user fails for anonymous traffic, per API key is the usual choice for platforms.
  • Sync with the product: a limit of 60/minute with a burst of 60 is a different promise from 1/second, even though the sustained rate is the same.
  • Exempt health checks and internal traffic explicitly, or the first thing the limiter blocks during an incident is your own monitoring.

Key points

  • Three reasons to limit — protection, fairness, cost — and the reason determines the key (IP, user, API key, tenant, provider) and the algorithm.
  • Fixed window is one counter and leaks 2× at the boundary; sliding log is exact and memory-heavy; sliding counter approximates in O(1); token bucket allows a bounded burst then a sustained rate; leaky bucket smooths output completely.
  • Token bucket state is two numbers with lazy refill; in Redis, do the read-refill-decrement in one Lua script so it is atomic across gateway instances.
  • Enforce at the earliest layer that knows the key: gateway for IP/API key, service for tenant rules, outbound client for provider contracts.
  • Reject with 429 + Retry-After and expose remaining-quota headers; clients must honour them or a rejection becomes a retry storm.

Four rate-limiting algorithms

Four rate-limiting algorithms (and a fifth)
Limit 5 per 1 s. A steady trickle plus a burst of 5 requests just before the window boundary and 5 just after it — the pattern that exposes the fixed-window flaw.
Algorithm
Fixed window
13/18
Sliding window log
10/18
Sliding window counter
11/18
Token bucket
14/18
Leaky bucket
15/18
0 s1 s (boundary)2 s3 s
accepted
13
rejected
5
peak accepted in any 1 s
10
memory per client
1 counter + window id · O(1)
Fixed window: One integer per client per window (`INCR key:window` with a TTL in Redis). Cheapest possible — and it allows 2× the limit across a window boundary: a burst at 0.9 s and another at 1.1 s both fit. Here: 9 requests accepted within one second around the 1 s boundary — 1.8× the limit. The counter reset at the boundary, and the dependency saw the burst the limit was supposed to prevent. The data structure you learned as a counter, a sliding window or a queue is the production rate limiter.

How data moves through it

One request or event, hop by hop.

  1. 1Client → API Gateway: request carrying an API key; the gateway derives the limit key rl:{apiKey}.
  2. 2Gateway → Redis: one Lua call that reads the bucket, refills by elapsed time, decrements if possible, and returns allowed/denied plus retry seconds.
  3. 3Gateway → Client: 429 with Retry-After: 3 and RateLimit-Remaining: 0 if denied; the request never reaches a service.
  4. 4Gateway → Service: allowed request forwarded with RateLimit-* headers added to the eventual response.
  5. 5Service → Provider client: an outbound token bucket (100/s for the SMS gateway) shared across workers gates the third-party call.

When to use — and when not

Use it when
  • Any public API, keyed per API key or user, with limits published as part of the contract.
  • Outbound calls to a provider with a documented rate contract: a shared token bucket sized to that contract.
  • Multi-tenant systems where one tenant’s burst would degrade others; per-tenant fairness limits.
  • Expensive or abusable operations — logins, OTP sends, exports, LLM calls — with limits far below raw capacity.
Avoid it when
  • As a substitute for capacity: if legitimate traffic exceeds the limit every day, the answer is scaling or a queue, not a lower limit; see Backpressure.
  • Between your own internal services under normal operation, where a bulkhead and a breaker are the better tools and a limit just adds a 429 to debug.
  • Keyed on a signal you cannot trust (client-supplied IDs) — the limit is then trivially bypassed.

Tradeoffs

Complexity
low → high
Ops cost
low → high
Latency
low → high
Consistency
weak → strong
Scalability
poor → strong

Cheap and effective; the cost is the Redis round trip on the hot path (or accepting per-instance imprecision) and a limit that must be re-tuned as traffic and plans change.

How it fails

  • Fixed-window boundary: 100/minute becomes 200 in two seconds around the minute mark, and a downstream sized for 100 falls over.
  • INCR without an atomic EXPIRE: a crash between the two leaves a key that never resets and a tenant locked out until someone deletes it by hand.
  • Limiting per IP behind a corporate NAT or mobile carrier: thousands of legitimate users share one key and are throttled together.
  • Redis unavailable and the limiter fails closed: every request is a 429 and the limiter is the outage. Decide explicitly — usually fail open with an alert.
  • Clients retrying 429s immediately: the limiter rejects faster than they retry, and the rejection path itself becomes the load.

How it scales

  • Per-key state is O(1) for token bucket and sliding counter, so millions of keys fit in a single Redis node; sliding logs do not scale the same way.
  • Redis Cluster shards keys by hash slot, so per-client limits scale horizontally with no coordination; a global limit is one hot key and needs local pre-aggregation.
  • Move the limiter to the CDN or edge for per-IP rules so rejected traffic never reaches the origin; see CDN Architecture.
  • For extreme throughput, keep local per-instance buckets and sync to Redis periodically, trading a few percent of precision for zero hot-path round trips.

How it interacts with databases, queues, caches, APIs and external systems

  • Cache (Redis): the shared counter store; atomic Lua scripts for check-and-decrement; keys with TTL so idle clients cost nothing.
  • API gateway / load balancer: the enforcement point for per-IP and per-key limits; see API Gateway and Load Balancing.
  • Queues and workers: an outbound limiter in front of a provider turns rejections into waiting rather than failing; see Background Jobs and Workers.
  • External providers: their published limits define your outbound buckets; their 429s must be honoured with Retry-After.
  • Observability: rejections per key and per rule as metrics; a tenant permanently at their limit is a sales or abuse signal, not noise.