Low Level DesignWorked problems
Design a rate limiter
Design a rate limiter step by step for a low level design interview. Fixed window versus token bucket behind one interface, several rules per request with refunds, memory that stays bounded, and thread safety, with code in Java, Python, C#, Go and TypeScript.
- Read first
- Object modelingThread safety basics
- Building blocks
- Rate limiting
“Design a rate limiter: each user may make at most 10 requests per second.” The low-level version is a class inside one service. The version spread across many servers is a system design question of its own.
A rate limiter decides, for each incoming request, “let it through” or “too many, try again later”. It protects a service from clients that send too much, by accident or on purpose.
The first version takes five minutes: a counter per user that resets every second. Everything after that is about what the counter gets wrong. At the edge of a second it lets twice the limit through. It remembers every client forever. It can’t apply two limits at once without wasting one of them. And with many threads, two requests can both take the last free slot.
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains how we get here.
- Each client gets a token bucket. A bucket holds a few tokens and refills at a steady rate. A request takes one token, or is refused if the bucket is empty. Short bursts are allowed, but the rate over time can’t go above the limit.
- The algorithm sits behind an interface. The token bucket is one kind of
Bucket. A fixed window or a sliding window can be swapped in, rule by rule. - A request must pass every rule, all or nothing. For example, per user and per IP address. If one rule refuses, the tokens already taken from the other rules are given back.
- Idle clients are forgotten. A bucket left alone for a full period is the same as a new one, so we can safely delete it. Memory grows with active clients, not with every client ever seen.
- Each bucket has its own small lock. Taking a token is one step that can’t be split. A thread holds at most one bucket’s lock at a time, so threads can never block each other forever.
We’ll build this in four stages, then deal with many threads at once, and with many servers.
Scope it
Section titled “Scope it”Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.
Design a rate limiter for our API.
Whether you ask what is limited (user, IP, endpoint), whether bursts are OK, and whether this is one process or many, before naming an algorithm. Opening with “token bucket in Redis” skips the question that decides the design.
Here are the questions that matter:
- Limited by what? Several rules: per user, per IP address, per endpoint. With only one rule, stage 3 goes away.
- Are short bursts OK? Yes, up to the limit, then a steady rate. If no bursts were allowed at all, we’d need a queue that releases requests evenly instead.
- What happens to a refused request? It’s refused right away, with a time to retry after. If refused requests had to wait in line, this would be a scheduler, not a limiter.
- One process or many? One first. Many is the follow-up, and it moves the state into a shared store like Redis.
- How many different clients? Millions, most seen only once. With a few known clients, we could keep their state forever.
These answers give us one promise that matters more than the others: no more than the limit ever gets through. Not at the edge of a time window, and not when many threads check at once. Three smaller promises come with it. Memory grows with active clients only. A check never waits for another client’s traffic. And time is passed in, so tests can check refills without sleeping.
We leave out queueing refused requests, billing quotas that must survive a restart, and the HTTP
part. Turning a refusal into a 429 Too Many Requests response with a Retry-After header is one
line in the web layer.
Go deeperThree more questions, and what each changes1 min
| Question | We assume | If the answer were different |
|---|---|---|
| Do some requests cost more than one? | No, one token each | A cost per request means tryTake(n), and n above the cap is refused |
| Do limits change while running? | Rarely, on deploy | Live changes mean swapping the whole rule list in one step |
| Exact, or roughly right? | Exact within one process | Roughly right (a few percent over) allows cheaper multi-server designs |
Build it
Section titled “Build it”We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram).
Stage 1: A counter per window
Section titled “Stage 1: A counter per window”Start simple. One limit: 10 requests per second per user.
Whether you can get a working limiter up in a few minutes and then find its flaw yourself. The flaw at the window edge is the one they want you to name.
Start with the smallest thing that works. Cut time into one-second windows, lined up on the clock: 12:00:00, 12:00:01, and so on. Each client has a count for its current window. A request in a new window resets the count to zero. A request that would push the count past 10 is refused.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// Fixed window: count each client's requests in the current window, and refuse once the// count reaches the limit. Time is injected from the start: a limiter is all about time,// and a test that sleeps is not a test.class FixedWindowLimiter { private final int limit; private final long windowNanos; private final LongSupplier clock; // System::nanoTime in production private final Map<String, Window> windows = new HashMap<>();
// One client's current window: when it started and how many requests it has seen. private static final class Window { long start; int count; }
FixedWindowLimiter(int limit, Duration window, LongSupplier clock) { this.limit = limit; this.windowNanos = window.toNanos(); this.clock = clock; }
boolean allow(String client) { long now = clock.getAsLong(); long start = now - Math.floorMod(now, windowNanos); // windows line up: 12:00:00, 12:00:01... Window w = windows.computeIfAbsent(client, k -> new Window()); if (w.start != start) { // a new window has begun: the old count no longer matters w.start = start; w.count = 0; } // flaw 1: a client can send `limit` requests at 12:00:00.999 and `limit` more at // 12:00:01.000: twice the limit in one millisecond. Stage 2. if (w.count >= limit) return false; w.count++; return true; // flaw 2: every client ever seen keeps a Window forever. Stage 4. }}import timefrom typing import Callable
# Fixed window: count each client's requests in the current window, and refuse once the# count reaches the limit. Time is injected from the start: a limiter is all about time,# and a test that sleeps is not a test.class FixedWindowLimiter: def __init__(self, limit: int, window_seconds: float, clock: Callable[[], float] = time.monotonic): self._limit = limit self._window = window_seconds self._clock = clock self._windows: dict[str, tuple[float, int]] = {} # client -> (window start, count)
def allow(self, client: str) -> bool: now = self._clock() start = now - now % self._window # windows line up: 12:00:00, 12:00:01... w_start, count = self._windows.get(client, (start, 0)) if w_start != start: # a new window has begun: the old count no longer matters count = 0 # flaw 1: a client can send `limit` requests at 12:00:00.999 and `limit` more at # 12:00:01.000: twice the limit in one millisecond. Stage 2. if count >= self._limit: return False self._windows[client] = (start, count + 1) return True # flaw 2: every client ever seen keeps an entry forever. Stage 4.// Fixed window: count each client's requests in the current window, and refuse once the// count reaches the limit. Time comes from an injected TimeProvider: a limiter is all// about time, and a test that sleeps is not a test.public sealed class FixedWindowLimiter(int limit, TimeSpan window, TimeProvider time){ private readonly Dictionary<string, (long Start, int Count)> _windows = new();
public bool Allow(string client) { long now = time.GetUtcNow().Ticks; long start = now - now % window.Ticks; // windows line up: 12:00:00, 12:00:01... var (wStart, count) = _windows.GetValueOrDefault(client, (start, 0)); if (wStart != start) count = 0; // a new window has begun: the old count no longer matters // flaw 1: a client can send `limit` requests at 12:00:00.999 and `limit` more at // 12:00:01.000: twice the limit in one millisecond. Stage 2. if (count >= limit) return false; _windows[client] = (start, count + 1); // flaw 2: every client stays forever. Stage 4. return true; }}// Fixed window: count each client's requests in the current window, and refuse once the// count reaches the limit. The clock is injected from the start: a limiter is all about// time, and a test that sleeps is not a test.type window struct { start time.Time count int}
type FixedWindowLimiter struct { limit int window time.Duration clock func() time.Time windows map[string]*window}
func NewFixedWindowLimiter(limit int, w time.Duration, clock func() time.Time) *FixedWindowLimiter { return &FixedWindowLimiter{limit: limit, window: w, clock: clock, windows: map[string]*window{}}}
func (l *FixedWindowLimiter) Allow(client string) bool { start := l.clock().Truncate(l.window) // windows line up: 12:00:00, 12:00:01... w, ok := l.windows[client] if !ok { w = &window{start: start} l.windows[client] = w // flaw 2: every client ever seen stays forever. Stage 4. } if !w.start.Equal(start) { // a new window has begun: the old count no longer matters w.start, w.count = start, 0 } // flaw 1: a client can send `limit` requests at 12:00:00.999 and `limit` more at // 12:00:01.000: twice the limit in one millisecond. Stage 2. if w.count >= l.limit { return false } w.count++ return true}// Fixed window: count each client's requests in the current window, and refuse once the// count reaches the limit. The clock (milliseconds) is injected from the start: a limiter// is all about time, and a test that sleeps is not a test.class FixedWindowLimiter { private readonly windows = new Map<string, { start: number; count: number }>();
constructor( private readonly limit: number, private readonly windowMs: number, private readonly clock: () => number = Date.now, ) {}
allow(client: string): boolean { const now = this.clock(); const start = now - (now % this.windowMs); // windows line up: 12:00:00, 12:00:01... let w = this.windows.get(client); if (!w) { w = { start, count: 0 }; this.windows.set(client, w); // flaw 2: every client ever seen stays forever. Stage 4. } if (w.start !== start) { // a new window has begun: the old count no longer matters w.start = start; w.count = 0; } // flaw 1: a client can send `limit` requests at 12:00:00.999 and `limit` more at // 12:00:01.000: twice the limit in one millisecond. Stage 2. if (w.count >= this.limit) return false; w.count++; return true; }}It’s cheap, just two numbers per client. But it has two flaws, and we leave them in on purpose.
The first is the edge. A client can send 10 requests at 12:00:00.999, and 10 more at 12:00:01.000. Both windows are within their limit. But that’s 20 requests in one millisecond, under a “10 per second” rule. Every window edge allows twice the limit.
The second is memory. Every client ever seen keeps an entry. A scanner that uses a new IP address for every request grows the map forever. Stage 2 fixes the edge, and stage 4 fixes the memory.
Stage 2: The algorithm behind an interface
Section titled “Stage 2: The algorithm behind an interface”Fix the burst at the window edge.
Whether you know the standard algorithms well enough to compare them by the burst they allow and the memory they need, and pick one for a reason. Listing names without the trade-offs is the weak version.
There are five standard algorithms. They differ in two ways: how big a burst they allow, and how much they store for each client.
| Algorithm | How | Biggest burst | Stored per client |
|---|---|---|---|
| Fixed window | A count per window lined up on the clock | 2× the limit at an edge | 2 numbers |
| Sliding log | Keep each request’s time; count those in the last period | Exactly the limit | One time per request |
| Sliding window counter | This window’s count plus the last one’s, weighted by overlap | About the limit | 3 numbers |
| Token bucket (our pick) | Tokens refill at a steady rate up to a cap; each request takes one | The cap, then the rate | 2 numbers |
| Leaky bucket | Requests wait in a queue and leave at a fixed rate | None; smooths everything | A queue |
The token bucket is our default. Picture a bucket that holds up to 10 tokens. Tokens drip in at 10 per second, and the bucket never holds more than 10. Each request takes one token. If the bucket is empty, the request is refused.
A client that has been quiet has a full bucket, so it can send 10 requests at once. That matches real clients: loading one page often fires ten calls together. But over any span of time, the rate can’t go above the limit, and there’s no window edge to take advantage of. The bucket stores two numbers: the tokens left, and when they were last counted.
Do we need a timer that adds tokens to every bucket? No. The refill is lazy: when a bucket is used, it works out how many tokens dripped in since the last use, and adds them. A million idle buckets cost no CPU at all.
We put the algorithm behind a Bucket interface with three methods: tryTake, refund (stage 3
needs it) and retryAfter. Choosing the algorithm for a rule is then choosing which kind of bucket
to create. A BucketFactory creates the bucket the first time a client is seen. This is the
Strategy pattern (one interface, one class per algorithm), and the Factory pattern for
creating them.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// "permits per period", such as 10 per second. A value, so rules can share and compare it.record Limit(int permits, Duration per) { static Limit of(int permits, Duration per) { return new Limit(permits, per); }}
// What the caller needs to answer the request: yes or no, and when to try again.// retryAfterNanos becomes the Retry-After header on an HTTP 429.record Decision(boolean allowed, long retryAfterNanos) {}
// One client's allowance under one limit. An interface, because token bucket and fixed// window (and sliding window, and leaky bucket) are interchangeable answers to// "may this key go now?".interface Bucket { boolean tryTake(long now); void refund(long now); // gives a permit back when a later rule says no long retryAfterNanos(long now); // 0 if a permit is available now}
// Holds up to `permits` tokens and refills continuously at permits/period. Each request// takes one. A client may burst up to the capacity, but over any long stretch the rate// can't exceed the limit, and there is no window edge to game.class TokenBucket implements Bucket { private final double capacity; private final double refillPerNano; private double tokens; private long updatedAt; // when `tokens` was last brought up to date
TokenBucket(Limit limit, long now) { this.capacity = limit.permits(); this.refillPerNano = limit.permits() / (double) limit.per().toNanos(); this.tokens = capacity; // a new client starts with a full bucket this.updatedAt = now; }
// Lazy refill: no timer per bucket, just catch up when the bucket is used. // A million idle buckets cost no CPU at all. private void refill(long now) { if (now <= updatedAt) return; // the clock didn't move (or went backwards): nothing to add tokens = Math.min(capacity, tokens + (now - updatedAt) * refillPerNano); updatedAt = now; }
public boolean tryTake(long now) { refill(now); if (tokens < 1) return false; tokens -= 1; return true; }
// Gives back a permit when a later rule refused the request. Never above capacity. public void refund(long now) { refill(now); tokens = Math.min(capacity, tokens + 1); }
public long retryAfterNanos(long now) { refill(now); return tokens >= 1 ? 0 : (long) Math.ceil((1 - tokens) / refillPerNano); }}
// The fixed-window algorithm behind the same interface: count requests in each window of the// clock, and start again at each edge. Cheaper (two numbers, no floating point), and fine for// long periods like "1,000 per day", where a burst at midnight is acceptable.class FixedWindow implements Bucket { private final int limit; private final long windowNanos; private long start; private int count;
FixedWindow(Limit limit, long now) { this.limit = limit.permits(); this.windowNanos = limit.per().toNanos(); this.start = now - Math.floorMod(now, windowNanos); }
// Moves to the current window once time has passed the edge, and starts the count again. private void roll(long now) { long current = now - Math.floorMod(now, windowNanos); if (current != start) { start = current; count = 0; } }
public boolean tryTake(long now) { roll(now); if (count >= limit) return false; count++; return true; }
public void refund(long now) { roll(now); if (count > 0) count--; // a refund after the window rolled has nothing to give back }
public long retryAfterNanos(long now) { roll(now); return count < limit ? 0 : start + windowNanos - now; }}
// Creates a bucket for a key seen for the first time. TokenBucket::new and FixedWindow::new// both fit, which is how a rule picks its algorithm.interface BucketFactory { Bucket create(Limit limit, long now);}
// One limit for every key, with the algorithm plugged in.class RateLimiter { private final Limit limit; private final BucketFactory algorithm; private final LongSupplier clock; private final Map<String, Bucket> buckets = new HashMap<>();
RateLimiter(Limit limit, BucketFactory algorithm, LongSupplier clock) { this.limit = limit; this.algorithm = algorithm; this.clock = clock; }
Decision check(String key) { long now = clock.getAsLong(); Bucket b = buckets.computeIfAbsent(key, k -> algorithm.create(limit, now)); return b.tryTake(now) ? new Decision(true, 0) : new Decision(false, b.retryAfterNanos(now)); }}import mathimport timefrom dataclasses import dataclassfrom typing import Callable, Protocol
# "permits per period", such as 10 per second. A value, so rules can share and compare it.@dataclass(frozen=True)class Limit: permits: int per_seconds: float
# What the caller needs to answer the request: yes or no, and when to try again.# retry_after becomes the Retry-After header on an HTTP 429.@dataclass(frozen=True)class Decision: allowed: bool retry_after: float = 0.0
# One client's allowance under one limit. A protocol, because token bucket and fixed# window (and sliding window, and leaky bucket) are interchangeable answers to# "may this key go now?".class Bucket(Protocol): def try_take(self, now: float) -> bool: ...
# Gives back a permit when a later rule refused the request. Never above capacity. def refund(self, now: float) -> None: ... # gives a permit back when a later rule says no
def retry_after(self, now: float) -> float: ... # 0 if a permit is available now
# Holds up to `permits` tokens and refills continuously at permits/period. Each request# takes one. A client may burst up to the capacity, but over any long stretch the rate# can't exceed the limit, and there is no window edge to game.class TokenBucket: def __init__(self, limit: Limit, now: float): self._capacity = float(limit.permits) self._rate = limit.permits / limit.per_seconds # tokens per second self._tokens = self._capacity # a new client starts with a full bucket self._updated_at = now
# Lazy refill: no timer per bucket, just catch up when the bucket is used. # A million idle buckets cost no CPU at all. def _refill(self, now: float) -> None: if now <= self._updated_at: return # the clock didn't move (or went backwards): nothing to add self._tokens = min(self._capacity, self._tokens + (now - self._updated_at) * self._rate) self._updated_at = now
def try_take(self, now: float) -> bool: self._refill(now) if self._tokens < 1: return False self._tokens -= 1 return True
def refund(self, now: float) -> None: self._refill(now) self._tokens = min(self._capacity, self._tokens + 1)
def retry_after(self, now: float) -> float: self._refill(now) return 0.0 if self._tokens >= 1 else (1 - self._tokens) / self._rate
# The fixed-window algorithm behind the same interface: count requests in each window of the# clock, and start again at each edge. Cheaper (two numbers), and fine for long periods like# "1,000 per day", where a burst at midnight is acceptable.class FixedWindow: def __init__(self, limit: Limit, now: float): self._limit = limit.permits self._window = limit.per_seconds self._start = now - now % self._window self._count = 0
# Moves to the current window once time has passed the edge, and starts the count again. def _roll(self, now: float) -> None: current = now - now % self._window if current != self._start: self._start, self._count = current, 0
def try_take(self, now: float) -> bool: self._roll(now) if self._count >= self._limit: return False self._count += 1 return True
def refund(self, now: float) -> None: self._roll(now) self._count = max(0, self._count - 1) # after a roll there is nothing to give back
def retry_after(self, now: float) -> float: self._roll(now) return 0.0 if self._count < self._limit else self._start + self._window - now
# Creates a bucket for a key seen for the first time. The TokenBucket and FixedWindow# classes both fit, which is how a rule picks its algorithm.BucketFactory = Callable[[Limit, float], Bucket]
# One limit for every key, with the algorithm plugged in.class RateLimiter: def __init__(self, limit: Limit, algorithm: BucketFactory, clock: Callable[[], float] = time.monotonic): self._limit = limit self._algorithm = algorithm self._clock = clock self._buckets: dict[str, Bucket] = {}
def check(self, key: str) -> Decision: now = self._clock() b = self._buckets.get(key) if b is None: b = self._buckets[key] = self._algorithm(self._limit, now) return Decision(True) if b.try_take(now) else Decision(False, b.retry_after(now))// "permits per period", such as 10 per second.public readonly record struct Limit(int Permits, TimeSpan Per);
// What the caller needs: yes or no, and when to try again.// RetryAfter becomes the Retry-After header on an HTTP 429.public readonly record struct Decision(bool Allowed, TimeSpan RetryAfter){ public static readonly Decision Allow = new(true, TimeSpan.Zero);}
// One client's allowance under one limit. An interface, because token bucket and fixed// window (and sliding window, and leaky bucket) are interchangeable answers to// "may this key go now?". Times are ticks from the TimeProvider.public interface IBucket{ bool TryTake(long now); void Refund(long now); // gives a permit back when a later rule says no TimeSpan RetryAfter(long now); // zero if a permit is available now}
// Holds up to Permits tokens and refills continuously at Permits/Per. Each request takes// one. A client may burst up to the capacity, but over any long stretch the rate can't// exceed the limit, and there is no window edge to game.public sealed class TokenBucket(Limit limit, long now) : IBucket{ private readonly double _capacity = limit.Permits; private readonly double _perTick = (double)limit.Permits / limit.Per.Ticks; // tokens per tick private double _tokens = limit.Permits; // a new client starts with a full bucket private long _updatedAt = now;
// Lazy refill: no timer per bucket, just catch up when the bucket is used. // A million idle buckets cost no CPU at all. private void Refill(long now) { if (now <= _updatedAt) return; // the clock didn't move (or went backwards) _tokens = Math.Min(_capacity, _tokens + (now - _updatedAt) * _perTick); _updatedAt = now; }
public bool TryTake(long now) { Refill(now); if (_tokens < 1) return false; _tokens--; return true; }
// Gives back a permit when a later rule refused the request. Never above capacity. public void Refund(long now) { Refill(now); _tokens = Math.Min(_capacity, _tokens + 1); }
public TimeSpan RetryAfter(long now) { Refill(now); return _tokens >= 1 ? TimeSpan.Zero : TimeSpan.FromTicks((long)Math.Ceiling((1 - _tokens) / _perTick)); }}
// The fixed-window algorithm behind the same interface: count requests in each window of the// clock, and start again at each edge. Cheaper (two numbers), and fine for long periods like// "1,000 per day", where a burst at midnight is acceptable.public sealed class FixedWindow(Limit limit, long now) : IBucket{ private readonly long _window = limit.Per.Ticks; private long _start = now - now % limit.Per.Ticks; private int _count;
// Moves to the current window once time has passed the edge, and starts the count again. private void Roll(long now) { long current = now - now % _window; if (current != _start) (_start, _count) = (current, 0); }
public bool TryTake(long now) { Roll(now); if (_count >= limit.Permits) return false; _count++; return true; }
public void Refund(long now) { Roll(now); if (_count > 0) _count--; // after a roll there is nothing to give back }
public TimeSpan RetryAfter(long now) { Roll(now); return _count < limit.Permits ? TimeSpan.Zero : TimeSpan.FromTicks(_start + _window - now); }}
// Creates a bucket for a key seen for the first time. Both constructors fit// ((l, now) => new TokenBucket(l, now)), which is how a rule picks its algorithm.public delegate IBucket BucketFactory(Limit limit, long now);
// One limit for every key, with the algorithm plugged in.public sealed class RateLimiter(Limit limit, BucketFactory algorithm, TimeProvider time){ private readonly Dictionary<string, IBucket> _buckets = new();
public Decision Check(string key) { long now = time.GetUtcNow().Ticks; if (!_buckets.TryGetValue(key, out var b)) _buckets[key] = b = algorithm(limit, now); return b.TryTake(now) ? Decision.Allow : new Decision(false, b.RetryAfter(now)); }}// Limit is "permits per period", such as 10 per second.type Limit struct { Permits int Per time.Duration}
// Decision is what the caller needs: yes or no, and when to try again.// RetryAfter becomes the Retry-After header on an HTTP 429.type Decision struct { Allowed bool RetryAfter time.Duration}
// Bucket is one client's allowance under one limit. An interface, because token bucket// and fixed window (and sliding window, and leaky bucket) are interchangeable answers to// "may this key go now?".type Bucket interface { TryTake(now time.Time) bool Refund(now time.Time) // gives a permit back when a later rule says no RetryAfter(now time.Time) time.Duration // 0 if a permit is available now}
// TokenBucket holds up to Permits tokens and refills continuously at Permits/Per. Each// request takes one. A client may burst up to the capacity, but over any long stretch the// rate can't exceed the limit, and there is no window edge to game.type TokenBucket struct { capacity float64 perToken time.Duration // time to refill one token tokens float64 updatedAt time.Time}
func NewTokenBucket(l Limit, now time.Time) Bucket { // A new client starts with a full bucket. return &TokenBucket{capacity: float64(l.Permits), perToken: l.Per / time.Duration(l.Permits), tokens: float64(l.Permits), updatedAt: now}}
// refill is lazy: no timer per bucket, just catch up when the bucket is used.// A million idle buckets cost no CPU at all.func (b *TokenBucket) refill(now time.Time) { if !now.After(b.updatedAt) { return // the clock didn't move (or went backwards): nothing to add } b.tokens = min(b.capacity, b.tokens+float64(now.Sub(b.updatedAt))/float64(b.perToken)) b.updatedAt = now}
func (b *TokenBucket) TryTake(now time.Time) bool { b.refill(now) if b.tokens < 1 { return false } b.tokens-- return true}
// Refund gives back a permit when a later rule refused the request. Never above capacity.func (b *TokenBucket) Refund(now time.Time) { b.refill(now) b.tokens = min(b.capacity, b.tokens+1)}
func (b *TokenBucket) RetryAfter(now time.Time) time.Duration { b.refill(now) if b.tokens >= 1 { return 0 } return time.Duration(math.Ceil((1 - b.tokens) * float64(b.perToken)))}
// FixedWindow is the fixed-window algorithm behind the same interface: count requests in each// window of the clock, and start again at each edge. Cheaper (two numbers), and fine for long// periods like "1,000 per day", where a burst at midnight is acceptable.type FixedWindow struct { limit int window time.Duration start time.Time count int}
func NewFixedWindow(l Limit, now time.Time) Bucket { return &FixedWindow{limit: l.Permits, window: l.Per, start: now.Truncate(l.Per)}}
// roll moves to the current window once time has passed the edge, and starts the count again.func (w *FixedWindow) roll(now time.Time) { if s := now.Truncate(w.window); !s.Equal(w.start) { w.start, w.count = s, 0 }}
func (w *FixedWindow) TryTake(now time.Time) bool { w.roll(now) if w.count >= w.limit { return false } w.count++ return true}
func (w *FixedWindow) Refund(now time.Time) { w.roll(now) if w.count > 0 { // after a roll there is nothing to give back w.count-- }}
func (w *FixedWindow) RetryAfter(now time.Time) time.Duration { w.roll(now) if w.count < w.limit { return 0 } return w.start.Add(w.window).Sub(now)}
// BucketFactory creates a bucket for a key seen for the first time. NewTokenBucket and// NewFixedWindow both fit, which is how a rule picks its algorithm.type BucketFactory func(l Limit, now time.Time) Bucket
// RateLimiter applies one limit to every key, with the algorithm plugged in.type RateLimiter struct { limit Limit algorithm BucketFactory clock func() time.Time buckets map[string]Bucket}
func NewRateLimiter(l Limit, algorithm BucketFactory, clock func() time.Time) *RateLimiter { return &RateLimiter{limit: l, algorithm: algorithm, clock: clock, buckets: map[string]Bucket{}}}
func (rl *RateLimiter) Check(key string) Decision { now := rl.clock() b, ok := rl.buckets[key] if !ok { b = rl.algorithm(rl.limit, now) rl.buckets[key] = b } if b.TryTake(now) { return Decision{Allowed: true} } return Decision{RetryAfter: b.RetryAfter(now)}}// "permits per period", such as 10 per second.interface Limit { readonly permits: number; readonly perMs: number;}
// What the caller needs: yes or no, and when to try again.// retryAfterMs becomes the Retry-After header on an HTTP 429.interface Decision { readonly allowed: boolean; readonly retryAfterMs: number;}
// One client's allowance under one limit. An interface, because token bucket and fixed// window (and sliding window, and leaky bucket) are interchangeable answers to// "may this key go now?".interface Bucket { tryTake(now: number): boolean; refund(now: number): void; // gives a permit back when a later rule says no retryAfterMs(now: number): number; // 0 if a permit is available now}
// Holds up to `permits` tokens and refills continuously at permits/period. Each request// takes one. A client may burst up to the capacity, but over any long stretch the rate// can't exceed the limit, and there is no window edge to game.class TokenBucket implements Bucket { private readonly capacity: number; private readonly perMsRate: number; // tokens per millisecond private tokens: number;
constructor(limit: Limit, private updatedAt: number) { this.capacity = limit.permits; this.perMsRate = limit.permits / limit.perMs; this.tokens = limit.permits; // a new client starts with a full bucket }
// Lazy refill: no timer per bucket, just catch up when the bucket is used. // A million idle buckets cost no CPU at all. private refill(now: number): void { if (now <= this.updatedAt) return; // the clock didn't move (or went backwards) this.tokens = Math.min(this.capacity, this.tokens + (now - this.updatedAt) * this.perMsRate); this.updatedAt = now; }
tryTake(now: number): boolean { this.refill(now); if (this.tokens < 1) return false; this.tokens--; return true; }
// Gives back a permit when a later rule refused the request. Never above capacity. refund(now: number): void { this.refill(now); this.tokens = Math.min(this.capacity, this.tokens + 1); }
retryAfterMs(now: number): number { this.refill(now); return this.tokens >= 1 ? 0 : Math.ceil((1 - this.tokens) / this.perMsRate); }}
// The fixed-window algorithm behind the same interface: count requests in each window of the// clock, and start again at each edge. Cheaper (two numbers), and fine for long periods like// "1,000 per day", where a burst at midnight is acceptable.class FixedWindow implements Bucket { private start: number; private count = 0;
constructor(private readonly limit: Limit, now: number) { this.start = now - (now % limit.perMs); }
// Moves to the current window once time has passed the edge, and starts the count again. private roll(now: number): void { const current = now - (now % this.limit.perMs); if (current !== this.start) { this.start = current; this.count = 0; } }
tryTake(now: number): boolean { this.roll(now); if (this.count >= this.limit.permits) return false; this.count++; return true; }
refund(now: number): void { this.roll(now); if (this.count > 0) this.count--; // after a roll there is nothing to give back }
retryAfterMs(now: number): number { this.roll(now); return this.count < this.limit.permits ? 0 : this.start + this.limit.perMs - now; }}
// Creates a bucket for a key seen for the first time. Both classes fit// (`(l, now) => new TokenBucket(l, now)`), which is how a rule picks its algorithm.type BucketFactory = (limit: Limit, now: number) => Bucket;const tokenBucket: BucketFactory = (l, now) => new TokenBucket(l, now);const fixedWindow: BucketFactory = (l, now) => new FixedWindow(l, now);
// One limit for every key, with the algorithm plugged in.class RateLimiter { private readonly buckets = new Map<string, Bucket>();
constructor( private readonly limit: Limit, private readonly algorithm: BucketFactory, private readonly clock: () => number = Date.now, ) {}
check(key: string): Decision { const now = this.clock(); let b = this.buckets.get(key); if (!b) { b = this.algorithm(this.limit, now); this.buckets.set(key, b); } return b.tryTake(now) ? { allowed: true, retryAfterMs: 0 } : { allowed: false, retryAfterMs: b.retryAfterMs(now) }; }}The retry time comes straight from the arithmetic. A bucket holding 0.3 tokens needs 0.7 more. At 10 tokens per second, that’s 70 ms. We return that time, so a polite client waits exactly long enough instead of retrying again and again.
What does the token bucket cost? After a quiet spell, it allows a burst of the full cap. A backend that can’t handle 10 requests at once won’t like that. And tokens are decimals, so the code must compare with “less than 1”, never “equals 0”, and cap the count on every refill.
Go deeperWhen would you pick the sliding window counter instead?1 min
When the limit must hold over every span of time with no burst above it, and memory per client
matters. It keeps this window’s count and the previous window’s count. Then it estimates the count
over the last full period as current + previous × (the part of the previous window still in range). For example, 30% into the current window, 70% of the previous window still counts.
It assumes the previous window’s requests were spread evenly, so it can be off by a little. But it never allows the 2× burst at the edge. Cloudflare reported it within a fraction of a percent of exact on real traffic.
Go deeperA token bucket, worked by hand1 min
Take a bucket of 10 tokens that refills at 10 per second, so one token every 100 ms.
- 12:00:00.000. The client is new, so the bucket is full: 10 tokens. It sends 10 requests at once. All pass, and the bucket is at 0.
- 12:00:00.050. An 11th request. 50 ms have passed, so 0.5 tokens dripped in. 0.5 is less than 1, so it’s refused. The retry time is (1 − 0.5) ÷ 10 per second = 50 ms.
- 12:00:00.100. It retries. Another 50 ms, another 0.5 tokens: exactly 1. It passes, and the bucket is back at 0.
- 12:00:05.100. After five quiet seconds, 50 tokens have dripped in. But the cap is 10, so the bucket holds 10, not 50. That cap is what stops a long quiet spell from turning into a huge burst.
Each step used only two stored numbers (tokens and the last time) and the current time. That’s all
tryTake needs.
Stage 3: Several rules per request
Section titled “Stage 3: Several rules per request”We need 10 per second per user, and also 100 per minute per IP, because one office shares an IP.
Whether you notice that a request refused by one rule must not use up another rule’s token, and whether buckets are kept apart per rule. A refusal that silently drains a good user’s bucket is the bug they’re looking for.
A Rule has a name, a way to get a key from a request (the user ID, or the IP address), a limit,
and an algorithm. The limiter holds a list of rules, and one bucket for each rule and key pair. A
request goes through the rules in order and takes a token from each bucket.
The order creates a problem. Say the user rule takes a token, and then the IP rule refuses. The user has lost a token for a request that never ran. When a shared office IP is busy, this drains the buckets of good users, again and again.
So the check is all or nothing. If any rule refuses, every token taken so far is given back (refunded). The answer carries the refusing bucket’s retry time.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// What a request looks like to the limiter: who sent it, from where, to which endpoint.record Request(String userId, String ip, String endpoint) {}
// One limit, how to key it, and which algorithm enforces it. For example// new Rule("user/second", Request::userId, Limit.of(10, Duration.ofSeconds(1)), TokenBucket::new)record Rule(String name, Function<Request, String> keyOf, Limit limit, BucketFactory algorithm) {}
class RateLimiter { private final List<Rule> rules; private final LongSupplier clock; // One bucket per (rule, key). The rule name is in the key, so "ip:1.2.3.4" under two // rules is two separate buckets. private final Map<String, Bucket> buckets = new HashMap<>();
RateLimiter(List<Rule> rules, LongSupplier clock) { this.rules = List.copyOf(rules); this.clock = clock; }
// A request must pass every rule. All or nothing: if a later rule refuses, the permits // taken from earlier rules are given back, so a refused request uses up nothing. Decision check(Request r) { long now = clock.getAsLong(); List<Bucket> taken = new ArrayList<>(); for (Rule rule : rules) { String key = rule.name() + "|" + rule.keyOf().apply(r); Bucket b = buckets.computeIfAbsent(key, k -> rule.algorithm().create(rule.limit(), now)); if (!b.tryTake(now)) { for (Bucket t : taken) t.refund(now); return new Decision(false, b.retryAfterNanos(now)); } taken.add(b); } return new Decision(true, 0); }}# What a request looks like to the limiter: who sent it, from where, to which endpoint.@dataclass(frozen=True)class Request: user_id: str ip: str endpoint: str
# One limit, how to key it, and which algorithm enforces it. For example# Rule("user/second", lambda r: r.user_id, Limit(10, 1.0), TokenBucket)@dataclass(frozen=True)class Rule: name: str key_of: Callable[[Request], str] limit: Limit algorithm: BucketFactory
class RateLimiter: def __init__(self, rules: list[Rule], clock: Callable[[], float] = time.monotonic): self._rules = tuple(rules) self._clock = clock # One bucket per (rule, key), so "ip 1.2.3.4" under two rules is two buckets. self._buckets: dict[tuple[str, str], Bucket] = {}
# A request must pass every rule. All or nothing: if a later rule refuses, the permits # taken from earlier rules are given back, so a refused request uses up nothing. def check(self, r: Request) -> Decision: now = self._clock() taken: list[Bucket] = [] for rule in self._rules: key = (rule.name, rule.key_of(r)) b = self._buckets.get(key) if b is None: b = self._buckets[key] = rule.algorithm(rule.limit, now) if not b.try_take(now): for t in taken: t.refund(now) return Decision(False, b.retry_after(now)) taken.append(b) return Decision(True)// What a request looks like to the limiter.public sealed record Request(string UserId, string Ip, string Endpoint);
// One limit, how to key it, and which algorithm enforces it. For example// new Rule("user/second", r => r.UserId, new Limit(10, TimeSpan.FromSeconds(1)), (l, t) => new TokenBucket(l, t))public sealed record Rule(string Name, Func<Request, string> KeyOf, Limit Limit, BucketFactory Algorithm);
public sealed class RateLimiter(IReadOnlyList<Rule> rules, TimeProvider time){ // Keyed by (rule, key): "ip 1.2.3.4" under two rules is two buckets. private readonly Dictionary<(string Rule, string Key), IBucket> _buckets = new();
// A request must pass every rule. All or nothing: if a later rule refuses, the permits // taken from earlier rules are given back, so a refused request uses up nothing. public Decision Check(Request r) { long now = time.GetUtcNow().Ticks; var taken = new List<IBucket>(rules.Count); foreach (var rule in rules) { var key = (rule.Name, rule.KeyOf(r)); if (!_buckets.TryGetValue(key, out var b)) _buckets[key] = b = rule.Algorithm(rule.Limit, now); if (!b.TryTake(now)) { foreach (var t in taken) t.Refund(now); return new Decision(false, b.RetryAfter(now)); } taken.Add(b); } return Decision.Allow; }}// Request is what a request looks like to the limiter.type Request struct { UserID, IP, Endpoint string}
// Rule is one limit, how to key it, and which algorithm enforces it. For example// Rule{"user/second", func(r Request) string { return r.UserID }, Limit{10, time.Second}, NewTokenBucket}type Rule struct { Name string KeyOf func(Request) string Limit Limit Algorithm BucketFactory}
type RateLimiter struct { rules []Rule clock func() time.Time buckets map[string]Bucket // keyed by rule name + key: "ip 1.2.3.4" under two rules is two buckets}
func NewRateLimiter(rules []Rule, clock func() time.Time) *RateLimiter { return &RateLimiter{rules: slices.Clone(rules), clock: clock, buckets: map[string]Bucket{}}}
// Check makes a request pass every rule. All or nothing: if a later rule refuses, the// permits taken from earlier rules are given back, so a refused request uses up nothing.func (rl *RateLimiter) Check(r Request) Decision { now := rl.clock() var taken []Bucket for _, rule := range rl.rules { key := rule.Name + "|" + rule.KeyOf(r) b, ok := rl.buckets[key] if !ok { b = rule.Algorithm(rule.Limit, now) rl.buckets[key] = b } if !b.TryTake(now) { for _, t := range taken { t.Refund(now) } return Decision{RetryAfter: b.RetryAfter(now)} } taken = append(taken, b) } return Decision{Allowed: true}}// What a request looks like to the limiter.interface Request { readonly userId: string; readonly ip: string; readonly endpoint: string;}
// One limit, how to key it, and which algorithm enforces it. For example// { name: 'user/second', keyOf: (r) => r.userId, limit: { permits: 10, perMs: 1000 }, algorithm: tokenBucket }interface Rule { readonly name: string; readonly keyOf: (r: Request) => string; readonly limit: Limit; readonly algorithm: BucketFactory;}
class RateLimiter { // Keyed by rule name + key: "ip 1.2.3.4" under two rules is two buckets. private readonly buckets = new Map<string, Bucket>();
constructor( private readonly rules: readonly Rule[], private readonly clock: () => number = Date.now, ) {}
// A request must pass every rule. All or nothing: if a later rule refuses, the permits // taken from earlier rules are given back, so a refused request uses up nothing. check(r: Request): Decision { const now = this.clock(); const taken: Bucket[] = []; for (const rule of this.rules) { const key = `${rule.name}|${rule.keyOf(r)}`; let b = this.buckets.get(key); if (!b) { b = rule.algorithm(rule.limit, now); this.buckets.set(key, b); } if (!b.tryTake(now)) { for (const t of taken) t.refund(now); return { allowed: false, retryAfterMs: b.retryAfterMs(now) }; } taken.push(b); } return { allowed: true, retryAfterMs: 0 }; }}Here it is step by step. User bo sends a request from IP 10.0.0.1. The user bucket gives a token,
and the IP bucket refuses. So the limiter refunds the user bucket, which goes back to 5 tokens, and
answers “refused, retry after 600 ms”.
- Color key
- Facade
Why take and then refund, and not check every bucket first and then take? Because “check then take” needs every bucket locked at once, or another request could take the token in between. Take then refund touches one bucket at a time. The cost is a tiny moment when a token is out of a bucket for a request that will be refused. Another request for the same user, in that same microsecond, might be refused too.
Go deeperDoes the order of the rules matter?1 min
For correctness, no: refunds make it all or nothing whatever the order. For speed, yes. Put the rule most likely to refuse first, often the strictest per-IP rule. Then a refused request touches one bucket instead of several, and has nothing to refund.
Stage 4: Forgetting idle clients
Section titled “Stage 4: Forgetting idle clients”A crawler hits us from a million different IPs, once each. What happens to memory?
Whether you can find a rule for deleting state that never changes a decision. “Keep the last N clients” works but can forget an active client and give it a fresh burst.
Every first request creates a bucket, and nothing ever removes one. A million one-off IPs leave a million buckets behind.
Here is the key idea: a bucket left alone for one full period is the same as a new bucket. A token bucket idle for one period has refilled completely. A fixed window has moved on to a new window. So if we delete it, and create a fresh one on the next request, no decision changes.
Each bucket now records when it was last used. A sweep removes buckets that have been idle for at least their rule’s period. It runs once every 10,000 checks, so its cost is spread across many requests.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// A bucket plus when its key was last seen. A bucket left alone for one full period of its// limit is back where a new one starts (a token bucket refills completely; a fixed window// rolls over), so dropping it changes nothing.final class Tracked { final Bucket bucket; final long idleAfterNanos; long lastSeen;
Tracked(Bucket bucket, long idleAfterNanos, long now) { this.bucket = bucket; this.idleAfterNanos = idleAfterNanos; this.lastSeen = now; }}
class RateLimiter { private static final int SWEEP_EVERY = 10_000; // checks between sweeps; amortizes the scan private final List<Rule> rules; private final LongSupplier clock; private final Map<String, Tracked> buckets = new HashMap<>(); private int checksSinceSweep;
RateLimiter(List<Rule> rules, LongSupplier clock) { this.rules = List.copyOf(rules); this.clock = clock; }
Decision check(Request r) { long now = clock.getAsLong(); if (++checksSinceSweep >= SWEEP_EVERY) sweep(now); List<Bucket> taken = new ArrayList<>(); for (Rule rule : rules) { Tracked t = buckets.computeIfAbsent(rule.name() + "|" + rule.keyOf().apply(r), k -> new Tracked(rule.algorithm().create(rule.limit(), now), rule.limit().per().toNanos(), now)); t.lastSeen = now; if (!t.bucket.tryTake(now)) { for (Bucket b : taken) b.refund(now); return new Decision(false, t.bucket.retryAfterNanos(now)); } taken.add(t.bucket); } return new Decision(true, 0); }
// Forget every key idle for a full period. So memory tracks the clients active in the // last period, not every client ever seen: a scan of one IP per request no longer grows // the map forever. void sweep(long now) { buckets.values().removeIf(t -> now - t.lastSeen >= t.idleAfterNanos); checksSinceSweep = 0; }
int trackedKeys() { return buckets.size(); }}# A bucket plus when its key was last seen. A bucket left alone for one full period of its# limit is back where a new one starts (a token bucket refills completely; a fixed window# rolls over), so dropping it changes nothing.class Tracked: __slots__ = ("bucket", "idle_after", "last_seen")
def __init__(self, bucket: Bucket, idle_after: float, now: float): self.bucket = bucket self.idle_after = idle_after self.last_seen = now
class RateLimiter: SWEEP_EVERY = 10_000 # checks between sweeps; amortizes the scan
def __init__(self, rules: list[Rule], clock: Callable[[], float] = time.monotonic): self._rules = tuple(rules) self._clock = clock self._buckets: dict[tuple[str, str], Tracked] = {} self._checks_since_sweep = 0
def check(self, r: Request) -> Decision: now = self._clock() self._checks_since_sweep += 1 if self._checks_since_sweep >= self.SWEEP_EVERY: self.sweep(now) taken: list[Bucket] = [] for rule in self._rules: key = (rule.name, rule.key_of(r)) t = self._buckets.get(key) if t is None: t = self._buckets[key] = Tracked(rule.algorithm(rule.limit, now), rule.limit.per_seconds, now) t.last_seen = now if not t.bucket.try_take(now): for b in taken: b.refund(now) return Decision(False, t.bucket.retry_after(now)) taken.append(t.bucket) return Decision(True)
# Forget every key idle for a full period. So memory tracks the clients active in the # last period, not every client ever seen: a scan of one IP per request no longer grows # the dict forever. def sweep(self, now: float) -> None: idle = [k for k, t in self._buckets.items() if now - t.last_seen >= t.idle_after] for k in idle: del self._buckets[k] self._checks_since_sweep = 0
def tracked_keys(self) -> int: return len(self._buckets)// A bucket plus when its key was last seen. A bucket left alone for one full period of its// limit is back where a new one starts (a token bucket refills completely; a fixed window// rolls over), so dropping it changes nothing.internal sealed class Tracked(IBucket bucket, long idleAfter, long now){ public IBucket Bucket { get; } = bucket; public long IdleAfter { get; } = idleAfter; public long LastSeen { get; set; } = now;}
public sealed class RateLimiter(IReadOnlyList<Rule> rules, TimeProvider time){ private const int SweepEvery = 10_000; // checks between sweeps; amortizes the scan private readonly Dictionary<(string Rule, string Key), Tracked> _buckets = new(); private int _checksSinceSweep;
public Decision Check(Request r) { long now = time.GetUtcNow().Ticks; if (++_checksSinceSweep >= SweepEvery) Sweep(now); var taken = new List<IBucket>(rules.Count); foreach (var rule in rules) { var key = (rule.Name, rule.KeyOf(r)); if (!_buckets.TryGetValue(key, out var t)) _buckets[key] = t = new Tracked(rule.Algorithm(rule.Limit, now), rule.Limit.Per.Ticks, now); t.LastSeen = now; if (!t.Bucket.TryTake(now)) { foreach (var b in taken) b.Refund(now); return new Decision(false, t.Bucket.RetryAfter(now)); } taken.Add(t.Bucket); } return Decision.Allow; }
// Forget every key idle for a full period. So memory tracks the clients active in the // last period, not every client ever seen: a scan of one IP per request no longer grows // the dictionary forever. Removing while enumerating is allowed since .NET Core 3.0. public void Sweep(long now) { foreach (var (key, t) in _buckets) if (now - t.LastSeen >= t.IdleAfter) _buckets.Remove(key); _checksSinceSweep = 0; }
public int TrackedKeys => _buckets.Count;}// tracked is a bucket plus when its key was last seen. A bucket left alone for one full// period of its limit is back where a new one starts (a token bucket refills completely;// a fixed window rolls over), so dropping it changes nothing.type tracked struct { bucket Bucket idleAfter time.Duration lastSeen time.Time}
const sweepEvery = 10_000 // checks between sweeps; amortizes the scan
type RateLimiter struct { rules []Rule clock func() time.Time buckets map[string]*tracked checksSinceSweep int}
func NewRateLimiter(rules []Rule, clock func() time.Time) *RateLimiter { return &RateLimiter{rules: slices.Clone(rules), clock: clock, buckets: map[string]*tracked{}}}
func (rl *RateLimiter) Check(r Request) Decision { now := rl.clock() if rl.checksSinceSweep++; rl.checksSinceSweep >= sweepEvery { rl.Sweep(now) } var taken []Bucket for _, rule := range rl.rules { key := rule.Name + "|" + rule.KeyOf(r) t, ok := rl.buckets[key] if !ok { t = &tracked{bucket: rule.Algorithm(rule.Limit, now), idleAfter: rule.Limit.Per} rl.buckets[key] = t } t.lastSeen = now if !t.bucket.TryTake(now) { for _, b := range taken { b.Refund(now) } return Decision{RetryAfter: t.bucket.RetryAfter(now)} } taken = append(taken, t.bucket) } return Decision{Allowed: true}}
// Sweep forgets every key idle for a full period. So memory tracks the clients active in// the last period, not every client ever seen: a scan of one IP per request no longer// grows the map forever.func (rl *RateLimiter) Sweep(now time.Time) { for key, t := range rl.buckets { if now.Sub(t.lastSeen) >= t.idleAfter { delete(rl.buckets, key) // deleting while ranging is safe in Go } } rl.checksSinceSweep = 0}
func (rl *RateLimiter) TrackedKeys() int { return len(rl.buckets) }// A bucket plus when its key was last seen. A bucket left alone for one full period of its// limit is back where a new one starts (a token bucket refills completely; a fixed window// rolls over), so dropping it changes nothing.interface Tracked { readonly bucket: Bucket; readonly idleAfterMs: number; lastSeen: number;}
const SWEEP_EVERY = 10_000; // checks between sweeps; amortizes the scan
// Every rule must agree before a request goes through. Each rule has its own bucket per client,// kept in one map under a key like "rule|client".class RateLimiter { private readonly buckets = new Map<string, Tracked>(); private checksSinceSweep = 0;
constructor( private readonly rules: readonly Rule[], private readonly clock: () => number = Date.now, ) {}
// Each rule takes a permit from this client's bucket. On the first no, the permits taken by // earlier rules are given back, so a refused request costs the client nothing, and the answer // says when to retry. check(r: Request): Decision { const now = this.clock(); if (++this.checksSinceSweep >= SWEEP_EVERY) this.sweep(now); const taken: Bucket[] = []; for (const rule of this.rules) { const key = `${rule.name}|${rule.keyOf(r)}`; let t = this.buckets.get(key); if (!t) { t = { bucket: rule.algorithm(rule.limit, now), idleAfterMs: rule.limit.perMs, lastSeen: now }; this.buckets.set(key, t); } t.lastSeen = now; if (!t.bucket.tryTake(now)) { for (const b of taken) b.refund(now); return { allowed: false, retryAfterMs: t.bucket.retryAfterMs(now) }; } taken.push(t.bucket); } return { allowed: true, retryAfterMs: 0 }; }
// Forget every key idle for a full period. So memory tracks the clients active in the // last period, not every client ever seen: a scan of one IP per request no longer grows // the map forever. Deleting while iterating a Map is safe in JavaScript. sweep(now: number): void { for (const [key, t] of this.buckets) { if (now - t.lastSeen >= t.idleAfterMs) this.buckets.delete(key); } this.checksSinceSweep = 0; }
// How many buckets are in memory. Tests use it to watch the sweep work. trackedKeys(): number { return this.buckets.size; }}Memory now follows the clients that were active in the last period. Picture a crawler sending a million requests a minute, each from a new IP, under a one-second rule. It keeps a few tens of thousands of buckets alive (about one second of new IPs, plus those since the last sweep), not a million.
What does it cost? A sweep looks at every bucket, so it’s a short pause that grows with the number of clients. And during a flood of new clients within one period, memory still has no hard cap. A map with a fixed size limit would cap memory, but it would sometimes forget an active client, and hand it a fresh burst.
This is enough for a mid-level answer. The token bucket is correct, refills lazily, and sits behind an interface. The rest of the page is what makes it a senior answer.
The full design
Section titled “The full design”Here is everything together. Each class is here because a requirement needed it.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// The complete design, built from every stage on this page.
import java.util.*;import java.util.concurrent.*;import java.util.concurrent.atomic.*;import java.util.function.*;import java.time.*;
// "permits per period", such as 10 per second. A value, so rules can share and compare it.record Limit(int permits, Duration per) { static Limit of(int permits, Duration per) { return new Limit(permits, per); }}
// What the caller needs to answer the request: yes or no, and when to try again.// retryAfterNanos becomes the Retry-After header on an HTTP 429.record Decision(boolean allowed, long retryAfterNanos) {}
// One client's allowance under one limit. An interface, because token bucket and fixed// window (and sliding window, and leaky bucket) are interchangeable answers to// "may this key go now?".interface Bucket { boolean tryTake(long now); void refund(long now); // gives a permit back when a later rule says no long retryAfterNanos(long now); // 0 if a permit is available now}
// Holds up to `permits` tokens and refills continuously at permits/period. Each request// takes one. A client may burst up to the capacity, but over any long stretch the rate// can't exceed the limit, and there is no window edge to game.class TokenBucket implements Bucket { private final double capacity; private final double refillPerNano; private double tokens; private long updatedAt; // when `tokens` was last brought up to date
TokenBucket(Limit limit, long now) { this.capacity = limit.permits(); this.refillPerNano = limit.permits() / (double) limit.per().toNanos(); this.tokens = capacity; // a new client starts with a full bucket this.updatedAt = now; }
// Lazy refill: no timer per bucket, just catch up when the bucket is used. // A million idle buckets cost no CPU at all. private void refill(long now) { if (now <= updatedAt) return; // the clock didn't move (or went backwards): nothing to add tokens = Math.min(capacity, tokens + (now - updatedAt) * refillPerNano); updatedAt = now; }
public boolean tryTake(long now) { refill(now); if (tokens < 1) return false; tokens -= 1; return true; }
// Gives back a permit when a later rule refused the request. Never above capacity. public void refund(long now) { refill(now); tokens = Math.min(capacity, tokens + 1); }
public long retryAfterNanos(long now) { refill(now); return tokens >= 1 ? 0 : (long) Math.ceil((1 - tokens) / refillPerNano); }}
// The fixed-window algorithm behind the same interface: count requests in each window of the// clock, and start again at each edge. Cheaper (two numbers, no floating point), and fine for// long periods like "1,000 per day", where a burst at midnight is acceptable.class FixedWindow implements Bucket { private final int limit; private final long windowNanos; private long start; private int count;
FixedWindow(Limit limit, long now) { this.limit = limit.permits(); this.windowNanos = limit.per().toNanos(); this.start = now - Math.floorMod(now, windowNanos); }
// Moves to the current window once time has passed the edge, and starts the count again. private void roll(long now) { long current = now - Math.floorMod(now, windowNanos); if (current != start) { start = current; count = 0; } }
public boolean tryTake(long now) { roll(now); if (count >= limit) return false; count++; return true; }
public void refund(long now) { roll(now); if (count > 0) count--; // a refund after the window rolled has nothing to give back }
public long retryAfterNanos(long now) { roll(now); return count < limit ? 0 : start + windowNanos - now; }}
// Creates a bucket for a key seen for the first time. TokenBucket::new and FixedWindow::new// both fit, which is how a rule picks its algorithm.interface BucketFactory { Bucket create(Limit limit, long now);}
// Every rule must agree before a request goes through. Each rule has its own bucket per client,// kept in one map under a key like "rule|client". Safe to call from many threads.class RateLimiter { // The sweep runs once every 10,000 checks, so its cost is spread thin. private static final int SWEEP_EVERY = 10_000; private final List<Rule> rules; private final LongSupplier clock; // Safe to read and insert from many threads; computeIfAbsent creates each bucket once, // so two first requests from a new client can't each get their own full bucket. private final ConcurrentHashMap<String, Tracked> buckets = new ConcurrentHashMap<>(); private final AtomicInteger checksSinceSweep = new AtomicInteger();
RateLimiter(List<Rule> rules, LongSupplier clock) { this.rules = List.copyOf(rules); this.clock = clock; }
// Each rule takes a permit from this client's bucket. On the first no, the permits taken by // earlier rules are given back, so a refused request costs the client nothing, and the // answer says when to retry. Decision check(Request r) { long now = clock.getAsLong(); if (checksSinceSweep.incrementAndGet() >= SWEEP_EVERY) sweep(now); List<Tracked> taken = new ArrayList<>(); for (Rule rule : rules) { Tracked t = buckets.computeIfAbsent(rule.name() + "|" + rule.keyOf().apply(r), k -> new Tracked(rule.algorithm().create(rule.limit(), now), rule.limit().per().toNanos(), now)); boolean ok; // One lock per bucket: refill, check and take happen as one step, and two clients // never wait for each other. We hold at most one bucket lock at a time, so the // rules can't deadlock however they overlap. synchronized (t) { t.lastSeen = now; ok = t.bucket.tryTake(now); } if (!ok) { for (Tracked p : taken) synchronized (p) { p.bucket.refund(now); } // same lock as the take synchronized (t) { return new Decision(false, t.bucket.retryAfterNanos(now)); } } taken.add(t); } return new Decision(true, 0); }
// Removal re-checks idleness under the bucket's lock, so a key that was just used // survives. A request that fetched the entry a moment before it was removed can still // take from the orphaned bucket: that client gets at most one extra permit, once. void sweep(long now) { checksSinceSweep.set(0); buckets.forEach((key, t) -> { synchronized (t) { if (now - t.lastSeen >= t.idleAfterNanos) buckets.remove(key, t); } }); }
// How many buckets are in memory. Tests use it to watch the sweep work. int trackedKeys() { return buckets.size(); }}
// What a request looks like to the limiter: who sent it, from where, to which endpoint.record Request(String userId, String ip, String endpoint) {}
// One limit, how to key it, and which algorithm enforces it. For example// new Rule("user/second", Request::userId, Limit.of(10, Duration.ofSeconds(1)), TokenBucket::new)record Rule(String name, Function<Request, String> keyOf, Limit limit, BucketFactory algorithm) {}
// A bucket plus when its key was last seen. A bucket left alone for one full period of its// limit is back where a new one starts (a token bucket refills completely; a fixed window// rolls over), so dropping it changes nothing.final class Tracked { final Bucket bucket; final long idleAfterNanos; long lastSeen;
Tracked(Bucket bucket, long idleAfterNanos, long now) { this.bucket = bucket; this.idleAfterNanos = idleAfterNanos; this.lastSeen = now; }}# The complete design, built from every stage on this page.
from __future__ import annotations
import threadingimport timefrom dataclasses import dataclassfrom typing import Callable, Protocol
# "permits per period", such as 10 per second. A value, so rules can share and compare it.@dataclass(frozen=True)class Limit: permits: int per_seconds: float
# What the caller needs to answer the request: yes or no, and when to try again.# retry_after becomes the Retry-After header on an HTTP 429.@dataclass(frozen=True)class Decision: allowed: bool retry_after: float = 0.0
# One client's allowance under one limit. A protocol, because token bucket and fixed# window (and sliding window, and leaky bucket) are interchangeable answers to# "may this key go now?".class Bucket(Protocol): def try_take(self, now: float) -> bool: ...
# Gives back a permit when a later rule refused the request. Never above capacity. def refund(self, now: float) -> None: ... # gives a permit back when a later rule says no
def retry_after(self, now: float) -> float: ... # 0 if a permit is available now
# Holds up to `permits` tokens and refills continuously at permits/period. Each request# takes one. A client may burst up to the capacity, but over any long stretch the rate# can't exceed the limit, and there is no window edge to game.class TokenBucket: def __init__(self, limit: Limit, now: float): self._capacity = float(limit.permits) self._rate = limit.permits / limit.per_seconds # tokens per second self._tokens = self._capacity # a new client starts with a full bucket self._updated_at = now
# Lazy refill: no timer per bucket, just catch up when the bucket is used. # A million idle buckets cost no CPU at all. def _refill(self, now: float) -> None: if now <= self._updated_at: return # the clock didn't move (or went backwards): nothing to add self._tokens = min(self._capacity, self._tokens + (now - self._updated_at) * self._rate) self._updated_at = now
def try_take(self, now: float) -> bool: self._refill(now) if self._tokens < 1: return False self._tokens -= 1 return True
def refund(self, now: float) -> None: self._refill(now) self._tokens = min(self._capacity, self._tokens + 1)
def retry_after(self, now: float) -> float: self._refill(now) return 0.0 if self._tokens >= 1 else (1 - self._tokens) / self._rate
# The fixed-window algorithm behind the same interface: count requests in each window of the# clock, and start again at each edge. Cheaper (two numbers), and fine for long periods like# "1,000 per day", where a burst at midnight is acceptable.class FixedWindow: def __init__(self, limit: Limit, now: float): self._limit = limit.permits self._window = limit.per_seconds self._start = now - now % self._window self._count = 0
# Moves to the current window once time has passed the edge, and starts the count again. def _roll(self, now: float) -> None: current = now - now % self._window if current != self._start: self._start, self._count = current, 0
def try_take(self, now: float) -> bool: self._roll(now) if self._count >= self._limit: return False self._count += 1 return True
def refund(self, now: float) -> None: self._roll(now) self._count = max(0, self._count - 1) # after a roll there is nothing to give back
def retry_after(self, now: float) -> float: self._roll(now) return 0.0 if self._count < self._limit else self._start + self._window - now
# Creates a bucket for a key seen for the first time. The TokenBucket and FixedWindow# classes both fit, which is how a rule picks its algorithm.BucketFactory = Callable[[Limit, float], Bucket]
# Every rule must agree before a request goes through. Each rule has its own bucket per client,# kept in one map under a key like "rule|client". Safe to call from many threads.class RateLimiter: SWEEP_EVERY = 10_000
def __init__(self, rules: list[Rule], clock: Callable[[], float] = time.monotonic): self._rules = tuple(rules) self._clock = clock self._buckets: dict[tuple[str, str], Tracked] = {} self._map_lock = threading.Lock() # guards the dict itself: lookups, inserts, sweeps self._checks_since_sweep = 0
# Look up or create in one step, so two first requests from a new client can't each # get their own full bucket. def _tracked(self, rule: Rule, key: tuple[str, str], now: float) -> Tracked: with self._map_lock: t = self._buckets.get(key) if t is None: t = self._buckets[key] = Tracked(rule.algorithm(rule.limit, now), rule.limit.per_seconds, now) return t
# Each rule takes a permit from this client's bucket. On the first no, the permits taken by # earlier rules are given back, so a refused request costs the client nothing, and the # answer says when to retry. def check(self, r: Request) -> Decision: now = self._clock() with self._map_lock: self._checks_since_sweep += 1 sweep_due = self._checks_since_sweep >= self.SWEEP_EVERY if sweep_due: self.sweep(now) taken: list[Tracked] = [] for rule in self._rules: t = self._tracked(rule, (rule.name, rule.key_of(r)), now) # At most one bucket lock is held at a time, so overlapping rules can't deadlock. with t.lock: t.last_seen = now ok = t.bucket.try_take(now) if not ok: for p in taken: with p.lock: # the same lock as the take p.bucket.refund(now) with t.lock: return Decision(False, t.bucket.retry_after(now)) taken.append(t) return Decision(True)
# Holding the dict lock while checking each bucket under its own lock means a key that # was just used survives. A request that fetched its entry a moment before removal can # still take from the orphaned bucket: that client gets at most one extra permit, once. def sweep(self, now: float) -> None: with self._map_lock: for key, t in list(self._buckets.items()): with t.lock: if now - t.last_seen >= t.idle_after: del self._buckets[key] self._checks_since_sweep = 0
# How many buckets are in memory. Tests use it to watch the sweep work. def tracked_keys(self) -> int: with self._map_lock: return len(self._buckets)
# What a request looks like to the limiter: who sent it, from where, to which endpoint.@dataclass(frozen=True)class Request: user_id: str ip: str endpoint: str
# One limit, how to key it, and which algorithm enforces it. For example# Rule("user/second", lambda r: r.user_id, Limit(10, 1.0), TokenBucket)@dataclass(frozen=True)class Rule: name: str key_of: Callable[[Request], str] limit: Limit algorithm: BucketFactory
# Each tracked bucket gets its own lock: refill, check and take happen as one step, and two# clients never wait for each other.class Tracked: __slots__ = ("bucket", "idle_after", "last_seen", "lock")
def __init__(self, bucket: Bucket, idle_after: float, now: float): self.bucket = bucket self.idle_after = idle_after self.last_seen = now self.lock = threading.Lock()// The complete design, built from every stage on this page.
using System.Collections.Concurrent;
// "permits per period", such as 10 per second.public readonly record struct Limit(int Permits, TimeSpan Per);
// What the caller needs: yes or no, and when to try again.// RetryAfter becomes the Retry-After header on an HTTP 429.public readonly record struct Decision(bool Allowed, TimeSpan RetryAfter){ public static readonly Decision Allow = new(true, TimeSpan.Zero);}
// One client's allowance under one limit. An interface, because token bucket and fixed// window (and sliding window, and leaky bucket) are interchangeable answers to// "may this key go now?". Times are ticks from the TimeProvider.public interface IBucket{ bool TryTake(long now); void Refund(long now); // gives a permit back when a later rule says no TimeSpan RetryAfter(long now); // zero if a permit is available now}
// Holds up to Permits tokens and refills continuously at Permits/Per. Each request takes// one. A client may burst up to the capacity, but over any long stretch the rate can't// exceed the limit, and there is no window edge to game.public sealed class TokenBucket(Limit limit, long now) : IBucket{ private readonly double _capacity = limit.Permits; private readonly double _perTick = (double)limit.Permits / limit.Per.Ticks; // tokens per tick private double _tokens = limit.Permits; // a new client starts with a full bucket private long _updatedAt = now;
// Lazy refill: no timer per bucket, just catch up when the bucket is used. // A million idle buckets cost no CPU at all. private void Refill(long now) { if (now <= _updatedAt) return; // the clock didn't move (or went backwards) _tokens = Math.Min(_capacity, _tokens + (now - _updatedAt) * _perTick); _updatedAt = now; }
public bool TryTake(long now) { Refill(now); if (_tokens < 1) return false; _tokens--; return true; }
// Gives back a permit when a later rule refused the request. Never above capacity. public void Refund(long now) { Refill(now); _tokens = Math.Min(_capacity, _tokens + 1); }
public TimeSpan RetryAfter(long now) { Refill(now); return _tokens >= 1 ? TimeSpan.Zero : TimeSpan.FromTicks((long)Math.Ceiling((1 - _tokens) / _perTick)); }}
// The fixed-window algorithm behind the same interface: count requests in each window of the// clock, and start again at each edge. Cheaper (two numbers), and fine for long periods like// "1,000 per day", where a burst at midnight is acceptable.public sealed class FixedWindow(Limit limit, long now) : IBucket{ private readonly long _window = limit.Per.Ticks; private long _start = now - now % limit.Per.Ticks; private int _count;
// Moves to the current window once time has passed the edge, and starts the count again. private void Roll(long now) { long current = now - now % _window; if (current != _start) (_start, _count) = (current, 0); }
public bool TryTake(long now) { Roll(now); if (_count >= limit.Permits) return false; _count++; return true; }
public void Refund(long now) { Roll(now); if (_count > 0) _count--; // after a roll there is nothing to give back }
public TimeSpan RetryAfter(long now) { Roll(now); return _count < limit.Permits ? TimeSpan.Zero : TimeSpan.FromTicks(_start + _window - now); }}
// Creates a bucket for a key seen for the first time. Both constructors fit// ((l, now) => new TokenBucket(l, now)), which is how a rule picks its algorithm.public delegate IBucket BucketFactory(Limit limit, long now);
// Every rule must agree before a request goes through. Each rule has its own bucket per client,// kept in one map under a key like "rule|client". Safe to call from many threads.public sealed class RateLimiter(IReadOnlyList<Rule> rules, TimeProvider time){ private const int SweepEvery = 10_000; // GetOrAdd makes lookup-or-create one step, so two first requests from a new client // can't each get their own full bucket. (The factory may run twice in a race, but only // one result is stored and returned to both.) private readonly ConcurrentDictionary<(string Rule, string Key), Tracked> _buckets = new(); private int _checksSinceSweep;
// Each rule takes a permit from this client's bucket. On the first no, the permits taken by // earlier rules are given back, so a refused request costs the client nothing, and the // answer says when to retry. public Decision Check(Request r) { long now = time.GetUtcNow().Ticks; if (Interlocked.Increment(ref _checksSinceSweep) >= SweepEvery) Sweep(now); var taken = new List<Tracked>(rules.Count); foreach (var rule in rules) { var t = _buckets.GetOrAdd((rule.Name, rule.KeyOf(r)), static (_, a) => new Tracked(a.rule.Algorithm(a.rule.Limit, a.now), a.rule.Limit.Per.Ticks, a.now), (rule, now)); // At most one bucket lock is held at a time, so overlapping rules can't deadlock. lock (t.Gate) { t.LastSeen = now; if (t.Bucket.TryTake(now)) { taken.Add(t); continue; } } foreach (var p in taken) lock (p.Gate) p.Bucket.Refund(now); // the same lock as the take lock (t.Gate) return new Decision(false, t.Bucket.RetryAfter(now)); } return Decision.Allow; }
// Each entry is re-checked under its own lock, and removed only if it is still the // same entry (TryRemove with the pair), so a key that was just used survives. A request // that fetched its entry a moment before removal can still take from the orphaned // bucket: that client gets at most one extra permit, once. public void Sweep(long now) { Interlocked.Exchange(ref _checksSinceSweep, 0); foreach (var pair in _buckets) lock (pair.Value.Gate) if (now - pair.Value.LastSeen >= pair.Value.IdleAfter) _buckets.TryRemove(pair); }
public int TrackedKeys => _buckets.Count;}
// What a request looks like to the limiter.public sealed record Request(string UserId, string Ip, string Endpoint);
// One limit, how to key it, and which algorithm enforces it. For example// new Rule("user/second", r => r.UserId, new Limit(10, TimeSpan.FromSeconds(1)), (l, t) => new TokenBucket(l, t))public sealed record Rule(string Name, Func<Request, string> KeyOf, Limit Limit, BucketFactory Algorithm);
// Each Tracked gets its own lock: refill, check and take happen as one step, and two// clients never wait for each other.internal sealed class Tracked(IBucket bucket, long idleAfter, long now){ public readonly Lock Gate = new(); public IBucket Bucket { get; } = bucket; public long IdleAfter { get; } = idleAfter; public long LastSeen { get; set; } = now; // read and written under Gate}// The complete design, built from every stage on this page.
package ratelimiter
import ( "math" "slices" "sync" "time")
// Fixed window: count each client's requests in the current window, and refuse once the// count reaches the limit. The clock is injected from the start: a limiter is all about// time, and a test that sleeps is not a test.type window struct { start time.Time count int}
// Limit is "permits per period", such as 10 per second.type Limit struct { Permits int Per time.Duration}
// Decision is what the caller needs: yes or no, and when to try again.// RetryAfter becomes the Retry-After header on an HTTP 429.type Decision struct { Allowed bool RetryAfter time.Duration}
// Bucket is one client's allowance under one limit. An interface, because token bucket// and fixed window (and sliding window, and leaky bucket) are interchangeable answers to// "may this key go now?".type Bucket interface { TryTake(now time.Time) bool Refund(now time.Time) // gives a permit back when a later rule says no RetryAfter(now time.Time) time.Duration // 0 if a permit is available now}
// TokenBucket holds up to Permits tokens and refills continuously at Permits/Per. Each// request takes one. A client may burst up to the capacity, but over any long stretch the// rate can't exceed the limit, and there is no window edge to game.type TokenBucket struct { capacity float64 perToken time.Duration // time to refill one token tokens float64 updatedAt time.Time}
func NewTokenBucket(l Limit, now time.Time) Bucket { // A new client starts with a full bucket. return &TokenBucket{capacity: float64(l.Permits), perToken: l.Per / time.Duration(l.Permits), tokens: float64(l.Permits), updatedAt: now}}
// refill is lazy: no timer per bucket, just catch up when the bucket is used.// A million idle buckets cost no CPU at all.func (b *TokenBucket) refill(now time.Time) { if !now.After(b.updatedAt) { return // the clock didn't move (or went backwards): nothing to add } b.tokens = min(b.capacity, b.tokens+float64(now.Sub(b.updatedAt))/float64(b.perToken)) b.updatedAt = now}
func (b *TokenBucket) TryTake(now time.Time) bool { b.refill(now) if b.tokens < 1 { return false } b.tokens-- return true}
// Refund gives back a permit when a later rule refused the request. Never above capacity.func (b *TokenBucket) Refund(now time.Time) { b.refill(now) b.tokens = min(b.capacity, b.tokens+1)}
func (b *TokenBucket) RetryAfter(now time.Time) time.Duration { b.refill(now) if b.tokens >= 1 { return 0 } return time.Duration(math.Ceil((1 - b.tokens) * float64(b.perToken)))}
// FixedWindow is the fixed-window algorithm behind the same interface: count requests in each// window of the clock, and start again at each edge. Cheaper (two numbers), and fine for long// periods like "1,000 per day", where a burst at midnight is acceptable.type FixedWindow struct { limit int window time.Duration start time.Time count int}
func NewFixedWindow(l Limit, now time.Time) Bucket { return &FixedWindow{limit: l.Permits, window: l.Per, start: now.Truncate(l.Per)}}
// roll moves to the current window once time has passed the edge, and starts the count again.func (w *FixedWindow) roll(now time.Time) { if s := now.Truncate(w.window); !s.Equal(w.start) { w.start, w.count = s, 0 }}
func (w *FixedWindow) TryTake(now time.Time) bool { w.roll(now) if w.count >= w.limit { return false } w.count++ return true}
func (w *FixedWindow) Refund(now time.Time) { w.roll(now) if w.count > 0 { // after a roll there is nothing to give back w.count-- }}
func (w *FixedWindow) RetryAfter(now time.Time) time.Duration { w.roll(now) if w.count < w.limit { return 0 } return w.start.Add(w.window).Sub(now)}
// BucketFactory creates a bucket for a key seen for the first time. NewTokenBucket and// NewFixedWindow both fit, which is how a rule picks its algorithm.type BucketFactory func(l Limit, now time.Time) Bucket
// Request is what a request looks like to the limiter.type Request struct { UserID, IP, Endpoint string}
// Rule is one limit, how to key it, and which algorithm enforces it. For example// Rule{"user/second", func(r Request) string { return r.UserID }, Limit{10, time.Second}, NewTokenBucket}type Rule struct { Name string KeyOf func(Request) string Limit Limit Algorithm BucketFactory}
const sweepEvery = 10_000 // checks between sweeps; amortizes the scan
func NewRateLimiter(rules []Rule, clock func() time.Time) *RateLimiter { return &RateLimiter{rules: slices.Clone(rules), clock: clock, buckets: map[string]*tracked{}}}
// Each tracked bucket gets its own mutex: refill, check and take happen as one step, and// two clients never wait for each other.type tracked struct { mu sync.Mutex bucket Bucket idleAfter time.Duration lastSeen time.Time}
// RateLimiter checks every rule before a request goes through. Each rule has its own bucket per// client, kept in one map under a key like "rule|client". Safe to call from many goroutines.type RateLimiter struct { rules []Rule clock func() time.Time mu sync.Mutex // guards the map itself: lookups, inserts, sweeps buckets map[string]*tracked checksSinceSweep int}
// get looks up or creates in one step, so two first requests from a new client can't// each get their own full bucket.func (rl *RateLimiter) get(rule Rule, key string, now time.Time) *tracked { rl.mu.Lock() defer rl.mu.Unlock() t, ok := rl.buckets[key] if !ok { t = &tracked{bucket: rule.Algorithm(rule.Limit, now), idleAfter: rule.Limit.Per} rl.buckets[key] = t } return t}
// Check takes a permit from this client's bucket for each rule. On the first no, the permits// taken by earlier rules are given back, so a refused request costs the client nothing, and the// answer says when to retry.func (rl *RateLimiter) Check(r Request) Decision { now := rl.clock() rl.mu.Lock() rl.checksSinceSweep++ sweepDue := rl.checksSinceSweep >= sweepEvery rl.mu.Unlock() if sweepDue { rl.Sweep(now) } var taken []*tracked for _, rule := range rl.rules { t := rl.get(rule, rule.Name+"|"+rule.KeyOf(r), now) // At most one bucket lock is held at a time, so overlapping rules can't deadlock. t.mu.Lock() t.lastSeen = now ok := t.bucket.TryTake(now) var retry time.Duration if !ok { retry = t.bucket.RetryAfter(now) } t.mu.Unlock() if !ok { for _, p := range taken { p.mu.Lock() // the same lock as the take p.bucket.Refund(now) p.mu.Unlock() } return Decision{RetryAfter: retry} } taken = append(taken, t) } return Decision{Allowed: true}}
// Sweep holds the map lock while checking each bucket under its own lock, so a key that// was just used survives. A request that fetched its entry a moment before removal can// still take from the orphaned bucket: that client gets at most one extra permit, once.func (rl *RateLimiter) Sweep(now time.Time) { rl.mu.Lock() defer rl.mu.Unlock() for key, t := range rl.buckets { t.mu.Lock() if now.Sub(t.lastSeen) >= t.idleAfter { delete(rl.buckets, key) } t.mu.Unlock() } rl.checksSinceSweep = 0}
// TrackedKeys says how many buckets are in memory. Tests use it to watch the sweep work.func (rl *RateLimiter) TrackedKeys() int { rl.mu.Lock() defer rl.mu.Unlock() return len(rl.buckets)}// The complete design, built from every stage on this page.
// "permits per period", such as 10 per second.interface Limit { readonly permits: number; readonly perMs: number;}
// What the caller needs: yes or no, and when to try again.// retryAfterMs becomes the Retry-After header on an HTTP 429.interface Decision { readonly allowed: boolean; readonly retryAfterMs: number;}
// One client's allowance under one limit. An interface, because token bucket and fixed// window (and sliding window, and leaky bucket) are interchangeable answers to// "may this key go now?".interface Bucket { tryTake(now: number): boolean; refund(now: number): void; // gives a permit back when a later rule says no retryAfterMs(now: number): number; // 0 if a permit is available now}
// Holds up to `permits` tokens and refills continuously at permits/period. Each request// takes one. A client may burst up to the capacity, but over any long stretch the rate// can't exceed the limit, and there is no window edge to game.class TokenBucket implements Bucket { private readonly capacity: number; private readonly perMsRate: number; // tokens per millisecond private tokens: number;
constructor(limit: Limit, private updatedAt: number) { this.capacity = limit.permits; this.perMsRate = limit.permits / limit.perMs; this.tokens = limit.permits; // a new client starts with a full bucket }
// Lazy refill: no timer per bucket, just catch up when the bucket is used. // A million idle buckets cost no CPU at all. private refill(now: number): void { if (now <= this.updatedAt) return; // the clock didn't move (or went backwards) this.tokens = Math.min(this.capacity, this.tokens + (now - this.updatedAt) * this.perMsRate); this.updatedAt = now; }
tryTake(now: number): boolean { this.refill(now); if (this.tokens < 1) return false; this.tokens--; return true; }
// Gives back a permit when a later rule refused the request. Never above capacity. refund(now: number): void { this.refill(now); this.tokens = Math.min(this.capacity, this.tokens + 1); }
retryAfterMs(now: number): number { this.refill(now); return this.tokens >= 1 ? 0 : Math.ceil((1 - this.tokens) / this.perMsRate); }}
// The fixed-window algorithm behind the same interface: count requests in each window of the// clock, and start again at each edge. Cheaper (two numbers), and fine for long periods like// "1,000 per day", where a burst at midnight is acceptable.class FixedWindow implements Bucket { private start: number; private count = 0;
constructor(private readonly limit: Limit, now: number) { this.start = now - (now % limit.perMs); }
// Moves to the current window once time has passed the edge, and starts the count again. private roll(now: number): void { const current = now - (now % this.limit.perMs); if (current !== this.start) { this.start = current; this.count = 0; } }
tryTake(now: number): boolean { this.roll(now); if (this.count >= this.limit.permits) return false; this.count++; return true; }
refund(now: number): void { this.roll(now); if (this.count > 0) this.count--; // after a roll there is nothing to give back }
retryAfterMs(now: number): number { this.roll(now); return this.count < this.limit.permits ? 0 : this.start + this.limit.perMs - now; }}
// Creates a bucket for a key seen for the first time. Both classes fit// (`(l, now) => new TokenBucket(l, now)`), which is how a rule picks its algorithm.type BucketFactory = (limit: Limit, now: number) => Bucket;
const tokenBucket: BucketFactory = (l, now) => new TokenBucket(l, now);
const fixedWindow: BucketFactory = (l, now) => new FixedWindow(l, now);
// checks between sweeps; amortizes the scan
// Every rule must agree before a request goes through. Each rule has its own bucket per client,// kept in one map under a key like "rule|client".class RateLimiter { private readonly buckets = new Map<string, Tracked>(); private checksSinceSweep = 0;
constructor( private readonly rules: readonly Rule[], private readonly clock: () => number = Date.now, ) {}
// Each rule takes a permit from this client's bucket. On the first no, the permits taken by // earlier rules are given back, so a refused request costs the client nothing, and the answer // says when to retry. check(r: Request): Decision { const now = this.clock(); if (++this.checksSinceSweep >= SWEEP_EVERY) this.sweep(now); const taken: Bucket[] = []; for (const rule of this.rules) { const key = `${rule.name}|${rule.keyOf(r)}`; let t = this.buckets.get(key); if (!t) { t = { bucket: rule.algorithm(rule.limit, now), idleAfterMs: rule.limit.perMs, lastSeen: now }; this.buckets.set(key, t); } t.lastSeen = now; if (!t.bucket.tryTake(now)) { for (const b of taken) b.refund(now); return { allowed: false, retryAfterMs: t.bucket.retryAfterMs(now) }; } taken.push(t.bucket); } return { allowed: true, retryAfterMs: 0 }; }
// Forget every key idle for a full period. So memory tracks the clients active in the // last period, not every client ever seen: a scan of one IP per request no longer grows // the map forever. Deleting while iterating a Map is safe in JavaScript. sweep(now: number): void { for (const [key, t] of this.buckets) { if (now - t.lastSeen >= t.idleAfterMs) this.buckets.delete(key); } this.checksSinceSweep = 0; }
// How many buckets are in memory. Tests use it to watch the sweep work. trackedKeys(): number { return this.buckets.size; }}
// What a request looks like to the limiter.interface Request { readonly userId: string; readonly ip: string; readonly endpoint: string;}
// One limit, how to key it, and which algorithm enforces it. For example// { name: 'user/second', keyOf: (r) => r.userId, limit: { permits: 10, perMs: 1000 }, algorithm: tokenBucket }interface Rule { readonly name: string; readonly keyOf: (r: Request) => string; readonly limit: Limit; readonly algorithm: BucketFactory;}
// A bucket plus when its key was last seen. A bucket left alone for one full period of its// limit is back where a new one starts (a token bucket refills completely; a fixed window// rolls over), so dropping it changes nothing.interface Tracked { readonly bucket: Bucket; readonly idleAfterMs: number; lastSeen: number;}
const SWEEP_EVERY = 10_000;
// Node runs one check at a time, so one process has no race to fix. The race comes back// when the API runs on many instances: each one's buckets only see its own traffic, and// "10 per second" becomes "10 per second per instance". The buckets move to Redis.//// The minimum this needs from a Redis client (ioredis and node-redis both have it).interface RedisLike { eval(script: string, keys: string[], args: (string | number)[]): Promise<unknown>;}
// The token bucket, written in Lua. Redis runs a script as one step, so// read, refill, take and write can't interleave with another instance's check. A// GET-then-SET from Node could: two instances would both see one token and both take it.const TOKEN_BUCKET_LUA = `local key = KEYS[1]local capacity = tonumber(ARGV[1])local per_ms = tonumber(ARGV[2]) -- tokens per millisecondlocal now = tonumber(ARGV[3])local state = redis.call('HMGET', key, 'tokens', 'at')local tokens = tonumber(state[1]) or capacity -- a new key starts fulllocal at = tonumber(state[2]) or nowif now > at then tokens = math.min(capacity, tokens + (now - at) * per_ms); at = now endlocal allowed = 0if tokens >= 1 then tokens = tokens - 1; allowed = 1 endredis.call('HSET', key, 'tokens', tokens, 'at', at)-- The sweep, done by Redis: an idle key expires after one full period.redis.call('PEXPIRE', key, math.ceil(capacity / per_ms))if allowed == 1 then return 0 endreturn math.ceil((1 - tokens) / per_ms) -- milliseconds until a token is back`;
// One rule, enforced across every instance. The clock is still passed in, but in// production every instance should use Redis's own clock (redis.call('TIME')), because// instance clocks drift apart.class RedisTokenBucket { constructor( private readonly redis: RedisLike, private readonly rule: Rule, private readonly clock: () => number = Date.now, ) {}
async check(r: Request): Promise<Decision> { const { permits, perMs } = this.rule.limit; const key = `rl:${this.rule.name}:${this.rule.keyOf(r)}`; const retryAfterMs = Number(await this.redis.eval(TOKEN_BUCKET_LUA, [key], [permits, permits / perMs, this.clock()])); return { allowed: retryAfterMs === 0, retryAfterMs }; }}Three ideas hold it together:
- What is limited, and how, are separate. A
Rulesays which key and which limit. ABucketenforces it. Adding a sliding window touches neither the rules nor the limiter. The buckets are the Strategy pattern, andBucketFactoryis the Factory pattern. - Buckets know nothing about clocks or keys. They’re given
nowas an argument, so each bucket is plain arithmetic that a test can check with plain numbers. - A decision is all or nothing. Either every rule’s bucket gave up a token, or none did. To
callers,
RateLimiteris a simple front door with one method,check(request)(a facade).
Stress it
Section titled “Stress it”The design works for one thread. But a server calls check from thousands of request threads at
once. And later, from many servers.
Two threads, one last token
Section titled “Two threads, one last token”Thousands of request threads call
checkat once. Make it safe.
Whether you find both races (inside a bucket, and when a bucket is created), and lock so that two clients never wait for each other. Then whether you notice that holding several bucket locks at once can block forever.
There are two races, meaning two places where threads can interfere.
The first is inside a bucket. A bucket has one token left. Two threads refill it at the same moment, both see 1 token, and both take it. The bucket has handed out a token it didn’t have. So “refill, check, take” must run as one step that no other thread can split.
The second is in the map. A new client sends its first two requests at the same moment. Both threads look for the client’s bucket, find nothing, and each creates one. Each bucket is full, so the client gets two bursts. So “look up, or create” must also be one step.
The clearly correct fix is one global lock. check takes one lock for the whole limiter. That
fixes both races. But every check, from every client, now waits in one line.
The better fix is a lock per bucket. A thread-safe map creates each bucket in one step: if two threads ask at once, both get the same bucket. Then each take and each refund runs under that bucket’s own small lock. Two different clients never wait for each other.
| Option | How | Threads wait when |
|---|---|---|
| 1. One global lock | check holds one lock |
Always: one check at a time |
| 2. Lock per bucket (our choice) | Thread-safe map; each bucket’s take under its own lock | Only when they share a bucket |
| 3. Lock-free | Pack tokens and time into one atomic number; retry on conflict | Never, but retries under heavy use |
| 4. Redis script | The bucket lives in Redis; a script refills and takes | One network round trip per check |
// Every rule must agree before a request goes through. Each rule has its own bucket per client,// kept in one map under a key like "rule|client". Safe to call from many threads.class RateLimiter { // The sweep runs once every 10,000 checks, so its cost is spread thin. private static final int SWEEP_EVERY = 10_000; private final List<Rule> rules; private final LongSupplier clock; // Safe to read and insert from many threads; computeIfAbsent creates each bucket once, // so two first requests from a new client can't each get their own full bucket. private final ConcurrentHashMap<String, Tracked> buckets = new ConcurrentHashMap<>(); private final AtomicInteger checksSinceSweep = new AtomicInteger();
RateLimiter(List<Rule> rules, LongSupplier clock) { this.rules = List.copyOf(rules); this.clock = clock; }
// Each rule takes a permit from this client's bucket. On the first no, the permits taken by // earlier rules are given back, so a refused request costs the client nothing, and the // answer says when to retry. Decision check(Request r) { long now = clock.getAsLong(); if (checksSinceSweep.incrementAndGet() >= SWEEP_EVERY) sweep(now); List<Tracked> taken = new ArrayList<>(); for (Rule rule : rules) { Tracked t = buckets.computeIfAbsent(rule.name() + "|" + rule.keyOf().apply(r), k -> new Tracked(rule.algorithm().create(rule.limit(), now), rule.limit().per().toNanos(), now)); boolean ok; // One lock per bucket: refill, check and take happen as one step, and two clients // never wait for each other. We hold at most one bucket lock at a time, so the // rules can't deadlock however they overlap. synchronized (t) { t.lastSeen = now; ok = t.bucket.tryTake(now); } if (!ok) { for (Tracked p : taken) synchronized (p) { p.bucket.refund(now); } // same lock as the take synchronized (t) { return new Decision(false, t.bucket.retryAfterNanos(now)); } } taken.add(t); } return new Decision(true, 0); }
// Removal re-checks idleness under the bucket's lock, so a key that was just used // survives. A request that fetched the entry a moment before it was removed can still // take from the orphaned bucket: that client gets at most one extra permit, once. void sweep(long now) { checksSinceSweep.set(0); buckets.forEach((key, t) -> { synchronized (t) { if (now - t.lastSeen >= t.idleAfterNanos) buckets.remove(key, t); } }); }
// How many buckets are in memory. Tests use it to watch the sweep work. int trackedKeys() { return buckets.size(); }}import threading
# Each tracked bucket gets its own lock: refill, check and take happen as one step, and two# clients never wait for each other.class Tracked: __slots__ = ("bucket", "idle_after", "last_seen", "lock")
def __init__(self, bucket: Bucket, idle_after: float, now: float): self.bucket = bucket self.idle_after = idle_after self.last_seen = now self.lock = threading.Lock()
# Every rule must agree before a request goes through. Each rule has its own bucket per client,# kept in one map under a key like "rule|client". Safe to call from many threads.class RateLimiter: SWEEP_EVERY = 10_000
def __init__(self, rules: list[Rule], clock: Callable[[], float] = time.monotonic): self._rules = tuple(rules) self._clock = clock self._buckets: dict[tuple[str, str], Tracked] = {} self._map_lock = threading.Lock() # guards the dict itself: lookups, inserts, sweeps self._checks_since_sweep = 0
# Look up or create in one step, so two first requests from a new client can't each # get their own full bucket. def _tracked(self, rule: Rule, key: tuple[str, str], now: float) -> Tracked: with self._map_lock: t = self._buckets.get(key) if t is None: t = self._buckets[key] = Tracked(rule.algorithm(rule.limit, now), rule.limit.per_seconds, now) return t
# Each rule takes a permit from this client's bucket. On the first no, the permits taken by # earlier rules are given back, so a refused request costs the client nothing, and the # answer says when to retry. def check(self, r: Request) -> Decision: now = self._clock() with self._map_lock: self._checks_since_sweep += 1 sweep_due = self._checks_since_sweep >= self.SWEEP_EVERY if sweep_due: self.sweep(now) taken: list[Tracked] = [] for rule in self._rules: t = self._tracked(rule, (rule.name, rule.key_of(r)), now) # At most one bucket lock is held at a time, so overlapping rules can't deadlock. with t.lock: t.last_seen = now ok = t.bucket.try_take(now) if not ok: for p in taken: with p.lock: # the same lock as the take p.bucket.refund(now) with t.lock: return Decision(False, t.bucket.retry_after(now)) taken.append(t) return Decision(True)
# Holding the dict lock while checking each bucket under its own lock means a key that # was just used survives. A request that fetched its entry a moment before removal can # still take from the orphaned bucket: that client gets at most one extra permit, once. def sweep(self, now: float) -> None: with self._map_lock: for key, t in list(self._buckets.items()): with t.lock: if now - t.last_seen >= t.idle_after: del self._buckets[key] self._checks_since_sweep = 0
# How many buckets are in memory. Tests use it to watch the sweep work. def tracked_keys(self) -> int: with self._map_lock: return len(self._buckets)// Each Tracked gets its own lock: refill, check and take happen as one step, and two// clients never wait for each other.internal sealed class Tracked(IBucket bucket, long idleAfter, long now){ public readonly Lock Gate = new(); public IBucket Bucket { get; } = bucket; public long IdleAfter { get; } = idleAfter; public long LastSeen { get; set; } = now; // read and written under Gate}
// Every rule must agree before a request goes through. Each rule has its own bucket per client,// kept in one map under a key like "rule|client". Safe to call from many threads.public sealed class RateLimiter(IReadOnlyList<Rule> rules, TimeProvider time){ private const int SweepEvery = 10_000; // GetOrAdd makes lookup-or-create one step, so two first requests from a new client // can't each get their own full bucket. (The factory may run twice in a race, but only // one result is stored and returned to both.) private readonly ConcurrentDictionary<(string Rule, string Key), Tracked> _buckets = new(); private int _checksSinceSweep;
// Each rule takes a permit from this client's bucket. On the first no, the permits taken by // earlier rules are given back, so a refused request costs the client nothing, and the // answer says when to retry. public Decision Check(Request r) { long now = time.GetUtcNow().Ticks; if (Interlocked.Increment(ref _checksSinceSweep) >= SweepEvery) Sweep(now); var taken = new List<Tracked>(rules.Count); foreach (var rule in rules) { var t = _buckets.GetOrAdd((rule.Name, rule.KeyOf(r)), static (_, a) => new Tracked(a.rule.Algorithm(a.rule.Limit, a.now), a.rule.Limit.Per.Ticks, a.now), (rule, now)); // At most one bucket lock is held at a time, so overlapping rules can't deadlock. lock (t.Gate) { t.LastSeen = now; if (t.Bucket.TryTake(now)) { taken.Add(t); continue; } } foreach (var p in taken) lock (p.Gate) p.Bucket.Refund(now); // the same lock as the take lock (t.Gate) return new Decision(false, t.Bucket.RetryAfter(now)); } return Decision.Allow; }
// Each entry is re-checked under its own lock, and removed only if it is still the // same entry (TryRemove with the pair), so a key that was just used survives. A request // that fetched its entry a moment before removal can still take from the orphaned // bucket: that client gets at most one extra permit, once. public void Sweep(long now) { Interlocked.Exchange(ref _checksSinceSweep, 0); foreach (var pair in _buckets) lock (pair.Value.Gate) if (now - pair.Value.LastSeen >= pair.Value.IdleAfter) _buckets.TryRemove(pair); }
public int TrackedKeys => _buckets.Count;}// Each tracked bucket gets its own mutex: refill, check and take happen as one step, and// two clients never wait for each other.type tracked struct { mu sync.Mutex bucket Bucket idleAfter time.Duration lastSeen time.Time}
// RateLimiter checks every rule before a request goes through. Each rule has its own bucket per// client, kept in one map under a key like "rule|client". Safe to call from many goroutines.type RateLimiter struct { rules []Rule clock func() time.Time mu sync.Mutex // guards the map itself: lookups, inserts, sweeps buckets map[string]*tracked checksSinceSweep int}
// get looks up or creates in one step, so two first requests from a new client can't// each get their own full bucket.func (rl *RateLimiter) get(rule Rule, key string, now time.Time) *tracked { rl.mu.Lock() defer rl.mu.Unlock() t, ok := rl.buckets[key] if !ok { t = &tracked{bucket: rule.Algorithm(rule.Limit, now), idleAfter: rule.Limit.Per} rl.buckets[key] = t } return t}
// Check takes a permit from this client's bucket for each rule. On the first no, the permits// taken by earlier rules are given back, so a refused request costs the client nothing, and the// answer says when to retry.func (rl *RateLimiter) Check(r Request) Decision { now := rl.clock() rl.mu.Lock() rl.checksSinceSweep++ sweepDue := rl.checksSinceSweep >= sweepEvery rl.mu.Unlock() if sweepDue { rl.Sweep(now) } var taken []*tracked for _, rule := range rl.rules { t := rl.get(rule, rule.Name+"|"+rule.KeyOf(r), now) // At most one bucket lock is held at a time, so overlapping rules can't deadlock. t.mu.Lock() t.lastSeen = now ok := t.bucket.TryTake(now) var retry time.Duration if !ok { retry = t.bucket.RetryAfter(now) } t.mu.Unlock() if !ok { for _, p := range taken { p.mu.Lock() // the same lock as the take p.bucket.Refund(now) p.mu.Unlock() } return Decision{RetryAfter: retry} } taken = append(taken, t) } return Decision{Allowed: true}}
// Sweep holds the map lock while checking each bucket under its own lock, so a key that// was just used survives. A request that fetched its entry a moment before removal can// still take from the orphaned bucket: that client gets at most one extra permit, once.func (rl *RateLimiter) Sweep(now time.Time) { rl.mu.Lock() defer rl.mu.Unlock() for key, t := range rl.buckets { t.mu.Lock() if now.Sub(t.lastSeen) >= t.idleAfter { delete(rl.buckets, key) } t.mu.Unlock() } rl.checksSinceSweep = 0}
// TrackedKeys says how many buckets are in memory. Tests use it to watch the sweep work.func (rl *RateLimiter) TrackedKeys() int { rl.mu.Lock() defer rl.mu.Unlock() return len(rl.buckets)}// Node runs one check at a time, so one process has no race to fix. The race comes back// when the API runs on many instances: each one's buckets only see its own traffic, and// "10 per second" becomes "10 per second per instance". The buckets move to Redis.//// The minimum this needs from a Redis client (ioredis and node-redis both have it).interface RedisLike { eval(script: string, keys: string[], args: (string | number)[]): Promise<unknown>;}
// The token bucket, written in Lua. Redis runs a script as one step, so// read, refill, take and write can't interleave with another instance's check. A// GET-then-SET from Node could: two instances would both see one token and both take it.const TOKEN_BUCKET_LUA = `local key = KEYS[1]local capacity = tonumber(ARGV[1])local per_ms = tonumber(ARGV[2]) -- tokens per millisecondlocal now = tonumber(ARGV[3])local state = redis.call('HMGET', key, 'tokens', 'at')local tokens = tonumber(state[1]) or capacity -- a new key starts fulllocal at = tonumber(state[2]) or nowif now > at then tokens = math.min(capacity, tokens + (now - at) * per_ms); at = now endlocal allowed = 0if tokens >= 1 then tokens = tokens - 1; allowed = 1 endredis.call('HSET', key, 'tokens', tokens, 'at', at)-- The sweep, done by Redis: an idle key expires after one full period.redis.call('PEXPIRE', key, math.ceil(capacity / per_ms))if allowed == 1 then return 0 endreturn math.ceil((1 - tokens) / per_ms) -- milliseconds until a token is back`;
// One rule, enforced across every instance. The clock is still passed in, but in// production every instance should use Redis's own clock (redis.call('TIME')), because// instance clocks drift apart.class RedisTokenBucket { constructor( private readonly redis: RedisLike, private readonly rule: Rule, private readonly clock: () => number = Date.now, ) {}
async check(r: Request): Promise<Decision> { const { permits, perMs } = this.rule.limit; const key = `rl:${this.rule.name}:${this.rule.keyOf(r)}`; const retryAfterMs = Number(await this.redis.eval(TOKEN_BUCKET_LUA, [key], [permits, permits / perMs, this.clock()])); return { allowed: retryAfterMs === 0, retryAfterMs }; }}Why is this correct? The map’s “get or create” step (computeIfAbsent in Java, GetOrAdd in
C#, a map lock in Python and Go) means every thread gets the same bucket for a key. Each take and
each refund runs under that bucket’s lock, so a bucket never gives away the same token twice.
And there’s one more rule: a thread holds at most one bucket lock at a time. It takes from one bucket, lets go of its lock, then moves to the next. Refunds also go one at a time. Why does that matter? If a thread held bucket A’s lock and waited for B’s, while another thread held B and waited for A, both would wait forever. That’s called a deadlock. With only one lock held at a time, it can’t happen.
What does it cost? A very busy client, say 50,000 requests a second, makes its threads queue on its one bucket lock. A lock-free version avoids that, but it’s harder to get right, and it retries a lot in exactly that busy case.
Go deeperThe sweep, while requests are running1 min
The sweep is the delicate part. It checks again that a bucket is idle while holding that bucket’s lock. And it removes the map entry only if the entry is still that same bucket.
There’s still one small gap. A request may fetch a bucket just before the sweep removes it. That request takes from the removed bucket, and the next request creates a new, full one. The worst case is one extra token for that client, once. That’s acceptable for a limiter. Making it exact would need the map’s lock on every check, which is a big cost for a tiny gain.
Go deeperHow each language does the locking1 min
Java uses a ConcurrentHashMap and a lock per bucket. Python and Go use one lock for the map and
one per bucket. C# uses ConcurrentDictionary and the Lock type.
TypeScript in one Node process has no thread race, since each check runs to the end before the
next starts. So its stage 5 solves the race Node does have: many copies of the service behind a
load balancer, each counting only its own traffic. The bucket moves into Redis. A small Lua script
does the refill and take as one step, because Redis runs a script without mixing it with other
commands.
Go deeperTwenty servers, one limit2 min
Say the service runs on 20 servers, and each one enforces 10 per second. The real limit is 200 per second per user, because each server only sees its own requests. There are three common fixes:
- Divide the limit by the number of servers. Each server allows 0.5 per second. It’s cheap, but it’s wrong when traffic is uneven, or when servers are added and removed.
- Sticky routing. The load balancer always sends a user to the same server. It works until that server goes down, or one server gets all the busy users.
- A shared store. The buckets move into Redis, and every server checks the same bucket. This is
the usual answer. Our
Bucketinterface stays the same; only where the numbers live changes.
The shared store costs one network round trip per check, and adds something that can fail. So decide in advance what happens when Redis is down. For most APIs, fail open: let requests through, since a short time without limits is better than an outage. For login attempts, fail closed: refuse, since password guessing is exactly what the limit stops.
How you’d prove it works
Section titled “How you’d prove it works”Each test protects one promise:
- The fixed window really does let twice the limit through at an edge. Writing this test shows the flaw instead of just claiming it.
- The token bucket refills at exactly the limit’s rate, and reports the right retry time.
- A request refused by one rule leaves every other rule’s bucket untouched.
- Idle clients are forgotten after one full period.
- Under 16 threads, exactly the limit gets through.
The thread test freezes the clock, so no tokens drip in. It sends 1,000 requests from 16 threads at a bucket of 100 tokens. Exactly 100 must be allowed. More than 100 means a token was taken twice, or the client got two buckets. Freezing time is what makes an exact check possible.
Go deeperThe tests in five languages2 min
static final Duration SECOND = Duration.ofSeconds(1);long now = 0; // the test's clock, in nanosecondsLongSupplier clock = () -> now;
Rule perUser(int permits, BucketFactory algorithm) { return new Rule("user", Request::userId, Limit.of(permits, SECOND), algorithm);}
Request from(String user) { return new Request(user, "10.0.0.1", "/api"); }
@Testvoid fixedWindowLetsTwiceTheLimitThroughAtTheEdge() { RateLimiter rl = new RateLimiter(List.of(perUser(10, FixedWindow::new)), clock); now = SECOND.toNanos() - 1_000_000; // 1 ms before the window ends for (int i = 0; i < 10; i++) assertTrue(rl.check(from("ana")).allowed()); now = SECOND.toNanos(); // the next window for (int i = 0; i < 10; i++) assertTrue(rl.check(from("ana")).allowed()); // 20 requests in 1 ms under a "10 per second" limit: the flaw the token bucket fixes.}
@Testvoid tokenBucketRefillsAtTheLimitRateAndSaysWhenToRetry() { RateLimiter rl = new RateLimiter(List.of(perUser(10, TokenBucket::new)), clock); for (int i = 0; i < 10; i++) assertTrue(rl.check(from("ana")).allowed()); // the burst Decision d = rl.check(from("ana")); assertFalse(d.allowed()); assertEquals(100_000_000L, d.retryAfterNanos()); // one token every 100 ms now += 100_000_000L; assertTrue(rl.check(from("ana")).allowed());}
@Testvoid aRefusedRequestUsesUpNoOtherLimit() { // 5 per second per user, and 1 per second per IP. Bo is refused by the IP rule... Rule perIp = new Rule("ip", Request::ip, Limit.of(1, SECOND), TokenBucket::new); RateLimiter rl = new RateLimiter(List.of(perUser(5, TokenBucket::new), perIp), clock); assertTrue(rl.check(new Request("ana", "10.0.0.1", "/")).allowed()); for (int i = 0; i < 5; i++) assertFalse(rl.check(new Request("bo", "10.0.0.1", "/")).allowed()); // ...so his user bucket was refunded every time and is still full on a new IP. for (int i = 0; i < 5; i++) assertTrue(rl.check(new Request("bo", "10.0.0." + (i + 2), "/")).allowed());}
@Testvoid concurrentRequestsNeverExceedTheLimit() throws Exception { // 100 permits, 16 threads, 1,000 requests at one frozen instant: exactly 100 may pass. RateLimiter rl = new RateLimiter(List.of(perUser(100, TokenBucket::new)), clock); ExecutorService pool = Executors.newFixedThreadPool(16); CountDownLatch start = new CountDownLatch(1); List<Future<Boolean>> results = new ArrayList<>(); for (int i = 0; i < 1_000; i++) { results.add(pool.submit(() -> { start.await(); return rl.check(from("ana")).allowed(); })); } start.countDown(); int allowed = 0; for (var f : results) if (f.get()) allowed++; pool.shutdown(); assertEquals(100, allowed);}
@Testvoid idleKeysAreForgottenAfterOneFullPeriod() { RateLimiter rl = new RateLimiter(List.of(perUser(10, TokenBucket::new)), clock); for (int i = 0; i < 1_000; i++) rl.check(from("user-" + i)); // a scan: one request per user now += SECOND.toNanos(); // one full period later rl.sweep(now); assertEquals(0, rl.trackedKeys()); // nothing kept for them}import threadingfrom concurrent.futures import ThreadPoolExecutor
# A clock the test moves by hand, in seconds.class TestClock: def __init__(self): self.now = 0.0
def __call__(self) -> float: return self.now
def per_user(permits: int, algorithm: BucketFactory) -> Rule: return Rule("user", lambda r: r.user_id, Limit(permits, 1.0), algorithm)
def frm(user: str) -> Request: return Request(user, "10.0.0.1", "/api")
def test_fixed_window_lets_twice_the_limit_through_at_the_edge(): clock = TestClock() rl = RateLimiter([per_user(10, FixedWindow)], clock) clock.now = 0.999 # 1 ms before the window ends assert all(rl.check(frm("ana")).allowed for _ in range(10)) clock.now = 1.0 # the next window assert all(rl.check(frm("ana")).allowed for _ in range(10)) # 20 requests in 1 ms under a "10 per second" limit: the flaw the token bucket fixes.
def test_token_bucket_refills_at_the_limit_rate_and_says_when_to_retry(): clock = TestClock() rl = RateLimiter([per_user(10, TokenBucket)], clock) assert all(rl.check(frm("ana")).allowed for _ in range(10)) # the burst d = rl.check(frm("ana")) assert not d.allowed and math.isclose(d.retry_after, 0.1) # one token every 100 ms clock.now += 0.1 assert rl.check(frm("ana")).allowed
def test_a_refused_request_uses_up_no_other_limit(): # 5 per second per user, and 1 per second per IP. Bo is refused by the IP rule... clock = TestClock() per_ip = Rule("ip", lambda r: r.ip, Limit(1, 1.0), TokenBucket) rl = RateLimiter([per_user(5, TokenBucket), per_ip], clock) assert rl.check(Request("ana", "10.0.0.1", "/")).allowed assert not any(rl.check(Request("bo", "10.0.0.1", "/")).allowed for _ in range(5)) # ...so his user bucket was refunded every time and is still full on new IPs. assert all(rl.check(Request("bo", f"10.0.0.{i + 2}", "/")).allowed for i in range(5))
def test_idle_keys_are_forgotten_after_one_full_period(): clock = TestClock() rl = RateLimiter([per_user(10, TokenBucket)], clock) for i in range(1_000): rl.check(frm(f"user-{i}")) # a scan: one request per user clock.now += 1.0 # one full period later rl.sweep(clock.now) assert rl.tracked_keys() == 0
def test_concurrent_requests_never_exceed_the_limit(): # 100 permits, 16 threads, 1,000 requests at one frozen instant: exactly 100 may pass. rl = RateLimiter([per_user(100, TokenBucket)], TestClock()) start = threading.Barrier(16)
def attempt(i: int) -> bool: if i < 16: start.wait() # line the first wave up so they collide return rl.check(frm("ana")).allowed
with ThreadPoolExecutor(max_workers=16) as pool: assert sum(pool.map(attempt, range(1_000))) == 100// FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) is moved by hand,// so refill is tested without sleeping.private readonly FakeTimeProvider _time = new();
private static readonly BucketFactory Tokens = (l, now) => new TokenBucket(l, now);private static readonly BucketFactory Windows = (l, now) => new FixedWindow(l, now);
private static Rule PerUser(int permits, BucketFactory algorithm) => new("user", r => r.UserId, new Limit(permits, TimeSpan.FromSeconds(1)), algorithm);
private static Request From(string user, string ip = "10.0.0.1") => new(user, ip, "/api");
private static int AllowedCount(RateLimiter rl, Request r, int n) => Enumerable.Range(0, n).Count(_ => rl.Check(r).Allowed);
[Fact]public void FixedWindowLetsTwiceTheLimitThroughAtTheEdge(){ _time.SetUtcNow(new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero).AddMilliseconds(999)); // 1 ms before the window ends var rl = new RateLimiter([PerUser(10, Windows)], _time); int first = AllowedCount(rl, From("ana"), 10); _time.Advance(TimeSpan.FromMilliseconds(1)); // the next window // 20 in 1 ms under "10 per second": the flaw the token bucket fixes. Assert.Equal(20, first + AllowedCount(rl, From("ana"), 10));}
[Fact]public void TokenBucketRefillsAtTheLimitRateAndSaysWhenToRetry(){ var rl = new RateLimiter([PerUser(10, Tokens)], _time); Assert.Equal(10, AllowedCount(rl, From("ana"), 10)); // the burst Assert.Equal(new Decision(false, TimeSpan.FromMilliseconds(100)), rl.Check(From("ana"))); // one token every 100 ms _time.Advance(TimeSpan.FromMilliseconds(100)); Assert.True(rl.Check(From("ana")).Allowed);}
[Fact]public void ARefusedRequestUsesUpNoOtherLimit(){ // 5 per second per user, and 1 per second per IP. Bo is refused by the IP rule... var perIp = new Rule("ip", r => r.Ip, new Limit(1, TimeSpan.FromSeconds(1)), Tokens); var rl = new RateLimiter([PerUser(5, Tokens), perIp], _time); rl.Check(From("ana")); Assert.Equal(0, AllowedCount(rl, From("bo"), 5)); // ...so his user bucket was refunded every time and is still full on new IPs. for (int i = 0; i < 5; i++) Assert.True(rl.Check(From("bo", $"10.0.0.{i + 2}")).Allowed);}
[Fact]public void IdleKeysAreForgottenAfterOneFullPeriod(){ var rl = new RateLimiter([PerUser(10, Tokens)], _time); for (int i = 0; i < 1_000; i++) rl.Check(From($"user-{i}")); // a scan: one request per user _time.Advance(TimeSpan.FromSeconds(1)); // one full period later rl.Sweep(_time.GetUtcNow().Ticks); Assert.Equal(0, rl.TrackedKeys);}
[Fact]public void ConcurrentRequestsNeverExceedTheLimit(){ // 100 permits, 1,000 requests across the thread pool at one frozen instant: exactly 100 pass. var rl = new RateLimiter([PerUser(100, Tokens)], _time); int allowed = 0; Parallel.For(0, 1_000, new ParallelOptions { MaxDegreeOfParallelism = 16 }, _ => { if (rl.Check(From("ana")).Allowed) Interlocked.Increment(ref allowed); }); Assert.Equal(100, allowed);}// testClock is moved by hand, so refill is tested without sleeping.type testClock struct{ t time.Time }
func (c *testClock) now() time.Time { return c.t }
func perUser(permits int, algorithm BucketFactory) Rule { return Rule{"user", func(r Request) string { return r.UserID }, Limit{permits, time.Second}, algorithm}}
func from(user string) Request { return Request{user, "10.0.0.1", "/api"} }
func allowedCount(rl *RateLimiter, r Request, n int) int { ok := 0 for range n { if rl.Check(r).Allowed { ok++ } } return ok}
func TestFixedWindowLetsTwiceTheLimitThroughAtTheEdge(t *testing.T) { clock := &testClock{time.Unix(0, 0).Add(999 * time.Millisecond)} // 1 ms before the window ends rl := NewRateLimiter([]Rule{perUser(10, NewFixedWindow)}, clock.now) first := allowedCount(rl, from("ana"), 10) clock.t = clock.t.Add(time.Millisecond) // the next window second := allowedCount(rl, from("ana"), 10) if first+second != 20 { // 20 in 1 ms under "10 per second": the flaw the token bucket fixes t.Fatalf("allowed %d, want 20", first+second) }}
func TestTokenBucketRefillsAtTheLimitRateAndSaysWhenToRetry(t *testing.T) { clock := &testClock{time.Unix(0, 0)} rl := NewRateLimiter([]Rule{perUser(10, NewTokenBucket)}, clock.now) if n := allowedCount(rl, from("ana"), 10); n != 10 { // the burst t.Fatalf("burst allowed %d", n) } if d := rl.Check(from("ana")); d.Allowed || d.RetryAfter != 100*time.Millisecond { t.Fatalf("got %+v, want a refusal with a 100ms retry", d) // one token every 100 ms } clock.t = clock.t.Add(100 * time.Millisecond) if !rl.Check(from("ana")).Allowed { t.Fatal("a token should have refilled") }}
func TestARefusedRequestUsesUpNoOtherLimit(t *testing.T) { // 5 per second per user, and 1 per second per IP. Bo is refused by the IP rule... clock := &testClock{time.Unix(0, 0)} perIP := Rule{"ip", func(r Request) string { return r.IP }, Limit{1, time.Second}, NewTokenBucket} rl := NewRateLimiter([]Rule{perUser(5, NewTokenBucket), perIP}, clock.now) rl.Check(Request{"ana", "10.0.0.1", "/"}) if n := allowedCount(rl, Request{"bo", "10.0.0.1", "/"}, 5); n != 0 { t.Fatalf("bo allowed %d on a used-up IP", n) } // ...so his user bucket was refunded every time and is still full on new IPs. for i := range 5 { if !rl.Check(Request{"bo", fmt.Sprintf("10.0.0.%d", i+2), "/"}).Allowed { t.Fatalf("request %d refused: the user bucket was charged for refusals", i) } }}
func TestIdleKeysAreForgottenAfterOneFullPeriod(t *testing.T) { clock := &testClock{time.Unix(0, 0)} rl := NewRateLimiter([]Rule{perUser(10, NewTokenBucket)}, clock.now) for i := range 1000 { rl.Check(from(fmt.Sprintf("user-%d", i))) // a scan: one request per user } clock.t = clock.t.Add(time.Second) // one full period later rl.Sweep(clock.now()) if n := rl.TrackedKeys(); n != 0 { t.Fatalf("%d keys still tracked", n) }}
// Run with: go test -race ./... The race detector flags any unguarded access.func TestConcurrentRequestsNeverExceedTheLimit(t *testing.T) { // 100 permits, 16 goroutines, 1,000 requests at one frozen instant: exactly 100 pass. clock := &testClock{time.Unix(0, 0)} rl := NewRateLimiter([]Rule{perUser(100, NewTokenBucket)}, clock.now) var allowed atomic.Int64 var wg sync.WaitGroup for g := range 16 { wg.Add(1) go func() { defer wg.Done() for i := g; i < 1000; i += 16 { if rl.Check(from("ana")).Allowed { allowed.Add(1) } } }() } wg.Wait() if n := allowed.Load(); n != 100 { t.Fatalf("allowed %d, want exactly 100", n) }}import { test } from 'node:test';import assert from 'node:assert/strict';
// A clock the test moves by hand, in milliseconds.let now = 0;const perUser = (permits: number, algorithm: BucketFactory): Rule => ({ name: 'user', keyOf: (r) => r.userId, limit: { permits, perMs: 1000 }, algorithm,});const from = (userId: string, ip = '10.0.0.1'): Request => ({ userId, ip, endpoint: '/api' });const allowedCount = (rl: RateLimiter, r: Request, n: number) => Array.from({ length: n }, () => rl.check(r).allowed).filter(Boolean).length;
test('fixed window lets twice the limit through at the edge', () => { now = 999; // 1 ms before the window ends const rl = new RateLimiter([perUser(10, fixedWindow)], () => now); const first = allowedCount(rl, from('ana'), 10); now = 1000; // the next window // 20 in 1 ms under "10 per second": the flaw the token bucket fixes. assert.equal(first + allowedCount(rl, from('ana'), 10), 20);});
test('token bucket refills at the limit rate and says when to retry', () => { now = 0; const rl = new RateLimiter([perUser(10, tokenBucket)], () => now); assert.equal(allowedCount(rl, from('ana'), 10), 10); // the burst assert.deepEqual(rl.check(from('ana')), { allowed: false, retryAfterMs: 100 }); // one token every 100 ms now += 100; assert.equal(rl.check(from('ana')).allowed, true);});
test('a refused request uses up no other limit', () => { // 5 per second per user, and 1 per second per IP. Bo is refused by the IP rule... now = 0; const perIp: Rule = { name: 'ip', keyOf: (r) => r.ip, limit: { permits: 1, perMs: 1000 }, algorithm: tokenBucket }; const rl = new RateLimiter([perUser(5, tokenBucket), perIp], () => now); rl.check(from('ana')); assert.equal(allowedCount(rl, from('bo'), 5), 0); // ...so his user bucket was refunded every time and is still full on new IPs. for (let i = 0; i < 5; i++) assert.equal(rl.check(from('bo', `10.0.0.${i + 2}`)).allowed, true);});
test('idle keys are forgotten after one full period', () => { now = 0; const rl = new RateLimiter([perUser(10, tokenBucket)], () => now); for (let i = 0; i < 1000; i++) rl.check(from(`user-${i}`)); // a scan: one request per user now += 1000; // one full period later rl.sweep(now); assert.equal(rl.trackedKeys(), 0);});Go deeperEvery edge case, and where it is handled2 min
| Case | What happens | Where |
|---|---|---|
| First request from a new client | A full bucket is created in one step | computeIfAbsent |
| Clock goes backwards | Refill ignores it; no tokens added or removed | refill check |
| Long idle, then a burst | Tokens capped at the bucket’s size, never more | refill |
| Refused by the second rule | The first rule’s token refunded | check |
| Refund after the window moved on | Fixed window: nothing to refund; the count stays at 0 | FixedWindow.refund |
| Refund would go over the cap | Capped at the bucket’s size | TokenBucket.refund |
| Limit with 0 tokens | Should be rejected up front (left out of the stage code) | Limit |
| Bucket swept while a request holds it | At most one extra token for that client | sweep |
| Redis down (many servers) | Fail open for most APIs; fail closed for login attempts | the caller’s policy |
Wrap up
Section titled “Wrap up”The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| Decision | We chose | We gave up |
|---|---|---|
| Algorithm | Token bucket by default, behind a Bucket interface |
A perfectly smooth rate: bursts up to the cap |
| Refill | Lazy, from the time passed, when used | Nothing; idle buckets cost no CPU |
| Several rules | Take from each, refund all on any refusal | A microsecond where a token is out for nothing |
| Bucket identity | Keyed by rule and key together | A little more memory for repeated keys |
| Memory | Sweep buckets idle for one full period | A short pause; no hard cap during a flood |
| Thread safety | Thread-safe map, lock per bucket, one lock at a time | A very busy client queues on its one lock |
| Many servers | Buckets in Redis with a Lua script | A round trip per check and a new way to fail |
Go deeperGenuinely different designs, and when to pick them2 min
Leaky bucket as a queue. Requests go into a queue with a size limit, and a worker releases them at a fixed rate. Nothing is refused until the queue is full, and the backend sees a perfectly smooth rate. It suits calls you make to a partner’s API with a strict limit. It doesn’t suit protecting your own API, because a waiting request holds a connection open.
GCRA (generic cell rate algorithm). A token bucket stored as a single time: when the next request is allowed. A check compares now with that time and moves it forward. One number per client and one atomic update, which is why some Redis modules and API gateways use it.
Limiting requests in progress, not per second. Limit how many requests a client has running at once, with a counter per client. It protects a backend whose cost is time spent, not requests started, and it adjusts on its own when the backend slows down.
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 0 to 3. The key, bursts, one process or many, how many clients.
- 3 to 5. The promises. Name “never more than the limit, at an edge or under threads” as the hard one.
- 5 to 25. The fixed window and its edge, the algorithm table and the token bucket (code it fully), several rules with refunds, then the sweep.
- 25 to 27. Walk through the full diagram: rules say what, buckets say how.
- 27 to 36. The two races, a lock per bucket, one lock at a time, then many servers.
- 36 to 45. One extension, usually tiers or cost per request, then your own questions.
If time is short, describe the sweep in a sentence. Always code the token bucket completely. It’s the part that gets checked line by line.
Follow-up questions
Section titled “Follow-up questions”Premium users get 100 per second. Some endpoints cost 10 tokens each.
Whether each change lands in one place: the rule picks the limit, the bucket takes n tokens. And whether you spot the new edge case: a request that costs more than the bucket can ever hold.
Most extensions fit into one place:
| Extension | What changes | Touches the limiter? |
|---|---|---|
| Sliding window counter | A new Bucket |
No |
| Limits per tier | Rule.limitFor(request) instead of one Limit |
One line |
| Requests costing n tokens | tryTake(now, n); refuse outright when n is above the cap |
One argument |
| Different limits per endpoint | A rule whose key includes the endpoint | No |
| Metrics (refusals per rule) | A counter where check refuses, labelled by rule name |
One line |
| Many servers | Buckets in Redis behind the same Bucket idea |
The storage |
Go deeperAdding tiers and request costs, step by step2 min
Start with tiers. Premium users get 100 per second, everyone else 10. Where should that decision live? Not in the bucket: a bucket only knows its cap and its rate. And not in the limiter, which only walks the rules. It belongs in the rule, because the rule already reads the request.
- The rule picks the limit.
Rulegets a methodlimitFor(request). For the user rule, it looks at the user’s tier and returns 10 or 100. - The bucket is created with that limit. When a user is seen for the first time, the factory builds their bucket with their own limit. After that, nothing else changes.
- What if a user upgrades? Their old bucket still has the old limit. The simplest answer: put
the tier in the bucket’s key, like
ana:premium. The upgrade creates a new bucket, and the old one is swept when it goes idle.
Now costs. A search costs 10 tokens, a simple read costs 1.
- Take n tokens.
tryTake(now)becomestryTake(now, n). The refill is the same. The check becomes “do I have at least n?”, and the retry time is “how long until I have n?”. - Refund n tokens. If a later rule refuses, the refund gives back the same n.
- Refuse what can never pass. If a request costs 20 tokens and the bucket holds at most 10, waiting will never help. Refuse it with a clear error, not a retry time, or the client will retry forever.
Go deeperThree more follow-up questions2 min
Interviewer asksShould a refused request return 429 or 503?
429 Too Many Requests, with a Retry-After header from the decision: the client caused it and can
fix it by waiting. 503 means the server is overloaded whoever is calling, which is load shedding,
a different tool. Many APIs also return RateLimit-Remaining and RateLimit-Reset on every
response, so clients can slow down before they’re refused.
Interviewer asksA client retries immediately every time it's refused. What happens?
Each retry is refused and takes no token. But it still costs a check and a network round trip. If it’s abusive, add a penalty rule: refusals themselves count against a slower, longer limit, and a client over that is blocked for minutes.
Interviewer asksHow do you limit by user before you know who the user is?
In many systems, login is checked after the limiter. So the first rule keys by IP, which is known as soon as the request arrives, and it protects the login endpoint itself. Once the user is known, the per-user rules apply. Login attempts get their own strict rule, keyed by the username being tried, to slow down password guessing from many IPs.
Further reading
Section titled “Further reading”- Strategy pattern, Factory pattern
- Thread safety basics, Rate limiting
- Design a distributed rate limiter, the system design version of this question
- Design an LRU cache, which also keeps per-key state that must be evicted
- Cloudflare: How we built rate limiting capable of scaling to millions of domains, the sliding window counter in production
- Stripe: Scaling your API with rate limiters, four kinds of limiters and why each exists
- Redis: Scripting with Lua, why a script runs as one step
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.