On this page
Description
A rate limiter decides, for each request, whether a client is still within its allowance - "at most 100 requests per minute per user" - and it sits in front of nearly every public API. The design question is which algorithm counts usage (fixed window, sliding log, sliding window counter, token bucket), how it stays correct with many threads, and what changes when many servers share one limit. This skill builds the algorithms behind one interface and makes them thread-safe and testable.
"At most 100 requests per minute per user" sounds like a counter, and the first version is one:
Java
Map<String, Integer> countThisMinute = new HashMap<>(); // a background thread clears it every minute
boolean allow(String user) {
int n = countThisMinute.getOrDefault(user, 0);
if (n >= 100) return false;
countThisMinute.put(user, n + 1);
return true;
}
Python
count_this_minute: dict[str, int] = {} # a background thread clears it every minute
def allow(user: str) -> bool:
n = count_this_minute.get(user, 0)
if n >= 100:
return False
count_this_minute[user] = n + 1
return True
A client that sends 100 requests at 12:00:59 and another 100 at 12:01:00 gets 200 through in about a second - the window reset in between. Two threads can read n = 99 together and both allow request 100 and 101. The map grows with every IP address that ever called. The clearing thread, the real clock and the scheduler make it untestable. And the day the API runs on five servers, each server's counter allows 100, so a client gets 500.
We'll go from simple to more technical:
- The algorithms and what each allows: fixed window, sliding log, token bucket.
- A token bucket in code, with lazy refill and an injected clock.
- Concurrency: per-key atomicity without a global lock.
- Production concerns, bugs and the memory-accuracy tradeoff.
The approach
- Clarify. Limit per what - user, API key, IP, endpoint? How many requests over what period, and are short bursts acceptable? When limited: reject (HTTP 429) or queue and delay? One server or many?
- Model. A
RateLimiterinterface -boolean tryAcquire(String key)- with one class per algorithm. Per-key state in a concurrent map. Time from an injectedClock. - API.
tryAcquire(key)returnstrueand counts the request, orfalseand counts nothing. - Core. Pick the algorithm by what the limit means (below); make each key's check-and-update atomic; create each key's state exactly once.
| Algorithm | State per key | Bursts | Accuracy |
|---|---|---|---|
| Fixed window | one counter | up to 2× the limit around a boundary | coarse |
| Sliding log | a timestamp per allowed request | none beyond the limit | exact |
| Sliding window counter | two counters (this and last window) | small | approximate |
| Token bucket | tokens + last refill time | up to the capacity, then the refill rate | exact for "burst + rate" |
| Leaky bucket | a queue or a level | none - output at a constant rate | smooths traffic |
The pieces in code
A token bucket needs no timer thread: it refills lazily, when a request arrives, from the time that has passed.
Java
private final class Bucket {
private long tokens = capacity; // starts full
private long lastRefill = clock.millis();
synchronized boolean take() { // one lock per key
long now = clock.millis();
long gained = (now - lastRefill) / refillMillis;
if (gained > 0) {
tokens = Math.min(capacity, tokens + gained);
lastRefill += gained * refillMillis; // keep the unfinished part of an interval
}
if (tokens == 0) return false;
tokens--;
return true;
}
}
public boolean tryAcquire(String key) {
return buckets.computeIfAbsent(key, k -> new Bucket()).take(); // exactly one bucket per key
}
Python
import threading
from collections.abc import Callable
class _Bucket:
def __init__(self, capacity: int, refill_millis: int, clock: Callable[[], int]):
self._capacity = capacity
self._refill_millis = refill_millis
self._clock = clock
self._tokens = capacity # starts full
self._last_refill = clock()
self._lock = threading.Lock() # one lock per key
def take(self) -> bool:
with self._lock:
now = self._clock()
gained = (now - self._last_refill) // self._refill_millis
if gained > 0:
self._tokens = min(self._capacity, self._tokens + gained)
self._last_refill += gained * self._refill_millis # keep the unfinished part of an interval
if self._tokens == 0:
return False
self._tokens -= 1
return True
def try_acquire(self, key: str) -> bool:
with self._buckets_lock: # exactly one bucket per key
bucket = self._buckets.get(key)
if bucket is None:
bucket = self._buckets[key] = _Bucket(self._capacity, self._refill_millis, self._clock)
return bucket.take()
- Integer refill.
gainedcounts whole intervals; advancinglastRefillby exactly those intervals keeps the leftover progress. SettinglastRefill = nowinstead throws away up to one interval every time - a token due at 2000 ms arrives at 2500. - The cap (
Math.min(capacity, ...)) is what makes capacity the maximum burst: an hour of silence buyscapacityrequests, not thousands. computeIfAbsenton aConcurrentHashMapcreates a key's bucket atomically. "get, andputif missing" lets two threads each create a bucket for a new key - and each bucket starts full. (Python's nearest equivalent,dict.setdefault(key, Bucket()), is atomic in CPython but builds a throwaway bucket on every call - so the Python tab takes a small lock around get-or-create instead.)
A sliding log keeps the times of allowed requests and drops the ones that have left the window:
Java
synchronized (log) {
long now = clock.millis();
while (!log.isEmpty() && log.peekFirst() <= now - windowMillis) log.pollFirst();
if (log.size() >= limit) return false; // denied: not recorded
log.addLast(now);
return true;
}
Python
def try_acquire(self, key: str) -> bool:
log = self._log_for(key) # this key's allowed times, oldest first
with self._lock_for(key):
now = self._clock()
while log and log[0] <= now - self._window_millis:
log.popleft()
if len(log) >= self._limit:
return False # denied: not recorded
log.append(now)
return True
A request at time s counts while now - window < s, so it leaves exactly one window later. Recording denied requests too would lock out a client that keeps retrying - forever, as long as it retries.
Concurrency
if (tokens > 0) tokens-- is a check-then-act: two threads can both see one token and both take it. Two ways to make it atomic:
- A lock per key -
synchronizedon the bucket, as above. Different keys never wait for each other; one global lock around the whole limiter would serialize every request in the API. - A lock-free loop - keep the state in an
AtomicLongand retrycompareAndSet(old, new)until it succeeds. It avoids blocking under heavy contention, at the cost of packing refill time and tokens into one atomic value. (Python's standard library has no compare-and-set; a lock per key is the Python answer.)
Across servers, in-process locks don't help at all: the counter has to live in one shared store. Redis is the usual choice - INCR plus an expiry for a fixed window, or a small Lua script that does the token-bucket refill-and-take atomically on the server.
Where you'll see it
Rate limiting usually lives at the edge: API gateways (AWS API Gateway throttles with a token bucket; nginx's limit_req uses a leaky bucket) and service meshes. A limited request gets HTTP 429 Too Many Requests with a Retry-After header - a token bucket can compute exactly how long until the next token. Per-key state is kept in a cache that evicts idle keys (a full bucket carries no information, so forgetting it is free). And distributed limiters must decide what to do when the shared store is unreachable: fail open (allow, and risk overload) or fail closed (deny, and risk an outage of your own making).
Which tool for which job?
| You need | Use |
|---|---|
| Simple, cheap limits where boundary bursts are acceptable | fixed window |
| Exact "N in any window" | sliding log (memory grows with the limit) |
| Nearly exact with O(1) memory | sliding window counter |
| Allow bursts up to a size, steady rate after | token bucket (or GCRA) |
| Smooth output to a fragile backend | leaky bucket (a queue drained at a fixed rate) |
| Many threads | per-key atomic updates (compute, a lock per key) |
| Many servers | a shared store with atomic operations (Redis + Lua) |
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
| A fixed window for strict limits | twice the limit across a window boundary | sliding window or token bucket |
| Counting denied requests | a client that keeps retrying stays blocked forever | count only admitted requests |
lastRefill = now after adding whole tokens | the partial token earned is thrown away; refill runs slower than configured | advance the refill time by exactly the tokens added, or keep fractional tokens |
| No cap on the bucket | a long idle period becomes an enormous burst | cap tokens at the bucket size |
| Check, then decrement, without atomicity | concurrent requests overshoot the limit | one atomic step per key |
| One global lock | all clients wait on each other | per-key atomicity |
Check yourself
Try to answer before opening each one - recalling beats re-reading.
1. A fixed-window limiter allows 100 requests per minute. What's the most it can let through in two seconds, and why?
Show answer
About 200: 100 in the last second of one window and 100 in the first second of the next. The counter resets at the boundary, so the limit only holds within each window, not across any 60-second span. A sliding log or a sliding window counter closes that gap.
2. A token bucket has capacity 1 and refills every 1000 ms. A request at t = 0 takes the token; the next arrive at 1500 and 2000. With lastRefill = now, which of those are allowed - and with lastRefill += gained * interval?
Show answer
With lastRefill = now: 1500 is allowed (one interval passed), and lastRefill jumps to 1500, so 2000 is denied - only 500 ms have "passed". With lastRefill += gained * interval: 1500 is allowed and lastRefill becomes 1000, so at 2000 another whole interval has passed and it's allowed too. Only the second version refills at the configured rate.
3. Eight threads send the first-ever request for a new API key at the same instant, and the limit is 1. How can more than one get through, and what fixes it?
Show answer
If the limiter does get, then put when the key is missing, several threads can each see no bucket, each create a full one, and each take its token. ConcurrentHashMap.computeIfAbsent creates the value atomically, so every thread gets the same bucket - and the bucket's own lock (or CAS) lets exactly one of them take the single token.
Key takeaways
- Choose the algorithm by what the limit means: token bucket for "burst plus rate", sliding window for "at most N in any window", fixed window only when boundary bursts don't matter.
- Denied requests don't count; token buckets refill lazily in whole intervals and cap at capacity.
- Make each key's check-and-update atomic (a lock per key or CAS) and create per-key state with
computeIfAbsent. - Inject the clock: refill and window tests move time instead of sleeping.
- Several servers need one shared counter (e.g. Redis with atomic scripts) - or they multiply the limit.