Intermediate30 min readMachine Coding Practicelive prototype

Rate Limiter

The interface is two lines: boolean allow(String key). That is the easy part, and you should say so out loud. The whole round is about which of five algorithms sits behind it — and about one bug, at the seam between two windows, that lets double the limit through in two seconds.

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.”

the entire public surface
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.

Client → the KEY client · user-42 client · user-7 client · user-99 RateLimiter «interface» — 1 method allow(key, now) one of five strategies: · fixed window · sliding window log · sliding window counter · token bucket · leaky bucket Map<key, Bucket> one small state object per key true API handler 200 OK · the real work false 429 Too Many Requests Retry-After: 12 X-RateLimit-Remaining: 0 the box has one method and no interesting API — every hard question is about what is inside it
Look at how small the interface is compared to the box it hides. That asymmetry is the problem statement. Everything you will be asked about lives behind 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

  1. 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.
  2. 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.
  3. Is the refill lazy? A token bucket needs no background thread. Compute what accrued since lastRefillTime on the way in. Spawning a timer is an instant mark against you.
  4. Is the clock injected? allow(key, now) or a Clock you can fake. Otherwise every test has to sleep for a real minute, and you will not write one in the round.
  5. 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.
✓ IN SCOPE — build these in 60 minutes allow(key) → boolean a limit per key, not one global count the algorithm behind an interface an injected Clock (so it is testable) thread safety for one key limit and window as configuration every one of these is 10 lines or fewer the value is in the choices, not the volume ✗ OUT OF SCOPE — say it in one sentence each auth — the key arrives already resolved the HTTP layer — a filter calls allow() persistence — limits are short-lived distributed counters — mention Redis, do not build it tiers and per-endpoint limits — config naming these OUT is worth marks building them costs you the algorithms, which is the only thing being graded
The trap in this problem is breadth. Rate limiting touches HTTP, auth, config and Redis, and every one of those is a rabbit hole. Say the words, draw the line, go back to the algorithms.

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.

limit = 5 requests per minute · fixed window counter window boundary — counter resets to 0 10 requests in 2 seconds quiet quiet 12:00:00 12:01:00 12:02:00 window 12:00 · count = 5 / 5 · legal ✓ window 12:01 · count = 5 / 5 · legal ✓ both windows are individually legal — and the client still got 2× the limit, in 2 seconds the counter has no memory of the window it just left; that is the entire flaw
Trace the counter, not the dots. At 12:00:59 it reads 5. At 12:01:00 it becomes 0 — and it forgets that five requests happened one second ago. Press 💥 Boundary burst in the prototype under 🪟 Fixed to watch exactly this.

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.

state per key — two numbers, that is all long windowStart int count 16 bytes per key, forever windowStart = now − (now % windowMs) if (windowStart != state.start) count = 0 the reset is derived from the clock — no timer, no thread limit 5 — what a client sees across one boundary 1 2 3 4 5 count → 0 1 2 3 4 5 if these two batches sit either side of the line, that is 10 in a moment ✓ smallest state, simplest code, and what almost every quota counter starts as ✗ up to 2× the limit across any boundary — disqualifying if the limit is a safety limit
Notice there is no timer resetting the counter. The window is derived from the clock by integer division, which is the same trick the token bucket uses for refill. Deriving beats scheduling every time.

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.

one key · a queue of timestamps, oldest on the left cut-off = now − window 12:00:02 12:00:07 12:00:11 evicted — older than the cut-off, they no longer count 12:00:44 12:00:51 12:00:58 12:00:59 12:01:01 inside the window → count = 5 → at the limit → the next one is refused while (head <= now − window) pop(); if (size < limit) { push(now); return true; } no windows at all — just a cut-off that moves with the clock, so there is no boundary to exploit ✓ exactly correct — the only algorithm here with no approximation ✗ one timestamp per request per key: 1000 req/s for a minute = 60,000 stored longs, for one client
The memory line is the one that matters. The log is perfect and unaffordable — say both halves, because the interviewer wants to hear you reject a correct answer for a cost reason.

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:

the whole algorithm
elapsed  = (now - windowStart) / windowMs        // 0.0 .. 1.0
estimate = prevCount * (1 - elapsed) + currCount

if (estimate < limit) { currCount++; return true; }
return false;
limit 5 · you are 30% into the current window previous window prevCount = 5 current window currCount = 2 now the real window we care about — the last 60 seconds, ending at now 70% of the previous window is still inside 30% elapsed estimate = prevCount × (1 − elapsed) + currCount = 5 × 0.70 + 2 = 5.50 5.50 ≥ 5 → refused — even though the current window only holds 2 ✓ three numbers per key, no boundary cliff, and typically under 1% off the exact answer ✗ it assumes the previous window was evenly spread — it was not, so it is an estimate, not a count
The weighting is the whole idea: the previous window fades out as you move through the current one. Read the arithmetic once and you can re-derive it in an interview from the words “weighted share of the previous window”.

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.

refill · rate = 5 tokens/sec computed on arrival, never scheduled capacity = 5 overflow spills — a full bucket stays full idle time is capped; you cannot bank a week of quiet request takes 1 token → allowed no tokens → refused, and nothing is queued tokens = min(capacity, tokens + (now − lastRefill) × rate) lastRefill = now two lines, on the way in — this is the whole refill mechanism ✓ two numbers per key, and a burst up to capacity is a feature, not a bug ✓ no timer, no scheduler, no thread — everything is derived from elapsed time ✗ a burst still reaches your service; capacity is a real safety decision
Two fields, 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.

10 requests arrive at once 5 overflow → dropped immediately queue size = 5 drains at a constant rate what leaves the bucket, on a timeline: t=0.0s t=0.2s t=0.4s t=0.6s t=0.8s perfectly even — the burst went in, a steady stream came out the one-line distinction people fumble: token bucket limits the AVERAGE and permits bursts leaky bucket enforces a CONSTANT rate and smooths them away
Read the two timelines together: the arrivals were a spike, the exits are a metronome. Use a leaky bucket when the thing downstream cannot absorb a spike — a payment gateway, a legacy database, an SMS provider with a hard per-second cap.
algorithm memory / key accuracy bursts? smooths? the hard part fixed window 2 numbers 2× at the edge accidentally no the boundary sliding window log 1 stamp / request exact no no memory at high rates sliding window counter 3 numbers ≈ exact (<1% off) barely no the weighted arithmetic token bucket 2 numbers exact on average yes, up to capacity no choosing the capacity leaky bucket 2 numbers exact rate no yes — only one added queueing delay default to token bucket. Reach for leaky bucket only when something downstream cannot take a spike. sliding counter is the right answer when you need a hard per-window cap with O(1) memory; the log almost never survives review.
This is the table to reproduce on the whiteboard. If you can draw these five rows from memory, you can answer “which would you use and why?” for any variation the interviewer invents.

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.

the whole trick
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 allowed

You 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?

the registry, and the sweep
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

RateLimiter «interface» + allow(key) : boolean KeyedLimiter «abstract» - byKey : ConcurrentMap<String, State> - clock : Clock - limit : int - windowMs + allow(key) · locks ONE bucket + sweepIdle(ms) · or the map leaks FixedWindow start, count 2× at the edge SlidingLog Deque<Long> memory grows SlidingCounter prev, curr, start O(1), ≈ exact TokenBucket tokens, lastRefill the usual default LeakyBucket level, lastLeak smooths output Clock «interface» + millis() : long SystemClock · FakeClock five classes, one interface — the caller never changes a line that row of five boxes IS the round every box below the line holds two or three numbers — the design is small, the decision behind it is not
Count the fields in the bottom row: two or three numbers each. This diagram exists to show the interviewer that you know the algorithms are interchangeable, not to show off class design. Notation: Class diagrams.
Client Filter RateLimiter Bucket Handler GET /v1/charges allow(“user-42”) clock.millis() → 61_000 byKey.computeIfAbsent(key) 🔒 lock this bucket only lazy refill · take 1 token true forward → 200 OK false → the Filter answers 429 itself: Retry-After, X-RateLimit-Remaining — the Handler is never reached
The limiter sits in a filter, before any business logic. That placement is the point: a rejected request must cost you almost nothing, so it must not touch the handler, the database or anything expensive. Notation: Sequence diagrams.

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.

⚠️ UNGUARDED — read, decide, write, with a gap between each thread A read tokens = 1 ✓ tokens −= 1 thread B read tokens = 1 ✓ tokens −= 1 tokens = −1 · 2 requests through where 1 was allowed · the limit is not a limit 🔒 GUARDED — refill, check and take inside one lock on that bucket thread A lock · refill · 1 ≥ 1 · take → 0 thread B lock · refill · 0 < 1 · REFUSED lock the BUCKET, not the limiter — user-7 never waits behind user-42
The fix is one word in the right place. 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 LimitPolicy looked 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: INCR the key, EXPIRE it 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.
✗ IN-PROCESS MAP × 4 SERVERS api-1 · user-42 → 100 api-2 · user-42 → 100 api-3 · user-42 → 100 api-4 · user-42 → 100 configured 100/min · actual 400/min nothing is wrong with the code; the state is just in four places ✓ ONE SHARED COUNTER api-1 api-2 api-3 api-4 Redis INCR key EXPIRE key ttl one Lua script → atomic · true 100/min · one network hop per request the trade is latency for exactness — and if Redis is unreachable you must have already decided: fail OPEN (serve everything, no limiting) or fail CLOSED (refuse everything). There is no third answer.
The number to say out loud is . It turns a vague “you would need something distributed” into a concrete failure, and it is the answer the interviewer is fishing for.

The 60 minutes

60 minutes · what to spend them on 0 10 25 40 50 60 0–5 clarify: key, limit, window, bursts, reject-not-wait 5–10 write the interface (30s) and DRAW THE BOUNDARY TIMELINE 10–25 fixed window + the boundary shown in a test with a FakeClock 25–40 token bucket with lazy refill · sliding counter if there is time 40–50 per-key map, lock the bucket, mention the sweep 50–60 main() that prints the boundary result · then the follow-ups if you are behind at minute 40, drop the sliding counter and keep the demo — a running program beats a fifth algorithm
The two orange blocks are where the marks are. If you find yourself at minute 25 still designing a request object, abandon it and go write 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 ScheduledExecutorService ticking 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.
  • synchronized on 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.

try 01

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.

try 02

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.

try 03

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.

try 04

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.

try 05

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.

try 06

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.

try 07

Build it from memory

Blank file, in this order: interface RateLimiter { boolean allow(String key); }interface Clock with a FakeClockFixedWindow 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 throughTokenBucket 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

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