Rate limiting
Rate limiting algorithms compared, from fixed windows to GCRA, where to enforce limits, distributed counting, 429 responses and load shedding, for interviews.
- Read first
- API design
A rate limiter decides, for each request, whether this caller may make it now. It counts what a key has done recently. A key is a user, an API key, an IP, or a tenant. It compares that count with a rule, such as “100 a second, bursts of 200”. And it answers yes, or “429, try again in 300 ms”. The algorithm fits on an index card. The hard parts are choosing what to count, and where to count it. Plus what to do when the counter is unreachable, and knowing when a rate limit is the wrong tool.
This page is the reference on the concept. The distributed rate limiter problem designs one end to end for 330 gateways, and the low level design builds the classes.
The problem it solves
Section titled “The problem it solves”An orders API runs comfortably at 20,000 requests a second, with a database that tops out near 30,000. Four things go wrong without limits:
- Abuse. A credential stuffing bot tries 3,000 passwords a second against
/login. A scraper walks every product page. Without a limit per account and per network, both run at whatever speed their hardware allows. - Fairness. One customer ships a bug that retries in a tight loop at 15,000 requests a second. Every other customer shares the remaining headroom with it, and their p99 latency triples. Nobody did anything wrong except one tenant.
- Cost. Some requests cost real money: an SMS at about a cent each, a call to a paid
third-party API, a GPU inference. A runaway loop on
/send-smsis a bill, not just load. - Protecting downstreams. A payment provider allows you 500 calls a second. The database has 5,000 connections. If you send more, they refuse or fall over, and the failure comes back to you.
A rate limiter answers all four by bounding what each key may do. It does not, on its own, bound what the whole system receives: 100,000 keys each under their limit can still overload the database. That job belongs to load shedding, which this page covers too, because the two are often confused.
Four words get used loosely:
| Term | What it bounds | What the caller sees |
|---|---|---|
| Rate limit | Requests per key per unit of time | 429 with Retry-After |
| Quota | A total over a long period (10,000 calls a month) | 429 or 403 until the period resets |
| Throttling | Delaying requests to a steady rate instead of refusing | Slower responses, no errors |
| Load shedding | Total work in the server, whoever sent it | 503, ideally lowest priority first |
How it works
Section titled “How it works”Every algorithm below enforces the same rule, “5 requests a second”, and differs in what it remembers per key and how it treats a burst.
Step 1: Count per fixed window
Section titled “Step 1: Count per fixed window”The simplest limiter keeps one counter per key per clock window. In Redis it is two commands:
INCR rl:key_8f3:1730102400 and EXPIRE on the first increment. If the count passes the limit,
refuse until the window rolls over.
It is cheap (one integer per key), and wrong in a specific way. A client can send 5 requests in the
last 100 ms of one window, and 5 in the first 100 ms of the next. That’s 10 requests in 200 ms, twice
the limit, and every one is admitted. At “1,000 an hour”, that is 2,000 within a few minutes, across
the top of the hour. Windows aligned to the clock also synchronize clients. Everyone who was refused
retries at :00, so the first second of every window is a spike.
Step 2: Remember every request
Section titled “Step 2: Remember every request”The sliding log stores a timestamp per admitted request and counts those newer than one window
ago. In Redis it is a sorted set: ZREMRANGEBYSCORE to drop old entries, ZCARD to count, ZADD
to record. It is exact: never more than 5 in any 1 second span, wherever the span starts.
The price is memory proportional to the limit. A limit of 1,000 a minute keeps up to 1,000 entries per key, about 80 KB at roughly 80 bytes per sorted set entry. A million active keys is 80 GB for the limiter alone. The sliding log is right for small limits that must be exact (5 login attempts per 15 minutes) and wrong for high-volume API limits.
Step 3: Weight the previous window
Section titled “Step 3: Weight the previous window”The sliding window counter keeps two fixed-window counters: this window’s, and the last one’s. It estimates the rolling count by assuming the previous window’s requests were spread evenly:
estimate = previous × (time left in the current window ÷ window) + currentSay it’s 1.1 s into a 1 s window scheme. 90% of the previous window still overlaps the rolling second. If the previous window held 10, the estimate starts at 9, and only one more fits. That’s two integers per key, and the instant burst at the boundary is gone. Cloudflare measured the error on 400 million requests: 0.003% of requests were wrongly allowed or refused.
The error that remains is in the “spread evenly” assumption. Say all 10 previous requests came in the last 50 ms. The estimate undercounts them. A client that then paces itself through the new window can approach 2× the limit over one rolling second. It cannot do that in 200 ms, as with a fixed window. And real traffic rarely has that shape, which is why the measured error is tiny. If the previous requests came early, it overcounts, and refuses a little too soon.
import java.util.HashMap;import java.util.Map;
// Two counters per key: the current fixed window and the one before it.final class WindowPair { long startMs; int current; int previous;
WindowPair(long startMs) { this.startMs = startMs; }}
// "At most N per window", without the fixed window's double burst at the boundary and without// the sliding log's timestamp per request.final class SlidingWindowCounter { private final int limit; private final long windowMs; private final Map<String, WindowPair> keys = new HashMap<>();
SlidingWindowCounter(int limit, long windowMs) { this.limit = limit; this.windowMs = windowMs; }
boolean tryAcquire(String key, long nowMs) { long start = nowMs - nowMs % windowMs; // windows align to the clock, like INCR keys WindowPair w = keys.computeIfAbsent(key, k -> new WindowPair(start)); if (w.startMs != start) { // Roll forward. If a whole window passed with no traffic, the previous count is 0, // not the stale one: the key was idle for that window. boolean justBefore = w.startMs == start - windowMs; w.previous = justBefore ? w.current : 0; w.current = 0; w.startMs = start; } // The rolling window covers the end of the previous window and the start of this one. // Assume the previous window's requests were spread evenly and count the overlapping // share. This assumption is the whole approximation, and it is usually close. double overlap = (double) (windowMs - (nowMs - start)) / windowMs; double estimate = w.previous * overlap + w.current; if (estimate + 1 > limit) return false; // refused requests are not counted, so a hammering client recovers w.current++; return true; }}
class Main { static int admitted(SlidingWindowCounter limiter, long atMs, int attempts) { int n = 0; for (int i = 0; i < attempts; i++) if (limiter.tryAcquire("key_8f3", atMs)) n++; return n; }
public static void main(String[] args) { var limiter = new SlidingWindowCounter(10, 1000); // 10 a second System.out.println(admitted(limiter, 900, 12)); // 10: the first window fills at 0.9 s // A fixed window would allow 10 more at 1.1 s: 20 requests in 0.2 s. Here 90% of the // previous window still overlaps, so it counts as 9 and only one more fits. System.out.println(admitted(limiter, 1100, 12)); // 1 System.out.println(admitted(limiter, 1500, 12)); // 4: half the old window has slid out System.out.println(admitted(limiter, 3200, 12)); // 10: a full window of silence clears the history }}from dataclasses import dataclass
# Two counters per key: the current fixed window and the one before it.@dataclassclass WindowPair: start_ms: int current: int previous: int
# 'At most N per window', without the fixed window's double burst at the boundary and# without the sliding log's timestamp per request.class SlidingWindowCounter: def __init__(self, limit: int, window_ms: int): self._limit = limit self._window_ms = window_ms self._keys: dict[str, WindowPair] = {}
def try_acquire(self, key: str, now_ms: int) -> bool: start = now_ms - now_ms % self._window_ms # windows align to the clock, like INCR keys w = self._keys.get(key) if w is None: w = self._keys[key] = WindowPair(start, 0, 0) elif w.start_ms != start: # Roll forward. If a whole window passed with no traffic, the previous count is 0, # not the stale one: the key was idle for that window. just_before = w.start_ms == start - self._window_ms w.previous = w.current if just_before else 0 w.current = 0 w.start_ms = start # The rolling window covers the end of the previous window and the start of this one. # Assume the previous window's requests were spread evenly and count the overlapping # share. This assumption is the whole approximation, and it is usually close. overlap = (self._window_ms - (now_ms - start)) / self._window_ms estimate = w.previous * overlap + w.current if estimate + 1 > self._limit: return False # refused requests are not counted, so a hammering client recovers w.current += 1 return True
def admitted(limiter: SlidingWindowCounter, at_ms: int, attempts: int) -> int: return sum(limiter.try_acquire("key_8f3", at_ms) for _ in range(attempts))
if __name__ == "__main__": limiter = SlidingWindowCounter(limit=10, window_ms=1000) # 10 a second print(admitted(limiter, 900, 12)) # 10: the first window fills at 0.9 s # A fixed window would allow 10 more at 1.1 s: 20 requests in 0.2 s. Here 90% of the # previous window still overlaps, so it counts as 9 and only one more fits. print(admitted(limiter, 1100, 12)) # 1 print(admitted(limiter, 1500, 12)) # 4: half the old window has slid out print(admitted(limiter, 3200, 12)) # 10: a full window of silence clears the historyusing System;using System.Collections.Generic;
// Two counters per key: the current fixed window and the one before it.public sealed class WindowPair(long startMs){ public long StartMs { get; set; } = startMs; public int Current { get; set; } public int Previous { get; set; }}
// "At most N per window", without the fixed window's double burst at the boundary and without// the sliding log's timestamp per request.public sealed class SlidingWindowCounter(int limit, long windowMs){ private readonly Dictionary<string, WindowPair> _keys = new();
public bool TryAcquire(string key, long nowMs) { long start = nowMs - nowMs % windowMs; // windows align to the clock, like INCR keys if (!_keys.TryGetValue(key, out var w)) { w = new WindowPair(start); _keys[key] = w; } else if (w.StartMs != start) { // Roll forward. If a whole window passed with no traffic, the previous count is 0, // not the stale one: the key was idle for that window. bool justBefore = w.StartMs == start - windowMs; w.Previous = justBefore ? w.Current : 0; w.Current = 0; w.StartMs = start; } // The rolling window covers the end of the previous window and the start of this one. // Assume the previous window's requests were spread evenly and count the overlapping // share. This assumption is the whole approximation, and it is usually close. double overlap = (double)(windowMs - (nowMs - start)) / windowMs; double estimate = w.Previous * overlap + w.Current; if (estimate + 1 > limit) return false; // refused requests are not counted, so a hammering client recovers w.Current++; return true; }}
public static class Program{ private static int Admitted(SlidingWindowCounter limiter, long atMs, int attempts) { int n = 0; for (int i = 0; i < attempts; i++) if (limiter.TryAcquire("key_8f3", atMs)) n++; return n; }
public static void Main() { var limiter = new SlidingWindowCounter(10, 1000); // 10 a second Console.WriteLine(Admitted(limiter, 900, 12)); // 10: the first window fills at 0.9 s // A fixed window would allow 10 more at 1.1 s: 20 requests in 0.2 s. Here 90% of the // previous window still overlaps, so it counts as 9 and only one more fits. Console.WriteLine(Admitted(limiter, 1100, 12)); // 1 Console.WriteLine(Admitted(limiter, 1500, 12)); // 4: half the old window has slid out Console.WriteLine(Admitted(limiter, 3200, 12)); // 10: a full window of silence clears the history }}package main
import "fmt"
// WindowPair holds two counters per key: the current fixed window and the one before it.type WindowPair struct { StartMs int64 Current int Previous int}
// SlidingWindowCounter enforces "at most N per window", without the fixed window's double// burst at the boundary and without the sliding log's timestamp per request.type SlidingWindowCounter struct { limit int windowMs int64 keys map[string]*WindowPair}
func NewSlidingWindowCounter(limit int, windowMs int64) *SlidingWindowCounter { return &SlidingWindowCounter{limit: limit, windowMs: windowMs, keys: map[string]*WindowPair{}}}
func (s *SlidingWindowCounter) TryAcquire(key string, nowMs int64) bool { start := nowMs - nowMs%s.windowMs // windows align to the clock, like INCR keys w, ok := s.keys[key] if !ok { w = &WindowPair{StartMs: start} s.keys[key] = w } else if w.StartMs != start { // Roll forward. If a whole window passed with no traffic, the previous count is 0, // not the stale one: the key was idle for that window. if w.StartMs == start-s.windowMs { w.Previous = w.Current } else { w.Previous = 0 } w.Current = 0 w.StartMs = start } // The rolling window covers the end of the previous window and the start of this one. // Assume the previous window's requests were spread evenly and count the overlapping // share. This assumption is the whole approximation, and it is usually close. overlap := float64(s.windowMs-(nowMs-start)) / float64(s.windowMs) estimate := float64(w.Previous)*overlap + float64(w.Current) if estimate+1 > float64(s.limit) { return false // refused requests are not counted, so a hammering client recovers } w.Current++ return true}
func admitted(limiter *SlidingWindowCounter, atMs int64, attempts int) int { n := 0 for i := 0; i < attempts; i++ { if limiter.TryAcquire("key_8f3", atMs) { n++ } } return n}
func main() { limiter := NewSlidingWindowCounter(10, 1000) // 10 a second fmt.Println(admitted(limiter, 900, 12)) // 10: the first window fills at 0.9 s // A fixed window would allow 10 more at 1.1 s: 20 requests in 0.2 s. Here 90% of the // previous window still overlaps, so it counts as 9 and only one more fits. fmt.Println(admitted(limiter, 1100, 12)) // 1 fmt.Println(admitted(limiter, 1500, 12)) // 4: half the old window has slid out fmt.Println(admitted(limiter, 3200, 12)) // 10: a full window of silence clears the history}// Two counters per key: the current fixed window and the one before it.interface WindowPair { startMs: number; current: number; previous: number;}
// "At most N per window", without the fixed window's double burst at the boundary and without// the sliding log's timestamp per request.class SlidingWindowCounter { private readonly keys = new Map<string, WindowPair>();
constructor( private readonly limit: number, private readonly windowMs: number, ) {}
tryAcquire(key: string, nowMs: number): boolean { const start = nowMs - (nowMs % this.windowMs); // windows align to the clock, like INCR keys let w = this.keys.get(key); if (!w) { w = { startMs: start, current: 0, previous: 0 }; this.keys.set(key, w); } else if (w.startMs !== start) { // Roll forward. If a whole window passed with no traffic, the previous count is 0, // not the stale one: the key was idle for that window. const justBefore = w.startMs === start - this.windowMs; w.previous = justBefore ? w.current : 0; w.current = 0; w.startMs = start; } // The rolling window covers the end of the previous window and the start of this one. // Assume the previous window's requests were spread evenly and count the overlapping // share. This assumption is the whole approximation, and it is usually close. const overlap = (this.windowMs - (nowMs - start)) / this.windowMs; const estimate = w.previous * overlap + w.current; if (estimate + 1 > this.limit) return false; // refused requests are not counted, so a hammering client recovers w.current++; return true; }}
const admitted = (limiter: SlidingWindowCounter, atMs: number, attempts: number) => Array.from({ length: attempts }, () => limiter.tryAcquire('key_8f3', atMs)).filter(Boolean) .length;
const limiter = new SlidingWindowCounter(10, 1000); // 10 a secondconsole.log(admitted(limiter, 900, 12)); // 10: the first window fills at 0.9 s// A fixed window would allow 10 more at 1.1 s: 20 requests in 0.2 s. Here 90% of the// previous window still overlaps, so it counts as 9 and only one more fits.console.log(admitted(limiter, 1100, 12)); // 1console.log(admitted(limiter, 1500, 12)); // 4: half the old window has slid outconsole.log(admitted(limiter, 3200, 12)); // 10: a full window of silence clears the historyStep 4: Think in rate and burst: the token bucket
Section titled “Step 4: Think in rate and burst: the token bucket”Most real limits are stated as a sustained rate plus a tolerance for bursts: “100 a second, bursts
of 200”. The token bucket models that directly. A bucket holds up to b tokens and refills at
r per second. Each request spends one token (or several, for an expensive call). No token, no
admission. The refill is lazy: on each request, add elapsed × r, capped at b, so the state is
two numbers per key: tokens and last-update time.
A key idle long enough has a full bucket. It can spend all b at once, then r per second after
that. So a token bucket of “5 a second, bursts of 5” can admit close to 10 in a single second: 5
saved, plus up to 5 refilled. That is the rule doing what it says. It surprises people who read the
limit as “at most 5 in any second”.
The leaky bucket has two meanings. As a meter, it is the token bucket upside down: a level that
drains at r, and fills with each request. It behaves identically. As a queue, it holds requests
and releases them at exactly r a second. It refuses only when the queue is full.
The queue version does not refuse a burst. It delays it. That is traffic shaping. It’s right in front of a fragile downstream that needs a smooth rate, like an SMTP relay, or a partner API with a strict per-second cap. It’s wrong for an API that should answer at once.
Step 5: One timestamp: GCRA
Section titled “Step 5: One timestamp: GCRA”The generic cell rate algorithm (GCRA), from ATM networks, gives exactly token bucket behavior with one number per key. That number is the theoretical arrival time (TAT): the moment the key’s bucket would be full again, if nothing else arrived.
- The emission interval
Tis1 ÷ r: 200 ms for 5 a second. - On a request at time
now, computenew_tat = max(tat, now) + T. - If
new_tat − nowexceedsb × T, refuse. The wait is exactlynew_tat − now − b × T. - Otherwise store
new_tatand admit.
There is no refill loop and no second field to keep consistent. A refused request changes nothing,
so a client hammering the limit does not push its own recovery further away. The exact retry delay
falls out for free, and a TAT in the past means “full bucket”, so the key can expire at the TAT.
redis-cell and several popular libraries implement it.
import java.util.ArrayList;import java.util.HashMap;import java.util.List;import java.util.Map;
// Allow or refuse, plus the two numbers the 429 response and the headers need.record GcraDecision(boolean allowed, int remaining, double retryAfterMs) {}
// GCRA (generic cell rate algorithm): "rate r a second, bursts of b", as a token bucket would// enforce it, but stored as ONE number per key: the theoretical arrival time (TAT), the moment// this key's bucket would be full again if nothing else arrived.final class Gcra { private final double intervalMs; private final double burstOffsetMs; private final Map<String, Double> tat = new HashMap<>();
Gcra(double ratePerSec, int burst) { // Emission interval: the gap between requests at exactly the sustained rate. this.intervalMs = 1000 / ratePerSec; // How far ahead of now the TAT may run. b intervals means b requests at once. this.burstOffsetMs = intervalMs * burst; }
GcraDecision check(String key, double nowMs) { // A TAT in the past means the bucket is full: start from now. There is no refill // loop and no second field to keep in step, which is why one SET can store it. double t = Math.max(tat.getOrDefault(key, nowMs), nowMs); double newTat = t + intervalMs; double ahead = newTat - nowMs; if (ahead > burstOffsetMs) { // Refused: nothing is stored, so a client hammering the limit does not push its // own TAT further out. The wait is exact, not a rounded window reset. return new GcraDecision(false, 0, ahead - burstOffsetMs); } tat.put(key, newTat); int remaining = (int) Math.floor((burstOffsetMs - ahead) / intervalMs); return new GcraDecision(true, remaining, 0); }
void evictIdle(double nowMs) { // A key whose TAT has passed behaves exactly like a key that was never seen, so it // can be dropped. In Redis the same fact becomes a TTL of (TAT - now). tat.values().removeIf(t -> t <= nowMs); }}
class Main { public static void main(String[] args) { var limiter = new Gcra(5, 5); // 5 a second, bursts of 5 List<GcraDecision> burst = new ArrayList<>(); for (int i = 0; i < 7; i++) burst.add(limiter.check("key_8f3", 0)); System.out.println(burst.stream().map(GcraDecision::allowed).toList()); // five true, then false, false System.out.println(burst.get(5).retryAfterMs()); // 200.0: the next request fits one interval later System.out.println(limiter.check("key_8f3", 200).allowed()); // true System.out.println(limiter.check("key_8f3", 200).allowed()); // false: only one interval has passed }}from dataclasses import dataclass
# Allow or refuse, plus the two numbers the 429 response and the headers need.@dataclass(frozen=True)class GcraDecision: allowed: bool remaining: int retry_after_ms: float
# GCRA (generic cell rate algorithm): "rate r a second, bursts of b", as a token bucket# would enforce it, but stored as ONE number per key: the theoretical arrival time (TAT),# the moment this key's bucket would be full again if nothing else arrived.class Gcra: def __init__(self, rate_per_sec: float, burst: int): # Emission interval: the gap between requests at exactly the sustained rate. self._interval_ms = 1000 / rate_per_sec # How far ahead of now the TAT may run. b intervals means b requests at once. self._burst_offset_ms = self._interval_ms * burst self._tat: dict[str, float] = {}
def check(self, key: str, now_ms: float) -> GcraDecision: # A TAT in the past means the bucket is full: start from now. There is no refill # loop and no second field to keep in step, which is why one SET can store it. tat = max(self._tat.get(key, now_ms), now_ms) new_tat = tat + self._interval_ms ahead = new_tat - now_ms if ahead > self._burst_offset_ms: # Refused: nothing is stored, so a client hammering the limit does not push its # own TAT further out. The wait is exact, not a rounded window reset. return GcraDecision(False, 0, ahead - self._burst_offset_ms) self._tat[key] = new_tat remaining = int((self._burst_offset_ms - ahead) // self._interval_ms) return GcraDecision(True, remaining, 0)
def evict_idle(self, now_ms: float) -> None: # A key whose TAT has passed behaves exactly like a key that was never seen, so it # can be dropped. In Redis the same fact becomes a TTL of (TAT - now). self._tat = {k: t for k, t in self._tat.items() if t > now_ms}
if __name__ == "__main__": limiter = Gcra(rate_per_sec=5, burst=5) # 5 a second, bursts of 5 burst = [limiter.check("key_8f3", 0) for _ in range(7)] print([d.allowed for d in burst]) # five True, then False, False print(burst[5].retry_after_ms) # 200.0: the next request fits one interval later print(limiter.check("key_8f3", 200).allowed) # True print(limiter.check("key_8f3", 200).allowed) # False: only one interval has passedusing System;using System.Collections.Generic;using System.Linq;
// Allow or refuse, plus the two numbers the 429 response and the headers need.public readonly record struct GcraDecision(bool Allowed, int Remaining, double RetryAfterMs);
// GCRA (generic cell rate algorithm): "rate r a second, bursts of b", as a token bucket would// enforce it, but stored as ONE number per key: the theoretical arrival time (TAT), the moment// this key's bucket would be full again if nothing else arrived.public sealed class Gcra{ private readonly double _intervalMs; private readonly double _burstOffsetMs; private readonly Dictionary<string, double> _tat = new();
public Gcra(double ratePerSec, int burst) { // Emission interval: the gap between requests at exactly the sustained rate. _intervalMs = 1000 / ratePerSec; // How far ahead of now the TAT may run. b intervals means b requests at once. _burstOffsetMs = _intervalMs * burst; }
public GcraDecision Check(string key, double nowMs) { // A TAT in the past means the bucket is full: start from now. There is no refill // loop and no second field to keep in step, which is why one SET can store it. double t = Math.Max(_tat.GetValueOrDefault(key, nowMs), nowMs); double newTat = t + _intervalMs; double ahead = newTat - nowMs; if (ahead > _burstOffsetMs) { // Refused: nothing is stored, so a client hammering the limit does not push its // own TAT further out. The wait is exact, not a rounded window reset. return new GcraDecision(false, 0, ahead - _burstOffsetMs); } _tat[key] = newTat; int remaining = (int)Math.Floor((_burstOffsetMs - ahead) / _intervalMs); return new GcraDecision(true, remaining, 0); }
public void EvictIdle(double nowMs) { // A key whose TAT has passed behaves exactly like a key that was never seen, so it // can be dropped. In Redis the same fact becomes a TTL of (TAT - now). foreach (var k in _tat.Where(e => e.Value <= nowMs).Select(e => e.Key).ToList()) _tat.Remove(k); }}
public static class Program{ public static void Main() { var limiter = new Gcra(5, 5); // 5 a second, bursts of 5 var burst = Enumerable.Range(0, 7).Select(_ => limiter.Check("key_8f3", 0)).ToList(); Console.WriteLine(string.Join(' ', burst.Select(d => d.Allowed))); // five True, then False, False Console.WriteLine(burst[5].RetryAfterMs); // 200: the next request fits one interval later Console.WriteLine(limiter.Check("key_8f3", 200).Allowed); // True Console.WriteLine(limiter.Check("key_8f3", 200).Allowed); // False: only one interval has passed }}package main
import ( "fmt" "math")
// GcraDecision is allow or refuse, plus the two numbers the 429 response and the headers need.type GcraDecision struct { Allowed bool Remaining int RetryAfterMs float64}
// Gcra (generic cell rate algorithm) enforces "rate r a second, bursts of b", as a token bucket// would, but stores ONE number per key: the theoretical arrival time (TAT), the moment this// key's bucket would be full again if nothing else arrived.type Gcra struct { intervalMs float64 burstOffsetMs float64 tat map[string]float64}
func NewGcra(ratePerSec float64, burst int) *Gcra { // Emission interval: the gap between requests at exactly the sustained rate. interval := 1000 / ratePerSec // How far ahead of now the TAT may run. b intervals means b requests at once. return &Gcra{intervalMs: interval, burstOffsetMs: interval * float64(burst), tat: map[string]float64{}}}
func (g *Gcra) Check(key string, nowMs float64) GcraDecision { // A TAT in the past means the bucket is full: start from now. There is no refill // loop and no second field to keep in step, which is why one SET can store it. t, ok := g.tat[key] if !ok || t < nowMs { t = nowMs } newTat := t + g.intervalMs ahead := newTat - nowMs if ahead > g.burstOffsetMs { // Refused: nothing is stored, so a client hammering the limit does not push its // own TAT further out. The wait is exact, not a rounded window reset. return GcraDecision{false, 0, ahead - g.burstOffsetMs} } g.tat[key] = newTat remaining := int(math.Floor((g.burstOffsetMs - ahead) / g.intervalMs)) return GcraDecision{true, remaining, 0}}
func (g *Gcra) EvictIdle(nowMs float64) { // A key whose TAT has passed behaves exactly like a key that was never seen, so it // can be dropped. In Redis the same fact becomes a TTL of (TAT - now). for k, t := range g.tat { if t <= nowMs { delete(g.tat, k) } }}
func main() { limiter := NewGcra(5, 5) // 5 a second, bursts of 5 burst := make([]GcraDecision, 7) allowed := make([]bool, 7) for i := range burst { burst[i] = limiter.Check("key_8f3", 0) allowed[i] = burst[i].Allowed } fmt.Println(allowed) // five true, then false, false fmt.Println(burst[5].RetryAfterMs) // 200: the next request fits one interval later fmt.Println(limiter.Check("key_8f3", 200).Allowed) // true fmt.Println(limiter.Check("key_8f3", 200).Allowed) // false: only one interval has passed}// Allow or refuse, plus the two numbers the 429 response and the headers need.interface GcraDecision { allowed: boolean; remaining: number; retryAfterMs: number;}
// GCRA (generic cell rate algorithm): "rate r a second, bursts of b", as a token bucket would// enforce it, but stored as ONE number per key: the theoretical arrival time (TAT), the moment// this key's bucket would be full again if nothing else arrived.class Gcra { private readonly intervalMs: number; private readonly burstOffsetMs: number; private readonly tat = new Map<string, number>();
constructor(ratePerSec: number, burst: number) { // Emission interval: the gap between requests at exactly the sustained rate. this.intervalMs = 1000 / ratePerSec; // How far ahead of now the TAT may run. b intervals means b requests at once. this.burstOffsetMs = this.intervalMs * burst; }
check(key: string, nowMs: number): GcraDecision { // A TAT in the past means the bucket is full: start from now. There is no refill // loop and no second field to keep in step, which is why one SET can store it. const t = Math.max(this.tat.get(key) ?? nowMs, nowMs); const newTat = t + this.intervalMs; const ahead = newTat - nowMs; if (ahead > this.burstOffsetMs) { // Refused: nothing is stored, so a client hammering the limit does not push its // own TAT further out. The wait is exact, not a rounded window reset. return { allowed: false, remaining: 0, retryAfterMs: ahead - this.burstOffsetMs }; } this.tat.set(key, newTat); const remaining = Math.floor((this.burstOffsetMs - ahead) / this.intervalMs); return { allowed: true, remaining, retryAfterMs: 0 }; }
evictIdle(nowMs: number): void { // A key whose TAT has passed behaves exactly like a key that was never seen, so it // can be dropped. In Redis the same fact becomes a TTL of (TAT - now). for (const [k, t] of this.tat) if (t <= nowMs) this.tat.delete(k); }}
const limiter = new Gcra(5, 5); // 5 a second, bursts of 5const burst = Array.from({ length: 7 }, () => limiter.check('key_8f3', 0));console.log(burst.map((d) => d.allowed)); // five true, then false, falseconsole.log(burst[5].retryAfterMs); // 200: the next request fits one interval laterconsole.log(limiter.check('key_8f3', 200).allowed); // trueconsole.log(limiter.check('key_8f3', 200).allowed); // false: only one interval has passedAll six side by side, on the same request stream. Every lane is set to 5 a second:
Every limiter allows 5 requests a second. Tall green ticks were admitted, short red ticks were refused with a 429. The leaky bucket delays instead: amber runs from arrival to release. A red border means a lane admitted more than 5 in some 1 second span, shaded on its timeline.
Press Start, then fire a burst. Watch the right edge of each lane: who admits the burst, and who spreads it out.
Things to try:
- Turn background traffic off, then fire 12 across a window edge. The fixed window admits 10 within about 0.3 s: 5 before the edge, and 5 after. The sliding log and the sliding window counter admit 5.
- Fire a burst of 15. Token bucket and GCRA admit the 5 tokens they saved, plus one that refills during the burst. They refuse the rest at once. The leaky bucket admits about as many, but queues them, and the last one waits a second.
- Watch token bucket and GCRA. Their lanes are identical, request for request. Their “most in any 1 s” reaches 9 after an idle period: 5 saved, plus 4 refilled. (The next token lands exactly at the 1 s mark.) That’s burst plus refill, as designed.
- Switch to 8 requests a second. Everyone refuses some all the time. The leaky queue stays full, so every admitted request waits about a second: the cost of shaping under sustained overload. The sliding window counter’s peak can creep to 6: its estimate, not an exact count.
Step 6: Choose where to enforce
Section titled “Step 6: Choose where to enforce”A request passes several places that could refuse it. Each sees different information and costs a different amount to reject at.
- Color key
- Service
- Database
| Layer | Knows | Good for | Cannot |
|---|---|---|---|
| Client | Its own traffic | Pacing to the published limit, backing off on 429, saving round trips |
Be trusted: attackers do not run your SDK |
| Edge / CDN / WAF | IP, prefix, TLS fingerprint, path | Floods and bots, rejected before TLS and app work reach your origin | See the user or tenant unless it parses tokens |
| API gateway | Authenticated key, user, tenant, plan | Plan limits and fairness, the main place for per-key limits | Know what a request costs inside the service |
| Service | The operation, its cost, its own load | Business limits (5 password tries per account), concurrency, shedding | Refuse before the request already used a connection |
| Per dependency (egress) | Calls to one downstream | Staying under a partner’s quota, bulkheads so one slow dependency cannot take every thread | Help callers upstream unless it pushes back |
Production systems use several layers at once. The cheap, coarse limits run first. Per IP at the edge stops a flood before it reaches your origin. The precise ones run later, like per tenant at the gateway. The ones that need business context run last, like per account in the login service.
Step 7: Beyond fixed rates: load shedding and adaptive limits
Section titled “Step 7: Beyond fixed rates: load shedding and adaptive limits”A rate limit is a number someone chose. The server’s real capacity changes: a cold cache, a slow disk, a database failover, a deploy. Little’s law says requests in flight = arrival rate × latency. At 1,000 requests a second and 50 ms, the service holds 50 in flight.
Now say the database slows, and latency hits 500 ms. The same admitted rate means 500 in flight. The 200-thread pool is exhausted, queues grow, and every request times out. Every client was under its limit the whole time.
Two tools handle this, and neither is a per-key rate limit:
- Load shedding. The server refuses work when it is overloaded, measured by its own signals:
in-flight requests, queue time, CPU. It sheds the least important work first. Google’s internal
RPCs carry a criticality (critical, sheddable) for exactly this, and Stripe describes shedding
non-critical traffic when its fleet is near capacity. A shed request gets
503, not429, because the caller did nothing wrong. - Adaptive concurrency limits. Instead of a configured rate, the server learns how many requests
it can have in flight, by watching latency. It’s the way TCP finds a path’s bandwidth. Grow the
limit slowly while latency is fine. Cut it by a fraction when latency rises. (This is called
additive increase, multiplicative decrease, or AIMD.) Netflix’s
concurrency-limitslibrary and Envoy’s adaptive concurrency filter both do this.
// An adaptive concurrency limit: how many requests may be in flight at once, learned from// latency instead of configured. A fixed rate cannot know that the database just got slower;// this limit finds out within a few round trips and backs off, the way TCP does.final class AimdLimiter { double limit; private final int min; private final int max; private final double targetMs; private int inFlight; private int sinceCut; // completions since the last decrease
AimdLimiter(double initial, int min, int max, double targetMs) { this.limit = initial; this.min = min; this.max = max; this.targetMs = targetMs; }
boolean tryAcquire() { // Over the limit means refuse NOW (a fast 503), not queue: a queue would only turn // the overload into latency for every request behind it. if (inFlight >= Math.floor(limit)) return false; inFlight++; return true; }
void release(double latencyMs) { inFlight--; sinceCut++; if (latencyMs > targetMs) { // Multiplicative decrease, at most once per window of requests. Cutting on every // slow reply would collapse the limit within one round trip of a blip. if (sinceCut >= limit) { limit = Math.max(min, limit * 0.9); sinceCut = 0; } } else { // Additive increase: +1 for every `limit` fast replies, about +1 per round trip. // Probing upward slowly is what lets it find new capacity after a recovery. limit = Math.min(max, limit + 1 / limit); } }}
class Main { // Each round, 100 requests arrive at a service that runs `capacity` at once in 40 ms. // Anything above capacity waits its turn, so latency grows by 40 ms per extra batch. static int simulate(AimdLimiter limiter, int rounds, int capacity) { for (int r = 0; r < rounds; r++) { int admitted = 0; for (int i = 0; i < 100; i++) if (limiter.tryAcquire()) admitted++; double latency = 40 * Math.ceil((double) admitted / capacity); for (int i = 0; i < admitted; i++) limiter.release(latency); } return (int) Math.floor(limiter.limit); }
public static void main(String[] args) { var limiter = new AimdLimiter(10, 5, 200, 100); System.out.println(simulate(limiter, 60, 30)); // 55: healthy, it saws between 50 and 61 at 80 ms System.out.println(simulate(limiter, 30, 10)); // 21: the database slows, it backs off to about 20 System.out.println(simulate(limiter, 60, 30)); // 54: recovered, it climbs back by about 1 a round }}import math
# An adaptive concurrency limit: how many requests may be in flight at once, learned from# latency instead of configured. A fixed rate cannot know that the database just got slower;# this limit finds out within a few round trips and backs off, the way TCP does.class AimdLimiter: def __init__(self, initial: float, minimum: int, maximum: int, target_ms: float): self.limit = initial self._min, self._max = minimum, maximum self._target_ms = target_ms self._in_flight = 0 self._since_cut = 0 # completions since the last decrease
def try_acquire(self) -> bool: # Over the limit means refuse NOW (a fast 503), not queue: a queue would only turn # the overload into latency for every request behind it. if self._in_flight >= math.floor(self.limit): return False self._in_flight += 1 return True
def release(self, latency_ms: float) -> None: self._in_flight -= 1 self._since_cut += 1 if latency_ms > self._target_ms: # Multiplicative decrease, at most once per window of requests. Cutting on every # slow reply would collapse the limit within one round trip of a blip. if self._since_cut >= self.limit: self.limit = max(self._min, self.limit * 0.9) self._since_cut = 0 else: # Additive increase: +1 for every `limit` fast replies, about +1 per round trip. # Probing upward slowly is what lets it find new capacity after a recovery. self.limit = min(self._max, self.limit + 1 / self.limit)
# Each round, 100 requests arrive at a service that runs `capacity` at once in 40 ms.# Anything above capacity waits its turn, so latency grows by 40 ms per extra batch.def simulate(limiter: AimdLimiter, rounds: int, capacity: int) -> int: for _ in range(rounds): admitted = sum(limiter.try_acquire() for _ in range(100)) latency = 40 * math.ceil(admitted / capacity) for _ in range(admitted): limiter.release(latency) return math.floor(limiter.limit)
if __name__ == "__main__": limiter = AimdLimiter(initial=10, minimum=5, maximum=200, target_ms=100) print(simulate(limiter, 60, capacity=30)) # 55: healthy, it saws between 50 and 61 at 80 ms print(simulate(limiter, 30, capacity=10)) # 21: the database slows, it backs off to about 20 print(simulate(limiter, 60, capacity=30)) # 54: recovered, it climbs back by about 1 a roundusing System;
// An adaptive concurrency limit: how many requests may be in flight at once, learned from// latency instead of configured. A fixed rate cannot know that the database just got slower;// this limit finds out within a few round trips and backs off, the way TCP does.public sealed class AimdLimiter(double initial, int min, int max, double targetMs){ private int _inFlight; private int _sinceCut; // completions since the last decrease
public double Limit { get; private set; } = initial;
public bool TryAcquire() { // Over the limit means refuse NOW (a fast 503), not queue: a queue would only turn // the overload into latency for every request behind it. if (_inFlight >= Math.Floor(Limit)) return false; _inFlight++; return true; }
public void Release(double latencyMs) { _inFlight--; _sinceCut++; if (latencyMs > targetMs) { // Multiplicative decrease, at most once per window of requests. Cutting on every // slow reply would collapse the limit within one round trip of a blip. if (_sinceCut >= Limit) { Limit = Math.Max(min, Limit * 0.9); _sinceCut = 0; } } else { // Additive increase: +1 for every Limit fast replies, about +1 per round trip. // Probing upward slowly is what lets it find new capacity after a recovery. Limit = Math.Min(max, Limit + 1 / Limit); } }}
public static class Program{ // Each round, 100 requests arrive at a service that runs `capacity` at once in 40 ms. // Anything above capacity waits its turn, so latency grows by 40 ms per extra batch. private static int Simulate(AimdLimiter limiter, int rounds, int capacity) { for (int r = 0; r < rounds; r++) { int admitted = 0; for (int i = 0; i < 100; i++) if (limiter.TryAcquire()) admitted++; double latency = 40 * Math.Ceiling((double)admitted / capacity); for (int i = 0; i < admitted; i++) limiter.Release(latency); } return (int)Math.Floor(limiter.Limit); }
public static void Main() { var limiter = new AimdLimiter(10, 5, 200, 100); Console.WriteLine(Simulate(limiter, 60, 30)); // 55: healthy, it saws between 50 and 61 at 80 ms Console.WriteLine(Simulate(limiter, 30, 10)); // 21: the database slows, it backs off to about 20 Console.WriteLine(Simulate(limiter, 60, 30)); // 54: recovered, it climbs back by about 1 a round }}package main
import ( "fmt" "math")
// AimdLimiter is an adaptive concurrency limit: how many requests may be in flight at once,// learned from latency instead of configured. A fixed rate cannot know that the database just// got slower; this limit finds out within a few round trips and backs off, the way TCP does.type AimdLimiter struct { Limit float64 min, max float64 targetMs float64 inFlight int sinceCut int // completions since the last decrease}
func NewAimdLimiter(initial float64, min, max int, targetMs float64) *AimdLimiter { return &AimdLimiter{Limit: initial, min: float64(min), max: float64(max), targetMs: targetMs}}
func (l *AimdLimiter) TryAcquire() bool { // Over the limit means refuse NOW (a fast 503), not queue: a queue would only turn // the overload into latency for every request behind it. if float64(l.inFlight) >= math.Floor(l.Limit) { return false } l.inFlight++ return true}
func (l *AimdLimiter) Release(latencyMs float64) { l.inFlight-- l.sinceCut++ if latencyMs > l.targetMs { // Multiplicative decrease, at most once per window of requests. Cutting on every // slow reply would collapse the limit within one round trip of a blip. if float64(l.sinceCut) >= l.Limit { l.Limit = math.Max(l.min, l.Limit*0.9) l.sinceCut = 0 } } else { // Additive increase: +1 for every Limit fast replies, about +1 per round trip. // Probing upward slowly is what lets it find new capacity after a recovery. l.Limit = math.Min(l.max, l.Limit+1/l.Limit) }}
// simulate sends 100 requests a round to a service that runs capacity at once in 40 ms.// Anything above capacity waits its turn, so latency grows by 40 ms per extra batch.func simulate(limiter *AimdLimiter, rounds, capacity int) int { for r := 0; r < rounds; r++ { admitted := 0 for i := 0; i < 100; i++ { if limiter.TryAcquire() { admitted++ } } latency := 40 * math.Ceil(float64(admitted)/float64(capacity)) for i := 0; i < admitted; i++ { limiter.Release(latency) } } return int(math.Floor(limiter.Limit))}
func main() { limiter := NewAimdLimiter(10, 5, 200, 100) fmt.Println(simulate(limiter, 60, 30)) // 55: healthy, it saws between 50 and 61 at 80 ms fmt.Println(simulate(limiter, 30, 10)) // 21: the database slows, it backs off to about 20 fmt.Println(simulate(limiter, 60, 30)) // 54: recovered, it climbs back by about 1 a round}// An adaptive concurrency limit: how many requests may be in flight at once, learned from// latency instead of configured. A fixed rate cannot know that the database just got slower;// this limit finds out within a few round trips and backs off, the way TCP does.class AimdLimiter { private inFlight = 0; private sinceCut = 0; // completions since the last decrease
constructor( public limit: number, private readonly min: number, private readonly max: number, private readonly targetMs: number, ) {}
tryAcquire(): boolean { // Over the limit means refuse NOW (a fast 503), not queue: a queue would only turn // the overload into latency for every request behind it. if (this.inFlight >= Math.floor(this.limit)) return false; this.inFlight++; return true; }
release(latencyMs: number): void { this.inFlight--; this.sinceCut++; if (latencyMs > this.targetMs) { // Multiplicative decrease, at most once per window of requests. Cutting on every // slow reply would collapse the limit within one round trip of a blip. if (this.sinceCut >= this.limit) { this.limit = Math.max(this.min, this.limit * 0.9); this.sinceCut = 0; } } else { // Additive increase: +1 for every `limit` fast replies, about +1 per round trip. // Probing upward slowly is what lets it find new capacity after a recovery. this.limit = Math.min(this.max, this.limit + 1 / this.limit); } }}
// Each round, 100 requests arrive at a service that runs `capacity` at once in 40 ms.// Anything above capacity waits its turn, so latency grows by 40 ms per extra batch.function simulate(limiter: AimdLimiter, rounds: number, capacity: number): number { for (let r = 0; r < rounds; r++) { let admitted = 0; for (let i = 0; i < 100; i++) if (limiter.tryAcquire()) admitted++; const latency = 40 * Math.ceil(admitted / capacity); for (let i = 0; i < admitted; i++) limiter.release(latency); } return Math.floor(limiter.limit);}
const limiter = new AimdLimiter(10, 5, 200, 100);console.log(simulate(limiter, 60, 30)); // 55: healthy, it saws between 50 and 61 at 80 msconsole.log(simulate(limiter, 30, 10)); // 21: the database slows, it backs off to about 20console.log(simulate(limiter, 60, 30)); // 54: recovered, it climbs back by about 1 a roundIn the run above, the limit settles between 50 and 61 while the service is healthy. It falls to about 20 within a few rounds when the database slows. And it climbs back by about 1 a round when it recovers. No one edited a config.
Options and tradeoffs
Section titled “Options and tradeoffs”The algorithm
Section titled “The algorithm”| Algorithm | State per key | Bursts | Accuracy | Retry-After | Wins when |
|---|---|---|---|---|---|
| Fixed window | 1 counter (~70 bytes in Redis) | Up to 2× the limit across a window edge | Off by up to 2× at edges | Next window start | Coarse limits where the edge burst does not matter |
| Sliding log | 1 timestamp per request (~80 B each) | Exactly N in any window | Exact | Oldest entry + window | Small limits that must be exact (login attempts) |
| Sliding window counter | 2 counters | Small overshoot, near 2× if paced adversarially | Approximate (0.003% in Cloudflare’s data) | Estimated | “N per minute/hour” stated as a window, high volume |
| Token bucket | 2 numbers (tokens, last time) | Up to b at once, then r a second | Exact for “rate r, burst b” | Exact | Limits stated as rate plus burst; weighted request cost |
| Leaky bucket (queue) | A queue of up to b requests + 1 time | Delayed, never passed through faster than r | Exact output rate | Not refused, delayed | Shaping traffic to a fragile downstream |
| GCRA | 1 timestamp (TAT) | Same as token bucket | Same as token bucket | Exact | Token bucket semantics with the least state and one SET |
The key
Section titled “The key”What you count by decides who shares a budget.
| Key | Wins when | Pitfall |
|---|---|---|
| API key | Public APIs, machine clients | One key shared by a customer’s whole fleet: 200 servers share one budget |
| User ID | Logged-in products, per-person actions | Unavailable before login, which is where abuse starts |
| Tenant / org | B2B fairness, plan limits | One big tenant’s users starve each other; pair with per-user limits inside it |
| IP address | Before authentication: login, signup, floods | Carrier-grade NAT puts hundreds to thousands of users behind one IPv4 address; IPv6 gives one user 2^64 of them |
| IPv6 /64 (or /56) | The IPv6 equivalent of “one client” | Some providers hand each customer a /56 or /48, so a determined client still rotates |
| Account + action | Security limits (password tries, OTP sends) | Lets an attacker lock a victim out, so refuse with backoff or a challenge, not a hard lock |
Two IP mistakes deserve names. Trusting X-Forwarded-For: the leftmost entry is whatever the
client wrote, so a limiter keyed on it lets an attacker pick a fresh key per request. Use the address
your own edge appended (the rightmost entry added by a proxy you trust). Per-address IPv6
limits: a home connection typically gets a /64, so limiting each address limits nothing. Key IPv6
on the /64 and, for abuse, also on the /48.
Counting across many servers
Section titled “Counting across many servers”Behind a load balancer, one key’s requests land on every gateway. Where the count lives is the distributed systems part of rate limiting.
| Option | How | Accuracy | Cost |
|---|---|---|---|
| Local only, limit ÷ N | Each of N gateways enforces its share in memory | Poor when traffic spreads unevenly or limits are small | Nothing: no network call |
| Central store, atomic script | Redis holds the counter, a Lua script reads, decides and writes in one step | Exact | A round trip per check (~0.3 to 1 ms), a store to run |
| Local + periodic sync | Gateways count locally, push deltas and pull totals every 100 ms to 1 s | Overshoot up to what all N admit in one sync interval | Background traffic, more complex code |
| Cell-based | Each cell (a full stack serving a slice of tenants) has its own limiter store | Exact within a cell, approximate for limits across cells | Tenants must be routed to a home cell |
The central store needs one property above all: check and update in one atomic step. Read, then write from the gateway, and two gateways can both see the last token:
- Color key
- Cache
- Service
Redis runs a script to completion before any other command, which is why the script is the unit of correctness. GCRA keeps it short:
-- KEYS[1]: the key's TAT. ARGV[1]: interval in ms. ARGV[2]: burst.local t = redis.call('TIME') -- the store's clock, not 300 gateway clockslocal now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)local interval, burst = tonumber(ARGV[1]), tonumber(ARGV[2])-- TIME in a script that writes needs effect replication: the default since Redis 5, the only mode in 7.local tat = math.max(tonumber(redis.call('GET', KEYS[1])) or now, now)local ahead = tat + interval - nowif ahead > interval * burst then return {0, ahead - interval * burst} -- refused, and the exact retry delayendredis.call('SET', KEYS[1], tat + interval, 'PX', math.ceil(ahead)) -- gone once the bucket is fullreturn {1, 0}At very high rates per key, even a central store becomes a hot spot. The usual answers are token leases (a gateway takes a batch of tokens, and spends them locally) and a refusal cache. Both are built in stage 5 of the rate limiter problem.
Telling the client
Section titled “Telling the client”A refusal is part of the API. It should tell the client when to come back, and responses that succeed should tell it how close it is.
HTTP/1.1 429 Too Many RequestsRetry-After: 2RateLimit-Policy: "default";q=100;w=60RateLimit: "default";r=0;t=2Content-Type: application/problem+json
{ "title": "Too many requests", "detail": "100 requests per 60 s for this API key." }429 Too Many Requests(RFC 6585): this caller, specifically, is over a limit.Retry-After(RFC 9110): seconds, or an HTTP date. Whole seconds only, so round up.RateLimit-PolicyandRateLimit: the IETF httpapi working group’s draft, in its recent structured-field form. It gives the policy’s quotaqper windoww, and the remainingr, withtseconds until it resets. It is still an Internet-Draft, and its syntax has changed between revisions, so name the revision you implement. Earlier drafts usedRateLimit-Limit,RateLimit-RemainingandRateLimit-Reset. Most public APIs still send theX-RateLimit-*family. GitHub, for example, sendsx-ratelimit-limit,-remaining,-usedand-reset(a Unix timestamp).
Clients must add jitter to Retry-After. If 5,000 clients are refused at once and all retry in
exactly 2 seconds, the limiter sees the same spike again 2 seconds later.
Go deeperHeader details that cause support tickets1 min
The most common confusion is whether Reset is seconds-until or a Unix timestamp. Document which.
Prefer seconds-until, which needs no clock agreement. Send the headers of the tightest rule, the
one closest to refusing. Do not send limit headers for security rules (login attempts). They tell an
attacker exactly how fast to go.
For a shed request, send 503 with Retry-After, never 429. Then clients do not conclude they
personally misbehaved. Some APIs return 403 for abuse limits. That tells well-behaved clients to
stop forever, which is rarely what you mean.
How it fails
Section titled “How it fails”The limiter’s store is down or slow. Every request now waits on a timeout, and the limiter has become the outage. Decide per rule what happens, and bound the time it takes to decide:
- Color key
- Service
- Database
Fail open for fairness limits: a few seconds without per-key limits is far better than refusing every customer. Fail closed for limits that are the security or cost control: login attempts, password resets, SMS sends. A local fallback sits in between and keeps a runaway client within about twice its limit. The rate limiter problem’s deep dive walks through the numbers.
Limits that protect no one. Every key is limited. A botnet of 50,000 IPs each sends 2 requests a second, well under the per-IP limit of 10. The service receives 100,000 a second. Per-key limits bound each caller, never the total. Detect it as total traffic far above normal, with no single key over its limit. Fix it with a global concurrency limit or load shedding at the service. And add edge rules that look at wider signals (prefix, fingerprint, path).
Synchronized retries. Fixed windows reset on the clock, so refused clients all come back at :00.
Retry-After without client jitter does the same. The limiter sees a sawtooth of spikes at exactly
the moment it has the least headroom. Use sliding windows or buckets, and jitter retries.
The wrong key. Keying on a spoofable header lets attackers rotate keys freely. Keying on a shared IP refuses a whole office, or a mobile carrier’s customers, because one of them misbehaves. Detect it as many refusals for one key, from many distinct user agents or accounts. Or as one user’s requests spread across thousands of keys.
A bad rule push. Someone sets a limit to 10 instead of 10,000, and every customer gets 429.
Ship new rules in shadow mode first: evaluate and log would-be refusals, but refuse nothing. Roll
them out gradually. And alert on the refusal rate per rule.
Hot keys. One tenant at 300,000 requests a second lands on one store shard however many shards there are. Token leases and a refusal cache keep that shard’s load bounded.
| Failure | Blast radius | Detect | Fix |
|---|---|---|---|
| Store down or slow | Every request, through latency | Limiter p99 and timeout rate per shard | 5 ms timeout, breaker, fail mode per rule |
| Distributed abuse under limits | The whole service | Total load high, no key over its limit | Concurrency limit, load shedding, edge rules |
| Synchronized retries | Spikes at window resets | Request rate sawtooth aligned to the clock | Buckets or sliding windows, jittered retries |
| Wrong or spoofable key | Innocent users refused, or no limit | Refusals per key vs distinct users behind it | Trusted client IP, IPv6 /64, layered keys |
| Bad rule push | Every customer under that rule | Refusal rate per rule jumps after a deploy | Shadow mode, gradual rollout, fast rollback |
| Hot key | One shard, everyone on it | Calls per key per shard | Token leases, refusal cache, dedicated shards |
At scale
Section titled “At scale”Numbers to remember.
| What | Typical figure |
|---|---|
| Redis round trip in the same zone | 0.2 to 0.5 ms; budget about 1 ms for the whole check |
| One Redis shard running short Lua scripts | Tens of thousands to about 100,000 a second |
| Token bucket or GCRA state per key in Redis | About 60 to 100 bytes; 10 million active keys is about 1 GB |
| Sliding log at 1,000 a minute | About 80 KB per key |
| Sliding window counter error (Cloudflare) | 0.003% of 400 million requests decided wrongly |
| GitHub REST API | 5,000 requests an hour per authenticated user, 60 per IP unauthenticated |
| AWS API Gateway default account throttle | 10,000 requests a second, bursts of 5,000, per Region (token bucket) |
| Carrier-grade NAT | Hundreds to thousands of subscribers per public IPv4 address |
At 10x (from 10,000 to 100,000 checks a second), one Redis instance is no longer enough. Shard the counters by key, with a hash tag, so all of one key’s rules live on one shard. Then one script sees them all. Load the scripts once, and call them by SHA. Put a short-lived refusal cache in each gateway. Then a client stuck at its limit costs one store call per retry interval, instead of one per request.
At 100x (millions of checks a second, several regions), the round trip to a central store is too expensive for every request. Gateways spend leased tokens locally, and sync in batches, accepting a small overshoot.
Global limits across regions split each key’s budget between regions, and rebalance every few seconds. That’s because a cross-region call (60 to 150 ms) on the hot path is out of the question. Cell-based designs keep each tenant’s counters inside its home cell. Server-side concurrency limits matter more, because no configured rate tracks the real capacity of a fleet that large.
What to monitor:
- Refusals per rule and per key, with the top keys by refusals. A new key at the top is either an incident at a customer or an attack.
- Limiter check latency (p99) and store timeout rate per shard. The limiter’s latency is added to every request.
- Fail-open time: how many seconds a rule ran without enforcement, and how many requests passed.
- Shadow mode would-be refusals for every rule before it enforces.
- Total traffic against the sum of what the limits allow, to see how far the limits are from protecting capacity.
- For adaptive limits: the current limit and the shed rate. A limit falling steadily is an early warning that a dependency is slowing down.
Go deeperClient-side adaptive throttling1 min
Google’s SRE book describes clients that refuse their own requests, before sending them. Each client
tracks two counts over the last two minutes: requests (attempted) and accepts (accepted by the
backend). It drops a new request locally with probability
max(0, (requests − K × accepts) ÷ (requests + 1)), with K typically 2.
While the backend accepts everything, nothing is dropped. When the backend starts refusing, clients stop sending most of the traffic that would have been refused anyway. So refusals stop costing the backend work. It suits internal service-to-service calls, where you control the clients.
In the interview
Section titled “In the interview”Interviewer asksToken bucket or sliding window counter for a public API? Defend the choice.
It depends on how the limit is stated. Most API plans read as a rate with a burst: “100 a second,
bursts of 200”. A token bucket enforces exactly that, with two numbers per key (or GCRA with one).
And it gives an exact Retry-After. Bursts are normal for API clients. A page load fires 20
calls, and then goes quiet. If the product promises a window, “1,000 an hour, resets at the top of
the hour”, a sliding window counter fits better. It’s still two integers per key, without the
fixed window’s double burst at the edge. I would not use a sliding log above small limits: 1,000 a
minute is 80 KB a key. And I would point out that a bucket can admit burst plus rate in one
second. So a downstream that truly cannot take more needs a smaller burst.
Interviewer asksEvery API key is rate limited, yet a traffic spike still took the database down. How?
Per-key limits bound each caller, not the total. It happens two ways. Many keys are each under
their limit (a launch, a botnet, a partner onboarding thousands of users). Or the database got
slower, so the same request rate meant many more requests in flight, and the connection pool ran
out. The fix is a second mechanism that measures the server. Put a concurrency limit at the
service, ideally adaptive. It sheds load with 503 when in-flight requests or latency climb,
lowest-priority traffic first. Rate limits stay, for fairness and abuse.
Interviewer asksWhere in the system would you enforce rate limits?
In layers. At the edge, coarse per-IP and per-prefix limits stop floods, before they cost TLS and
app work. At the API gateway, limits per API key, user and tenant enforce plans and fairness.
They’re backed by a shared store, so the count is right across all gateways. Inside services,
business limits that need context, such as 5 password attempts per account per 15 minutes. Plus a
concurrency limit for overload. On the way out, limits per dependency keep us under a partner’s
quota. Clients get headers and Retry-After, so well-behaved ones pace themselves.
Interviewer asksThe Redis cluster behind the limiter goes down. What happens?
Nothing should wait more than a few milliseconds. The check has a 5 ms timeout, and a circuit breaker per shard, so we stop paying even that. Then each rule’s fail mode applies. Fairness limits fail open. Or better, they fall back to local buckets at the limit divided by the gateway count. So a runaway client stays within about twice its limit. Security and cost limits, like login attempts and SMS sends, fail closed. Failing them open is a brute-force window. We alert on time spent failed open.
Seen in
Section titled “Seen in”- Rate limiter: the full distributed design, with token buckets in sharded Redis, local leases, hot keys, fail modes per rule and limits across regions.
- Job scheduler: a token bucket per tenant on claims, and
429on create past a pending-runs quota. So one tenant’s burst drains at its share. - Notification system: limits per producer, and
honoring each push provider’s
429andRetry-Afterwith jitter on the way out. - Pastebin: per account, IP and network limits on creates, in front of the size limit and the malware scan.
- Ticketmaster: why a rate limiter is the wrong tool for an on-sale spike (random winners reward bots). A waiting room is used instead, with limits as the backstop.
- Web crawler: rate limiting turned inside out, a politeness
limit per host on our own requests, backing off on
429and503. - Metrics and monitoring: per-team ingest limits at the distributor, so one team’s surge cannot slow every other team’s writes.
- Chat: sends limited per device, about 20 a second, and new conversations limited per account to slow spam.
The job. Bound what each key does per unit of time, for abuse, fairness, cost and protecting downstreams. Per-key limits never bound the total: that is load shedding’s job.
Algorithms. Fixed window is cheap but admits 2× at the edge. Sliding log is exact and costs a timestamp per request. Sliding window counter is two integers and nearly exact. Token bucket and GCRA enforce “rate plus burst” exactly, GCRA with one timestamp, and admit up to burst plus rate in one second. Leaky bucket as a queue delays instead of refusing.
Where. Client paces itself, edge stops floods by IP, gateway enforces per key and tenant, service applies business limits and concurrency, egress limits protect dependencies.
Keys. API key, user, tenant after login, IP before it. NAT puts thousands behind one IPv4, IPv6
keys on the /64, and never trust the leftmost X-Forwarded-For.
Distributed. A central store with an atomic script, for exact counts. Local leases and sync, for very high rates. Cells or regional budgets, at global scale.
Responses. 429 with Retry-After for over-limit callers, 503 for overload, limit headers on
every response, jitter in every client.
Failure. Bounded timeout and breaker, fail open for fairness, fail closed for security and cost, local fallback in between, shadow mode for new rules.
Further reading
Section titled “Further reading”- The distributed rate limiter problem, and the rate limiter class design, on this site. Plus load balancing, for retry budgets and shedding at the balancer.
- Scaling your API with rate limiters, Stripe: request rate limits, concurrency limits and two kinds of load shedder in production.
- RateLimit header fields for HTTP,
the IETF draft for
RateLimitandRateLimit-Policy. - Netflix concurrency-limits: adaptive concurrency limits (AIMD, Vegas, Gradient) as a library, with the reasoning behind them.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.