Cruxly

Cruxly › Low-level design › LLD Exercises

Rate Limiter

algorithm choice, then the check-then-act race

Open the interactive lesson

Prompt: design a rate limiter. allow(userId) returns true if this user may make a request now, false otherwise. Limits are configurable per user tier (free: 10/min, pro: 1000/min).

45 minutes. The interesting part is not the data structure.

Applying the five moves (from the method lesson): nouns are User, Tier, and whatever per-user counter state the chosen algorithm needs — that's it, resist inventing more. The invariant is "allow() never lets a user's count exceed their tier's limit, even under concurrent calls" — state that sentence before writing a single method. The interface seam is the algorithm itself (RateLimitStrategy or equivalent), because the prompt's four plausible algorithms is a direct signal that the choice will be questioned. The concurrency sentence is the whole second half of this exercise: allow() is check-then-act, and saying so before being asked is the move.

Scope, then the algorithm menu

In scope: per-user limits, configurable per tier, allow() returns a boolean. Single process, many threads. Out of scope (say it): distributed coordination across machines, persistence across restarts, per-endpoint limits. Each is a genuinely different problem and naming them prevents drift.

Four algorithms. What matters is not knowing them — it is that each one's failure mode is different, and stating the failure mode is how you pick.

Fixed window. A counter per (user, minute).

String key = userId + ":" + (now.getEpochSecond() / 60);
int n = counts.merge(key, 1, Integer::sum);
return n <= limit;

Trivial and cheap. Fails at window edges: 10 requests at 11:59:59 and 10 more at 12:00:00 means 20 in one second under a 10/min limit. Disqualifying for abuse prevention, fine for rough quota accounting.

Sliding window log. Keep a timestamp per request; evict what has aged out.

Deque<Long> log = logs.get(userId);
long cutoff = now - windowNanos;
while (!log.isEmpty() && log.peekFirst() <= cutoff) log.pollFirst();
if (log.size() < limit) { log.addLast(now); return true; }
return false;

Exact, no edge effect. Memory is O(limit) per user — 1000 timestamps for every pro user, which at scale is the problem.

Sliding window counter. Current and previous fixed window, previous weighted by overlap.

double elapsedInWindow = (now % windowNanos) / (double) windowNanos;
double estimate = prevCount * (1 - elapsedInWindow) + currCount;
return estimate < limit;

Two integers per user. Approximates within a few percent. Fails by admitting a slightly wrong number under bursty arrival — usually acceptable, and worth saying so rather than implying it is exact.

Token bucket. Tokens refill at a fixed rate up to a capacity; a request takes one. Two numbers per user. Permits bursts up to capacity by design — a feature for APIs, a bug for abuse prevention.

refill: rate x elapsed capacity = burst bucket take 1 request allowed (token taken) 429 (bucket empty)
The bucket is the burst allowance. It refills continuously, so a user who has been idle can spend everything at once — which is the behaviour to name out loud, because it is a feature for an API quota and a bug for abuse prevention.

Choice: token bucket, because tier limits like "1000/min" in practice mean "sustained 1000/min, and a short burst is fine." Say the alternative that would change the answer: "if this were abuse prevention rather than quota, I'd take the sliding window counter, because a burst is exactly what I'd be trying to stop."

The classes — full implementation

Start with the value types and the seam, then the limiter itself.

/** Per-tier configuration. An interface because the prompt named two tiers,
 *  which is the prompt telling you a third is coming. */
public interface TierPolicy {
    double ratePerSec();   // tokens added per second
    double capacity();     // max tokens that can accumulate (burst size)
}

public enum Tier implements TierPolicy {
    FREE(10.0 / 60.0, 10),        // 10 per minute, burst 10
    PRO (1000.0 / 60.0, 1000);    // 1000 per minute, burst 1000

    private final double rate, cap;
    Tier(double rate, double cap) { this.rate = rate; this.cap = cap; }

    public double ratePerSec() { return rate; }
    public double capacity()   { return cap;  }
}

The bucket holds the mutable state. Note it does not know the policy — the limiter passes it in, so one bucket class serves every tier.

final class Bucket {
    private double tokens;
    private long   lastRefillNanos;

    Bucket(double initialTokens, long nowNanos) {
        this.tokens = initialTokens;
        this.lastRefillNanos = nowNanos;
    }

    /** Refill lazily, then take one token if available.
     *  Caller must hold this bucket's monitor — see the concurrency section. */
    boolean tryConsume(TierPolicy policy, long nowNanos) {
        refill(policy, nowNanos);
        if (tokens >= 1.0) {
            tokens -= 1.0;
            return true;
        }
        return false;
    }

    private void refill(TierPolicy policy, long nowNanos) {
        long elapsed = nowNanos - lastRefillNanos;
        if (elapsed <= 0) return;                  // clock went backwards; do nothing
        double added = (elapsed / 1_000_000_000.0) * policy.ratePerSec();
        tokens = Math.min(policy.capacity(), tokens + added);
        lastRefillNanos = nowNanos;
    }

    /** Seconds until at least one token is available. Drives Retry-After. */
    double retryAfterSeconds(TierPolicy policy, long nowNanos) {
        refill(policy, nowNanos);
        if (tokens >= 1.0) return 0;
        return (1.0 - tokens) / policy.ratePerSec();
    }

    boolean isFull(TierPolicy policy, long nowNanos) {
        refill(policy, nowNanos);
        return tokens >= policy.capacity();
    }
}

And the limiter that ties them together:

public class RateLimiter {
    private final ConcurrentMap<String, Bucket> buckets = new ConcurrentHashMap<>();
    private final Function<String, TierPolicy> tierOf;   // userId -> policy
    private final LongSupplier clock;                    // injected: nanoTime source

    public RateLimiter(Function<String, TierPolicy> tierOf, LongSupplier clock) {
        this.tierOf = tierOf;
        this.clock  = clock;
    }

    public boolean allow(String userId) {
        TierPolicy policy = tierOf.apply(userId);
        long now = clock.getAsLong();

        // computeIfAbsent is atomic: two threads racing a new user get ONE bucket.
        Bucket b = buckets.computeIfAbsent(userId,
                       k -> new Bucket(policy.capacity(), now));

        // Lock the bucket, not the limiter: different users never contend.
        synchronized (b) {
            return b.tryConsume(policy, now);
        }
    }

    public double retryAfterSeconds(String userId) {
        Bucket b = buckets.get(userId);
        if (b == null) return 0;
        TierPolicy policy = tierOf.apply(userId);
        synchronized (b) {
            return b.retryAfterSeconds(policy, clock.getAsLong());
        }
    }
}

Three decisions worth defending out loud.

clock is injected as a LongSupplier. In production you pass System::nanoTime; in a test you pass a counter you control, so "does a user get exactly 10 requests in a minute" becomes a deterministic assertion rather than a Thread.sleep. A time-dependent component that cannot be tested deterministically is one nobody can safely change.

Refill is lazy, computed on read. No background thread walks a million buckets adding tokens. elapsed x rate, clamped to capacity, O(1), and an idle user costs nothing until they return.

A new bucket starts full (new Bucket(policy.capacity(), now)). That is a deliberate choice: a first-time user gets their full burst immediately. Starting at zero would throttle every new user for their first window, which is almost never what anyone wants — but it is a choice, so say it.

The two races — this is the actual exercise

@@ 1 | The version almost everyone writes first

public boolean allow(String userId) {
    Bucket b = buckets.get(userId);
    refill(b);
    if (b.tokens >= 1.0) {        // (A) check
        b.tokens -= 1.0;          // (B) act
        return true;
    }
    return false;
}

@@ 2 | Two threads interleave between the check and the act

time T1 T2 read tokens = 1.0 read tokens = 1.0 check passes check passes admit admit both saw 1.0 before either decremented
One token, two admissions. The gap between reading the count and writing it back is where the limit stops being enforced.

:::race The bug has a name This is check-then-act — the same shape as find-and-claim in the parking lot. Any time you read a value, make a decision from it, and write back based on that decision, the whole sequence has to be atomic or another thread can act inside the gap. :::

@@ 3 | Make check and act one atomic step

Three ways, ranked:

1. Lock the bucket (chosen).

Bucket b = buckets.computeIfAbsent(userId, k -> new Bucket(policy.capacity(), now));
synchronized (b) {                     // <-- per-user monitor
    return b.tryConsume(policy, now);  // refill + check + decrement, atomically
}

Two different users hold two different monitors and never contend. Two threads for the same user serialise for a few nanoseconds. Simple and obviously correct.

:::key Why per-bucket and not per-limiter The state being protected is per-user, so the lock should be per-user. A lock on the limiter would make user A wait for user B for no reason — a global bottleneck guarding state that was never shared. :::

2. CAS loop — lock-free, if contention on one user is genuinely high.

final class AtomicBucket {
    // tokens as fixed-point micro-tokens so we can CAS a single long
    private final AtomicLong state = new AtomicLong();   // packed: tokens | timestamp

    boolean tryConsume(TierPolicy p, long now) {
        for (;;) {
            long snapshot = state.get();
            long refilled = computeRefilled(snapshot, p, now);
            if (tokensOf(refilled) < 1_000_000L) return false;      // < 1 token
            long next = subtractOneToken(refilled, now);
            if (state.compareAndSet(snapshot, next)) return true;   // won the race
            // lost: another thread moved first, recompute and retry
        }
    }
}

Higher throughput when many threads hammer one key, materially harder to read. Reach for it only if the interviewer pushes on contention — and say that trade.

3. One lock on the whole limiter — wrong.

public synchronized boolean allow(String userId) { ... }   // <-- global bottleneck

Every user now serialises against every other user to protect per-user state. Being able to say why this is wrong matters more than avoiding it by luck.

@@ 4 | The second race, which most candidates miss

Bucket b = buckets.get(userId);
if (b == null) {                                   // both threads see null
    b = new Bucket(policy.capacity(), now);        // both construct
    buckets.put(userId, b);                        // one silently overwrites
}

Follow-ups they will ask

"Now make it work across 10 machines." The honest answer starts by naming what breaks: per-process buckets mean each machine admits the full limit, so a 10-machine fleet admits 10x. Options: (a) a shared store — Redis with an atomic INCR/Lua script, correct but adds a network hop to every request and a dependency that can fail; (b) allocate each machine limit/N and accept that an uneven load balancer under-admits; (c) approximate coordination — machines gossip counts periodically, converging but briefly wrong. State the trade: (a) costs latency and availability, (b) costs accuracy under skew, (c) costs simplicity. There is no free option and saying so is the answer.

"What does the client see?" 429 with Retry-After. Compute it from the bucket: seconds until one token refills. Cheap, and it is the difference between a limiter that protects you and one that causes a retry storm.

"Memory for millions of idle users?" The map grows forever. Evict buckets that are full and untouched for longer than a window — a full bucket is indistinguishable from a fresh one, so dropping it is free. That observation (full == absent) is the neat part, and it is a genuine invariant of the design rather than a trick.

"Per-endpoint limits too?" Key by (user, endpoint) and the model is unchanged — which is the point of keying rather than hardcoding. Mention the combinatorial memory growth before they do.

How to say it in the interview

"Four algorithms are plausible here and they differ in burst behaviour and memory, so I want to pick one by naming its failure mode. Then the real work: allow() is check-then-act, which is a race unless I make it atomic."

Checklist

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons