The idea
What it is
“Design a rate limiter.” Most candidates start drawing classes. Do not. Write the interface first, in one line, and then say the sentence that wins you the next forty minutes: “the interface is trivial — the whole design is which algorithm goes behind it.”
interface RateLimiter {
boolean allow(String key); // "user-42" -> true (serve it) or false (429)
}That is it. A caller asks “may this request through?” and gets a yes or a no. Everything the interviewer is grading is on the other side of that one method: how you count, what you remember per key, what happens when the clock rolls over a window edge, and what two threads do when they both look at the last token.
allow().The whole system in three sentences
A key identifies who is being limited — a user, an API token, an IP. The limiter keeps one small state object per key and, on every call, looks at the current time and decides yes or no. The five algorithms differ only in what that state object holds and how the arithmetic uses the clock.
What is actually being graded
- Do you know the boundary bug? Fixed window lets 2× the limit through at a window edge. If you cannot draw that on a timeline, nothing else you say counts.
- Can you name and compare all five algorithms? Memory per key, accuracy, bursts, smoothing. The interviewer is listening for the comparison, not for one implementation.
- Is the refill lazy? A token bucket needs no background thread. Compute what accrued since
lastRefillTimeon the way in. Spawning a timer is an instant mark against you. - Is the clock injected?
allow(key, now)or aClockyou can fake. Otherwise every test has to sleep for a real minute, and you will not write one in the round. - Is it correct with two threads on the same key? Both read 1 token left, both take one. Same check-then-act race as Coffee Machine — and the fix is a lock on the bucket, not on the limiter.
Mechanics
How it works
Step 1 · Clarify — 4 minutes
- What is the key? — per user, per API token, per IP? Say “the caller hands me a key; I do not care what it means.” That one sentence removes auth from the round.
- What is the limit? — “100 requests per minute” is the shape. Confirm it is
count per window, both configurable, both injected. - Do bursts matter? — the question that picks your algorithm. If a client may fire 20 at once and then go quiet, you want a token bucket. If downstream needs a steady drip, you want a leaky bucket.
- Reject or wait? — say reject, and return 429. Blocking the caller turns a limiter into a queue and changes the whole design.
- One process or many? — assume one for the build, and raise distributed yourself at minute 50. Building it costs you the algorithms.
Do not spend ten minutes on the API
There is nothing to design in boolean allow(String key). Candidates who draw a RateLimitRequest class, a RateLimitResponse class and a builder for both have spent a quarter of the round on the one part nobody is grading. Write the interface in 30 seconds, then spend your time on the timeline below.
Step 2 · The boundary bug — draw this before you write any code
Limit: 5 requests per minute. A client sends five requests at 12:00:59 and five more at 12:01:01. Ask a fixed-window counter about either minute and it answers honestly: five, exactly at the limit, both legal. And yet ten requests went through in two seconds — double the limit, inside a thirtieth of the window.
This is the one thing the interviewer is checking
Every candidate can write a counter and an if-statement. The dividing line is whether you volunteer the boundary case before being asked. Draw the timeline, say “this is why fixed window is not enough”, and then earn the rest of the round by fixing it. If you wait to be asked, you have already lost the point.
Step 3 · Five algorithms, one interface
Each of the five fixes something the one before it got wrong. Walk them in this order out loud — it is a story, and the interviewer can follow it without you drawing a single class.
1 · Fixed window counter
State per key: a window start and a count. When a request arrives, work out which window this instant belongs to; if it is a new one, reset the count to zero. Then compare against the limit. Two numbers, O(1) memory, about six lines — and the boundary bug you just drew.
2 · Sliding window log
Fix the boundary by refusing to forget. Keep the timestamp of every request. On each call, drop everything older than now − window and count what remains. It is exactly correct — there is no boundary because there are no windows, only a moving cut-off. And it is disqualified at scale for one reason: memory grows with the request rate.
3 · Sliding window counter
The practical compromise, and the one Cloudflare actually ships. Keep two counters — this window and the previous one — and weight the previous one by how much of it still falls inside the moving window. If you are 30% into the current window, then 70% of the previous window is still relevant:
elapsed = (now - windowStart) / windowMs // 0.0 .. 1.0
estimate = prevCount * (1 - elapsed) + currCount
if (estimate < limit) { currCount++; return true; }
return false;4 · Token bucket
Stop counting requests and start counting permission. A bucket holds up to capacity tokens and gains them at rate per second. A request takes one token, or is refused. Overflow spills — the bucket never holds more than its capacity. This is what most real systems use, including Guava's RateLimiter and Go's golang.org/x/time/rate.
The property that makes it popular: it allows a burst up to the bucket size. A client that was quiet for a minute has a full bucket and can fire all of it at once — which is usually exactly what you want, because idle clients are not the ones you are protecting against.
tokens and lastRefill, and one min(). Anyone who adds a background thread here has misunderstood the algorithm — and interviewers ask about it precisely because so many candidates do.Lazy refill, said properly
“I do not tick tokens in. On every call I compute how many accrued since lastRefillTime and cap at capacity.” That removes a thread, removes a scheduler, and makes the limiter a pure function of state and time — which is what lets you test it in a millisecond instead of a minute.
5 · Leaky bucket
A fixed-size queue that drains at a constant rate. A request joins the queue if there is room, and is dropped if there is not. What leaves the bucket leaves evenly — one every 200ms, forever — no matter how spiky the arrivals were.
The seam that makes all five interchangeable
Five classes, one interface, chosen at construction. That is Strategy in its plainest form, and it is what makes “show me a different algorithm” a one-line change at the call site instead of a rewrite. It is also Open/Closed (OCP) doing real work: a sixth algorithm is a new file, not an edit. See also Program to interfaces.
Step 4 · Inject the clock, or you will not test anything
Every algorithm above is a function of state and time. If you read System.currentTimeMillis() inside allow(), you have welded the clock to the logic, and the only way to test a one-minute window is to sleep for a minute. Nobody does that, so nobody tests it, so the boundary bug ships.
interface Clock { long millis(); }
class SystemClock implements Clock {
public long millis() { return System.currentTimeMillis(); }
}
class FakeClock implements Clock { // the entire test infrastructure
private long now;
public long millis() { return now; }
public void set(long ms) { now = ms; }
public void advance(long m) { now += m; }
}
// the boundary bug, tested in microseconds instead of two minutes:
FakeClock clock = new FakeClock();
RateLimiter limiter = new FixedWindow(clock, 5, 60_000);
clock.set(59_000); for (int i = 0; i < 5; i++) limiter.allow("user-42"); // 5 allowed
clock.set(61_000); for (int i = 0; i < 5; i++) limiter.allow("user-42"); // 5 MORE allowedYou have seen this exact move before
It is the same decision as unpark(id, exitAt) in Parking Lot: pass time in rather than reading it. Anything that reads the wall clock, a random number generator or the filesystem directly is untestable by construction. Say the sentence “I inject the clock so the tests do not sleep” and it will land every time.
Step 5 · One limiter per key — and the leak nobody mentions
There is not one bucket, there is one bucket per key. That is a ConcurrentHashMap<String, Bucket> and a computeIfAbsent. Which raises a question the interviewer will be pleased you asked yourself: what removes entries from that map?
private final ConcurrentHashMap<String, Bucket> byKey = new ConcurrentHashMap<>();
public boolean allow(String key) {
Bucket bucket = byKey.computeIfAbsent(key, k -> new Bucket(capacity));
synchronized (bucket) { // lock the BUCKET, not the map
bucket.lastSeen = clock.millis();
return bucket.tryTake(clock.millis());
}
}
// nothing above ever removes a key. One request from one IP creates an entry
// that lives forever. Sweep idle keys, or bound the map with an LRU cache.
public void sweepIdle(long idleMs) {
long cutoff = clock.millis() - idleMs;
byKey.entrySet().removeIf(e -> e.getValue().lastSeen < cutoff);
}Raise the leak before they do
A limiter keyed by IP address on a public API accumulates an entry per unique IP, forever. It is a slow, boring, production-grade memory leak. Three acceptable answers: a periodic sweep of idle keys, a bounded LRU cache, or a store with TTL built in (which is one reason Redis is the standard distributed answer — the key expires by itself).
The class diagram
The race — two threads, one last token
allow() reads the token count, decides, and writes it back. Two threads on the same key can both read 1, both decide yes, and both subtract — leaving −1 tokens and one more request through than the limit allows. This is the same check-then-act shape as the shared milk tank in Coffee Machine.
synchronized (bucket) costs nothing and is correct; synchronized on the whole limiter is also correct and serialises every key in the system. Background: Locks, Mutex, Semaphore and Atomic operations & CAS.The lock-free alternative, if they push
A token bucket can be done with a single AtomicLong holding a packed (tokens, lastRefill) state and a compareAndSet retry loop: read the state, compute the new one, swap it, retry if someone beat you. Mention it as the version you would write if this were on a hot path; do not build it in 60 minutes. Atomic operations & CAS has the loop.
What you actually return
A bare false is a weak answer at the end. Real limiters return 429 Too Many Requests with a Retry-After header saying when to come back, and X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset so a well-behaved client can pace itself without being refused at all.
So the honest interface is a small result object rather than a boolean — Decision(allowed, remaining, retryAfterMs). Build the boolean version first because it is the one you can finish, and say this in two sentences at minute 55.
The follow-ups
- “Free users get 10/min, paid users get 1000/min.” → a
LimitPolicylooked up by tier, returning(limit, window). Configuration, not code, and no new algorithm. - “Different limits per endpoint.” → make the key a compound one:
user-42:POST /charges. One line, and the same map does the work. - “You have four servers.” → each has its own in-process map, so the effective limit is 4× what you configured. Say this yourself before they say it.
- “So how do you fix it?” → move the counter to Redis:
INCRthe key,EXPIREit on first creation, compare to the limit. Both in one Lua script so it is atomic. The cost is a network round trip on every request. - “What if Redis is down?” → fail open (serve the traffic, drop the limiting) for a general API, fail closed for something protecting a payment path or a hard third-party quota. Say which you would pick and why — that is the whole question.
- “Can it be approximate to save the round trip?” → yes: give each server
limit / serverCount, or sync counts every few seconds. You trade exactness for latency, and for most APIs that is the right trade.
The 60 minutes
FixedWindow — the whole story starts there.How this round is lost
- One hardcoded algorithm, no interface. “Show me the token bucket instead” becomes a rewrite, and the comparison conversation never happens.
- Not knowing the boundary case. If the interviewer has to point out that ten requests got through, you have failed the question the problem exists to ask.
- A background refill thread. A
ScheduledExecutorServiceticking tokens into every bucket every second — for a million keys. It is wrong at every scale, and lazy refill is two lines. System.currentTimeMillis()inline. No injected clock means no tests, and you will not have time to write one that sleeps for a minute.synchronizedon the whole limiter. Correct, and it makes every key in the system queue behind every other. Lock the bucket.- Silence about the map. A per-key map with nothing evicting from it is a memory leak; raising it unprompted is one of the cheapest points on offer.
Interactive prototype
See it. Build it. Break it.
A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.
About this simulation
One limiter, five algorithms, a live clock. Pick 🪟 Fixed and press 💥 Boundary burst — it jumps the clock to the last second of a window, fires 5 requests, steps 2 seconds into the next window, fires 5 more. All ten go green and the red over-limit stat jumps to 5. Now press 📜 Sliding log and press 💥 Boundary burst again: the second batch is entirely red and over-limit stays at 0. Then try 🪣 Token bucket with 🔥 Burst of 10 and ⏩ +5s — tokens reappear the instant you move the clock, with no timer running anywhere.
Hands-on
Try these yourself
Open the prototype above, predict what happens, then verify.
Send a few requests and watch the state panel
Leave the default 🪟 Fixed, limit 5 / 10s, and press ▶ Send request six times. Five dots go green on the ruler, the sixth goes red. Watch the state panel on the left: count climbs 1…5 and then the sixth call finds count = 5 and refuses. Read the .callline — it shows the real call, limiter.allow("user-42", t=…) → true.
Fire the boundary bug under fixed window
Press ↺ Reset, stay on 🪟 Fixed, and press 💥 Boundary burst. It jumps the clock to the last second of a window, fires 5, steps 2 seconds past the edge, fires 5 more. All ten are green. The over-limit stat lights up red: ten requests inside one window-length, when the limit is five.
Now do exactly the same thing on a correct algorithm
Press 📜 Sliding log (which clears the ruler) and press 💥 Boundary burst again. Same clock, same ten requests — and the second batch is all red, because the log still remembers the five stamps from two seconds ago. Then try ⚖️ Sliding counter: one gets through, four are refused. That single extra one is the approximation, and it is why the counter is nearly exact rather than exact.
Move the clock and watch tokens appear from nothing
Switch to 🪣 Token bucket and press 🔥 Burst of 10 — five go through instantly (the bucket was full, that is the burst) and five are refused. Now press ⏩ +5s and look at the bucket before pressing anything else: dots have reappeared. Nothing was running. Advancing the clock is what refilled it, because the refill is computed on arrival, not scheduled.
Watch a leaky bucket smooth the same burst
Press 💧 Leaky bucket and press 🔥 Burst of 10. Five are accepted again — but the panel now shows a drain schedule: those five leave one at a time, evenly spaced. Press ⏩ +5s twice and watch the queue empty at a constant rate. That is the whole difference: the token bucket let a spike through, the leaky bucket let a spike in and released a metronome.
Prove the limits are per key
With any algorithm, hammer ▶ Send request on user-42 until it turns red, then click the user-7 chip and press ▶ Send request. It is allowed immediately. The per-key counters in the right pane tell the story: one key is exhausted, the other has not started. That is computeIfAbsent doing its job — and it is also the map that grows forever if nothing sweeps it.
Build it from memory
Blank file, in this order: interface RateLimiter { boolean allow(String key); } → interface Clock with a FakeClock → FixedWindow with start and count → a test that sets the clock to 59s, fires 5, sets it to 61s, fires 5, and asserts that 10 got through → TokenBucket with lazy refill → the same test, which now passes. If your test needs Thread.sleep, the clock is not injected.
In practice
When to use it — and what trips people up
The shape you just learned
Strip the requests away and this is a budget that refills with time. You hold a small amount of state per subject, you derive the current allowance from the clock rather than from a timer, and you make check-and-take one indivisible step. Once you see that shape you find it everywhere.
- Retry and backoff budgets — a client that may retry N times per minute is a token bucket with the sign flipped.
- Circuit breakers — the same per-key state machine over a rolling window of failures rather than requests.
- Login throttling — five attempts per account per fifteen minutes, and the boundary bug matters more here, because the limit is a security control.
- Connection and thread admission — take a permit or be refused. That is a token bucket with no refill, which is Object Pool.
- Billing quotas and free tiers — the window is a month and the key is an account, but the arithmetic is identical.
- Traffic shaping in networks — leaky bucket is literally where the name comes from; it is how routers have smoothed packet flow for decades.
The two-sentence version to say out loud
“The interface is one method; the design is choosing the algorithm. Fixed window is simplest but leaks 2× at a boundary, a log is exact but its memory grows with traffic, a sliding counter is the O(1) compromise, and a token bucket with lazy refill is what I would ship — it allows a controlled burst, needs no background thread, and is trivially testable if I inject the clock.” That is 25 seconds and it covers most of the round.
Where this design stops working
- Across processes. An in-process map means the limit multiplies by the number of servers. The moment there is a second instance, the counter has to move to a shared store with an atomic increment.
- When the limit protects something that must never be exceeded. A third-party API with a hard 100/second cap and a financial penalty needs a leaky bucket or a distributed counter, not an approximation and not a burst.
- When clients should be slowed rather than refused. Refusing is right for a public API. A trusted internal caller is usually better off waiting — but then you have built a queue, and back-pressure, timeouts and fairness all become your problem.
- When the key space is unbounded and hostile. Limiting by IP invites an attacker to create millions of map entries. Bound the map before you need to.
If you only remember one thing
Derive from the clock; never schedule. The window resets because now / windowMs changed, and tokens reappear because time passed — not because a thread woke up. That one habit removes the background thread, removes the scheduler, and makes the whole limiter a pure function of state and time that you can test in a microsecond.
What it gives you
- A one-method interface means the algorithm is a construction-time choice, so “use a token bucket instead” is a one-line change at the call site rather than a rewrite.
- Lazy refill removes every timer and scheduler from the design — the limiter is a pure function of stored state and the current time.
- An injected clock makes a 60-second window testable in microseconds, which is the only reason the boundary case ever gets a test written for it.
- One small state object per key keeps memory at a couple of numbers per client for four of the five algorithms, so a million keys is still tens of megabytes.
- Locking the individual bucket rather than the limiter keeps unrelated keys completely independent, so one hot client never slows anyone else down.
Common mistakes
- Fixed window is the simplest to explain and the one most often shipped, and it silently permits double the configured limit across every window boundary.
- The sliding window log is the only exactly correct option and its memory grows with the request rate, which rules it out precisely for the clients you most want to limit.
- The sliding window counter is an estimate that assumes the previous window's traffic was evenly spread, so a spiky client can be refused slightly early or let through slightly late.
- A token bucket permits a burst up to its capacity by design, so the peak load your service sees is capacity plus the steady rate — capacity is a real capacity-planning decision, not a tuning knob.
- The per-key map grows forever unless something sweeps idle keys, and with an attacker-controlled key space such as IP addresses that becomes a denial-of-service vector rather than a slow leak.
Reference
Code & further reading
A minimal reference implementation and pointers worth bookmarking.
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Function;
/** The entire public surface. Two lines. Everything hard is behind it. */
interface RateLimiter {
boolean allow(String key);
}
/** Inject the clock, or a test for a 60-second window has to sleep for 60 seconds. */
interface Clock { long millis(); }
final class SystemClock implements Clock {
public long millis() { return System.currentTimeMillis(); }
}
final class FakeClock implements Clock {
private long now;
FakeClock(long start) { this.now = start; }
public long millis() { return now; }
void set(long ms) { now = ms; }
void advance(long ms) { now += ms; }
}
/** Per-key state. lastSeen exists only so idle keys can be swept. */
abstract class KeyState { long lastSeen; }
/**
* Everything the five algorithms share: one small state object per key, an
* injected clock, a limit, a window, and a lock around ONE bucket.
*/
abstract class KeyedLimiter<S extends KeyState> implements RateLimiter {
protected final ConcurrentHashMap<String, S> byKey = new ConcurrentHashMap<>();
protected final Clock clock;
protected final int limit;
protected final long windowMs;
KeyedLimiter(Clock clock, int limit, long windowMs) {
this.clock = clock; this.limit = limit; this.windowMs = windowMs;
}
protected abstract S newState();
protected abstract boolean tryTake(S state, long now);
@Override public boolean allow(String key) {
S state = byKey.computeIfAbsent(key, k -> newState()); // one bucket per key
synchronized (state) { // lock the BUCKET, not the map
long now = clock.millis();
state.lastSeen = now;
return tryTake(state, now); // refill + check + take, atomically
}
}
/** Without this the map grows forever — one entry per key ever seen. */
public int sweepIdle(long idleMs) {
long cutoff = clock.millis() - idleMs;
int before = byKey.size();
byKey.entrySet().removeIf(e -> e.getValue().lastSeen < cutoff);
return before - byKey.size();
}
public int keyCount() { return byKey.size(); }
}
/** 1 · FIXED WINDOW — two numbers per key. Lets 2x the limit through at a boundary. */
final class FixedWindow extends KeyedLimiter<FixedWindow.W> {
static final class W extends KeyState { long start = Long.MIN_VALUE; int count; }
FixedWindow(Clock c, int limit, long windowMs) { super(c, limit, windowMs); }
protected W newState() { return new W(); }
protected boolean tryTake(W w, long now) {
long windowStart = now - Math.floorMod(now, windowMs); // derived from the clock, not scheduled
if (w.start != windowStart) { w.start = windowStart; w.count = 0; } // the reset instant
if (w.count < limit) { w.count++; return true; }
return false;
}
}
/** 2 · SLIDING WINDOW LOG — exactly correct, and memory grows with the request rate. */
final class SlidingLog extends KeyedLimiter<SlidingLog.L> {
static final class L extends KeyState { final ArrayDeque<Long> stamps = new ArrayDeque<>(); }
SlidingLog(Clock c, int limit, long windowMs) { super(c, limit, windowMs); }
protected L newState() { return new L(); }
protected boolean tryTake(L l, long now) {
long cutoff = now - windowMs;
while (!l.stamps.isEmpty() && l.stamps.peekFirst() <= cutoff) l.stamps.pollFirst(); // lazy eviction
if (l.stamps.size() < limit) { l.stamps.addLast(now); return true; }
return false;
}
}
/** 3 · SLIDING WINDOW COUNTER — three numbers per key, approximately right. */
final class SlidingCounter extends KeyedLimiter<SlidingCounter.C> {
static final class C extends KeyState { long start = Long.MIN_VALUE; int curr, prev; }
SlidingCounter(Clock c, int limit, long windowMs) { super(c, limit, windowMs); }
protected C newState() { return new C(); }
protected boolean tryTake(C c, long now) {
long windowStart = now - Math.floorMod(now, windowMs);
if (windowStart != c.start) {
// one window on -> the old current becomes the previous; a longer gap -> nothing carries over
c.prev = (c.start != Long.MIN_VALUE && windowStart - c.start == windowMs) ? c.curr : 0;
c.curr = 0;
c.start = windowStart;
}
double elapsed = (now - windowStart) / (double) windowMs; // 0.0 .. 1.0
double estimate = c.prev * (1.0 - elapsed) + c.curr; // the weighted share
if (estimate < limit) { c.curr++; return true; }
return false;
}
}
/** 4 · TOKEN BUCKET — lazy refill, allows a burst up to capacity. The usual default. */
final class TokenBucket extends KeyedLimiter<TokenBucket.B> {
private final double capacity;
private final double ratePerMs;
TokenBucket(Clock c, int limit, long windowMs) {
super(c, limit, windowMs);
this.capacity = limit; // burst size; a real system tunes this separately
this.ratePerMs = limit / (double) windowMs; // 5 per 60s -> 0.0000833 tokens per ms
}
static final class B extends KeyState { double tokens = -1; long lastRefill; }
protected B newState() { return new B(); }
protected boolean tryTake(B b, long now) {
if (b.tokens < 0) { b.tokens = capacity; b.lastRefill = now; } // a new key starts full
b.tokens = Math.min(capacity, b.tokens + (now - b.lastRefill) * ratePerMs); // LAZY refill
b.lastRefill = now; // no timer anywhere
if (b.tokens >= 1.0) { b.tokens -= 1.0; return true; }
return false;
}
double tokensFor(String key) { B b = byKey.get(key); return b == null ? capacity : b.tokens; }
}
/** 5 · LEAKY BUCKET — a fixed queue drained at a constant rate: smooths instead of bursting. */
final class LeakyBucket extends KeyedLimiter<LeakyBucket.Q> {
private final double capacity;
private final double leakPerMs;
LeakyBucket(Clock c, int limit, long windowMs) {
super(c, limit, windowMs);
this.capacity = limit;
this.leakPerMs = limit / (double) windowMs;
}
static final class Q extends KeyState { double level; long lastLeak = Long.MIN_VALUE; }
protected Q newState() { return new Q(); }
protected boolean tryTake(Q q, long now) {
if (q.lastLeak == Long.MIN_VALUE) q.lastLeak = now;
q.level = Math.max(0.0, q.level - (now - q.lastLeak) * leakPerMs); // drain by elapsed time
q.lastLeak = now;
if (q.level + 1.0 <= capacity) { q.level += 1.0; return true; } // joins the queue
return false; // overflow -> dropped
}
}
public class Main {
static int fire(RateLimiter limiter, String key, int n) {
int ok = 0;
for (int i = 0; i < n; i++) if (limiter.allow(key)) ok++;
return ok;
}
/** Five at 12:00:59, five at 12:01:01 — the whole problem, in six lines. */
static void boundary(String label, Function<Clock, RateLimiter> make) {
FakeClock clock = new FakeClock(0);
RateLimiter limiter = make.apply(clock);
clock.set(59_000); // 12:00:59 — last second of the window
int first = fire(limiter, "user-42", 5);
clock.set(61_000); // 12:01:01 — two seconds later
int second = fire(limiter, "user-42", 5);
System.out.printf(" %-18s %d at :59 + %d at 1:01 = %2d in 2 seconds%s%n",
label, first, second, first + second, first + second > 5 ? " <-- above the limit" : "");
}
public static void main(String[] args) throws Exception {
System.out.println("== the boundary · limit 5 per 60s ==");
boundary("fixed window", c -> new FixedWindow(c, 5, 60_000));
boundary("sliding log", c -> new SlidingLog(c, 5, 60_000));
boundary("sliding counter", c -> new SlidingCounter(c, 5, 60_000));
boundary("token bucket", c -> new TokenBucket(c, 5, 60_000));
boundary("leaky bucket", c -> new LeakyBucket(c, 5, 60_000));
System.out.println();
System.out.println("== lazy refill · tokens return because time passed, not because a thread ran ==");
FakeClock clock = new FakeClock(0);
TokenBucket bucket = new TokenBucket(clock, 5, 60_000);
System.out.printf(" t=0s allowed %d of 8 tokens left %.2f%n",
fire(bucket, "user-42", 8), bucket.tokensFor("user-42"));
clock.advance(30_000);
System.out.printf(" t=30s allowed %d of 8 tokens left %.2f%n",
fire(bucket, "user-42", 8), bucket.tokensFor("user-42"));
System.out.println();
System.out.println("== per key · and the map that grows forever ==");
FakeClock c2 = new FakeClock(0);
TokenBucket perKey = new TokenBucket(c2, 5, 60_000);
System.out.println(" user-42 hammered 20x -> allowed " + fire(perKey, "user-42", 20));
System.out.println(" user-7 first call -> " + perKey.allow("user-7"));
System.out.println(" keys held: " + perKey.keyCount());
c2.advance(10 * 60_000);
System.out.println(" swept " + perKey.sweepIdle(5 * 60_000) + " idle keys -> keys held: " + perKey.keyCount());
System.out.println();
System.out.println("== 8 threads, 1 key, limit 10 ==");
FakeClock c3 = new FakeClock(0);
RateLimiter shared = new TokenBucket(c3, 10, 60_000);
ExecutorService pool = Executors.newFixedThreadPool(8);
List<Future<Integer>> futures = new ArrayList<>();
for (int t = 0; t < 8; t++) futures.add(pool.submit(() -> fire(shared, "user-42", 5)));
int total = 0;
for (Future<Integer> f : futures) total += f.get();
pool.shutdown();
System.out.println(" 40 requests attempted -> " + total + " allowed, never more than the limit");
}
}
/* Expected output:
== the boundary · limit 5 per 60s ==
fixed window 5 at :59 + 5 at 1:01 = 10 in 2 seconds <-- above the limit
sliding log 5 at :59 + 0 at 1:01 = 5 in 2 seconds
sliding counter 5 at :59 + 1 at 1:01 = 6 in 2 seconds <-- above the limit
token bucket 5 at :59 + 0 at 1:01 = 5 in 2 seconds
leaky bucket 5 at :59 + 0 at 1:01 = 5 in 2 seconds
== lazy refill · tokens return because time passed, not because a thread ran ==
t=0s allowed 5 of 8 tokens left 0.00
t=30s allowed 2 of 8 tokens left 0.50
== per key · and the map that grows forever ==
user-42 hammered 20x -> allowed 5
user-7 first call -> true
keys held: 2
swept 2 idle keys -> keys held: 0
== 8 threads, 1 key, limit 10 ==
40 requests attempted -> 10 allowed, never more than the limit
The sliding counter's 6 is not a bug: it is the approximation. It weights the
previous window by the fraction still inside the moving window, so one extra
request slips through one second past the boundary instead of five.
*/References & further reading
7 sources- Articlestripe.com
Stripe — Scaling your API with rate limiters
How a payments API actually deploys several limiters at once: a request-rate limiter, a concurrency limiter and a fleet-usage load shedder. Read it for the reasoning about which requests to shed first.
- Articleblog.cloudflare.com
Cloudflare — How we built rate limiting capable of scaling to millions of domains
The sliding-window-counter approximation described from production, including how far off the estimate actually is (under 1% on their traffic).
- Docsredis.io
Redis INCR — the rate limiter pattern
The canonical distributed answer, written out as INCR plus EXPIRE, including the race in the naive version and why the fix is a single atomic script.
- Docspkg.go.dev
golang.org/x/time/rate — Go's token bucket limiter
A production token bucket in about 200 lines of readable Go, with lazy refill and a reservation API. Worth reading next to the Go sample above.
- Specdatatracker.ietf.org
RFC 6585 §4 — 429 Too Many Requests
Three paragraphs, and it is the actual specification for the status code plus the Retry-After guidance. Being able to cite it costs nothing and reads well.
- Articleen.wikipedia.org
Token bucket — Wikipedia
The formal definition alongside the leaky bucket article it links to; useful for getting the average-versus-constant-rate distinction phrased precisely.
- Book
System Design Interview vol. 1 — Alex Xu, chapter 4 “Design a rate limiter”
The chapter most interviewers have read. Covers the same five algorithms plus the distributed race and the header conventions, in interview language.
Knowledge check
Did it land?
Quick questions, answers revealed on submit. Sign in to save your best score.
question 01 / 08
Limit is 5 per minute. A fixed-window counter sees 5 requests at 12:00:59 and 5 more at 12:01:01. What happens?
question 02 / 08
A sliding window log is exactly correct. Why is it usually the wrong choice in production?
question 03 / 08
A sliding window counter is 30% into the current window. The previous window counted 8, the current one counts 1, and the limit is 8. What does it estimate, and what does it do?
question 04 / 08
In one line, what is the difference between a token bucket and a leaky bucket?
question 05 / 08
How should a token bucket refill?
question 06 / 08
Two threads call allow() for the same key when one token remains. What must the design guarantee, and how?
question 07 / 08
The limiter keeps a ConcurrentHashMap from key to bucket, populated by computeIfAbsent. What is the problem nobody mentions?
question 08 / 08
You deploy the same in-process limiter to four API servers behind a load balancer, configured at 100 requests per minute. What does a client actually get?
0/8 answered