Design Projects: Concurrent Systems · Rate Limiter

Design a Rate Limiter

Designing a rate limiter: fixed and sliding windows, token buckets, per-key state and thread safety.

  • 9 min read
  • 15 practice questions
  • 2 graded problems
On this page
  1. Description
  2. The approach
  3. The pieces in code
  4. Concurrency
  5. Where you'll see it
  6. Which tool for which job?
  7. Common mistakes
  8. Check yourself
  9. Key takeaways
  10. Quick revise
  11. Practice

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:

  1. The algorithms and what each allows: fixed window, sliding log, token bucket.
  2. A token bucket in code, with lazy refill and an injected clock.
  3. Concurrency: per-key atomicity without a global lock.
  4. Production concerns, bugs and the memory-accuracy tradeoff.

The approach

  1. 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?
  2. Model. A RateLimiter interface - boolean tryAcquire(String key) - with one class per algorithm. Per-key state in a concurrent map. Time from an injected Clock.
  3. API. tryAcquire(key) returns true and counts the request, or false and counts nothing.
  4. 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.
AlgorithmState per keyBurstsAccuracy
Fixed windowone counterup to 2× the limit around a boundarycoarse
Sliding loga timestamp per allowed requestnone beyond the limitexact
Sliding window countertwo counters (this and last window)smallapproximate
Token buckettokens + last refill timeup to the capacity, then the refill rateexact for "burst + rate"
Leaky bucketa queue or a levelnone - output at a constant ratesmooths 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. gained counts whole intervals; advancing lastRefill by exactly those intervals keeps the leftover progress. Setting lastRefill = now instead 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 buys capacity requests, not thousands.
  • computeIfAbsent on a ConcurrentHashMap creates a key's bucket atomically. "get, and put if 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 - synchronized on 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 AtomicLong and retry compareAndSet(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 needUse
Simple, cheap limits where boundary bursts are acceptablefixed window
Exact "N in any window"sliding log (memory grows with the limit)
Nearly exact with O(1) memorysliding window counter
Allow bursts up to a size, steady rate aftertoken bucket (or GCRA)
Smooth output to a fragile backendleaky bucket (a queue drained at a fixed rate)
Many threadsper-key atomic updates (compute, a lock per key)
Many serversa shared store with atomic operations (Redis + Lua)

Common mistakes

MistakeWhat happensDo this instead
A fixed window for strict limitstwice the limit across a window boundarysliding window or token bucket
Counting denied requestsa client that keeps retrying stays blocked forevercount only admitted requests
lastRefill = now after adding whole tokensthe partial token earned is thrown away; refill runs slower than configuredadvance the refill time by exactly the tokens added, or keep fractional tokens
No cap on the bucketa long idle period becomes an enormous burstcap tokens at the bucket size
Check, then decrement, without atomicityconcurrent requests overshoot the limitone atomic step per key
One global lockall clients wait on each otherper-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.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the tool

You needUse
Cheap, boundary bursts OKfixed window
Exactsliding log
Nearly exact, O(1) memorysliding window counter
Bursts then a steady ratetoken bucket / GCRA
Smoothing for a fragile backendleaky bucket
Many serversRedis with a Lua script

Snippets worth knowing by heart

Java

synchronized boolean tryAcquire(long nowNanos) {
    double elapsedSeconds = (nowNanos - lastRefillNanos) / 1e9;
    tokens = Math.min(capacity, tokens + elapsedSeconds * ratePerSecond);   // fractional tokens kept
    lastRefillNanos = nowNanos;
    if (tokens < 1) return false;                                           // denied requests cost nothing
    tokens -= 1;
    return true;
}

Python

def allow(self, now_ms: int) -> bool:                  # GCRA: one timestamp per key
    start = max(self._tat, now_ms)
    if start - self._tolerance > now_ms:
        return False
    self._tat = start + self._interval
    return True

The traps

  • Fixed windows for strict limits.
  • Counting denied requests.
  • Losing partial tokens; no cap.
  • Check-then-decrement without atomicity; one global lock.
  • Per-server limits; an unbounded key map.

Staff-level points, one line each

  • Sliding window counter vs exact log: 8 of 264 decisions differed (253 vs 245 admitted).
  • GCRA (one timestamp) matched a token bucket on every decision (258 admitted).
  • INCR then EXPIRE can leave a key with no TTL - use Lua or SET NX EX.
  • Store down: fail open for reads, closed for login/SMS/payments; keep a local fallback.
  • One clock: the store's time; monotonic clocks within a process.
  • 429 + Retry-After (+ RateLimit headers); clients back off with jitter.
  • Stack limits (user, tenant, endpoint, global); weight expensive requests.

Say it in an interview

  • "A token bucket per user: bursts up to the bucket size, then the refill rate."
  • "Each key is updated atomically - no global lock."
  • "Across servers the check runs as one Lua script in Redis."
  • "If Redis is down, login fails closed and browsing fails open."

Practice

This skill comes with 15 multiple-choice questions that tell you which misconception a wrong answer reveals and 2 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Design a Rate Limiter free

Build a Thread-Safe Rate Limiter

HardProDesign projectJava · Python

Build two per-key rate limiters behind one interface, as an API gateway would use them: a token bucket for "bursts up to N, then a steady rate", and a sliding window for "at most N in any window".

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Design a Rate Limiter free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime