Design a Rate Limiter
- Work a rate limiter as low-level design: an interface, a token bucket, and a decorator
- Make it correct under concurrency, closing the check-then-act race on per-key token state
- Weigh burst tolerance, memory per key, and when a lock-free atomic update is worth it
Understanding the problem
Design a rate limiter. It caps how often a caller may invoke a piece of work, say a hundred requests per client per minute, allowing the request when the caller is under the limit and rejecting it when they are over. Build it so it can wrap an existing service without that service changing, and make it correct when many threads call it at once for the same client and for different clients.
This is a low-level design question, so the interesting part is not how it scales to a billion users. It is how cleanly it models the problem in objects and how it behaves when a dozen threads hit the same client key in the same millisecond. A rate limiter has a tiny public surface: one method that answers "may this call proceed." Underneath sits a surprising amount of subtlety. It covers which counting algorithm you pick, how the limiter attaches to the work it protects, and where you guard the mutable state that every thread wants to touch. Get those three right and the design is done.
Picture the shape before any code. A Caller invokes a service. Instead of calling the raw service, it calls a Rate-limited wrapper that holds a Rate limiter and the real service inside it. The wrapper asks the limiter "may this key proceed," the limiter consults the Per-key bucket state for that client, and on a yes the wrapper delegates to the real work while on a no it rejects. Three collaborators, one guarded piece of state, and an interface thin enough that nothing calling the service can tell the limiter is there.
Functional requirements
Keep the core to the allow-or-deny decision and let the interviewer widen it. The limiter has to answer one question fast: given a key, is this call within the configured limit right now. It has to track a separate allowance per key, so one noisy client cannot spend another client's budget. It has to attach to an existing service without rewriting that service. And its limit, the count and the window, has to be configurable rather than hard-coded.
In scope
- Answer "may this key proceed" in constant time on the hot path
- Enforce a configurable limit, a count over a time window, per key
- Isolate keys so one client cannot consume another client's allowance
- Wrap an existing service transparently, without changing its interface
- Stay correct when many threads call for the same key at once
- Bound memory by aging out keys that have gone idle
Below the line (name it, then skip)
- A distributed limiter shared across many JVMs (a Redis-backed variant; gesture at it, then scope to one process)
- The transport layer, HTTP status codes, and retry-after headers (a concern of the caller wrapping this)
- Per-endpoint or per-tier policy configuration loading (a config service upstream)
- Billing, quotas, and long-horizon usage accounting (a separate metering system)
- Authentication and deciding what the key is (an upstream filter hands us the key)
Non-functional requirements
Reason through each target, because these are what draw the class boundaries later. Correctness under concurrency is the property everything else bends around. Many threads will call the limiter at once, some for the same key and some for different keys, and the answer has to stay right. A limit of a hundred means at most a hundred pass. It never means a hundred and three because three threads read the same count before any of them wrote it back. This is a check-then-act problem, and naming it as one early is the signal a Java panel is listening for.
Latency and footprint come next, and they push toward the same shape. The check sits on the hot path in front of real work, so it has to be constant time and allocate nothing surprising: read the key's state, update it, decide. Memory is per key, and keys can be unbounded over time, so the design has to hold only the active ones and evict the state for keys that have gone quiet, or a long-lived process leaks. Fairness matters too, since a limiter that lets one thread starve while another sails through is a subtle bug that shows up only under load.
Now scope it honestly, because scope is what makes this low-level design. The target is a single process. This is a library that lives inside one JVM, guarding calls made on that JVM's threads, which is why the hard part is the in-memory concurrency, not a network. A limiter shared across a whole fleet is a real and different problem. Name it (a shared counter in something like Redis), then set it aside as out of scope. That keeps the conversation on the object design and the threading the round actually tests.
- Correctness under threads: no check-then-act race. A limit of N lets at most N through even when many threads hit one key together. The read-modify-write on the counter is atomic, or it is wrong exactly when it is busiest.
- Constant time on the hot path: O(1) per decision. The check runs in front of real work, so it reads and updates one key's state and returns. No scanning a log of timestamps, no allocation storm.
- Bounded memory: state only for active keys. Per-key state in a concurrent map, with idle keys aged out. Unbounded keys over a long-lived process would otherwise leak until the heap gives up.
- Single process by design: in-JVM library, not a service. This is LLD: one process, real threads, no network. A fleet-wide limiter is a shared counter in Redis, named and then scoped out so the round stays on objects and concurrency.
One framing choice is worth stating out loud because it steers everything downstream. The limiter is a policy, and the work it protects is a separate thing. If you fuse the two, every service that wants limiting has to be edited, and the counting logic gets copied around. If you keep them apart, one limiter implementation can wrap any service, and swapping the algorithm never touches the service. That separation is the seam the decorator will sit on, and it is the difference between a design that reads as senior and one that reads as a first draft.
The counting algorithm
Before the objects, settle how the counting works, because the algorithm decides what state each key holds. Know these four. They trade accuracy against memory and against how they treat bursts. Work up from the simplest, since the interviewer wants to hear why the obvious counter leaks before you land on the one you would ship.
Approach
Keep an integer per key for the current window, say the current minute. Increment on each call, allow while the count is under the limit, and reset to zero when the clock rolls into the next window. Trivial to write and to reason about, one number per key.
Challenges
It lets through double the limit at a window boundary. A client can send the full limit in the last second of one minute and the full limit again in the first second of the next. Two hundred calls land inside two seconds, and every individual window stayed legal. The reset is a cliff, and traffic that clusters near it defeats the whole limit. It is the right thing to mention and then reject for anything that has to hold a real ceiling.
Approach
The sliding window log keeps a timestamp per recent call and, on each request, drops timestamps older than the window and counts what remains, which is exactly accurate. The token bucket instead keeps two numbers per key: a token count and a last-refill time. Tokens accrue at a steady rate up to a capacity, each call spends one, and a call with no token to spend is rejected.
Challenges
The sliding window log is precise but pays for it: it stores one timestamp per call in the window, so a hot key holds a growing list and the check is no longer a fixed two numbers. It is accurate and memory-hungry. The token bucket keeps its state at a constant two numbers per key and is the more practical choice, which is why it is the one to develop. Its one open question is how it handles bursts, and the answer turns out to be a feature.
Approach
Commit to the token bucket and refill it lazily instead of on a timer. Each key holds a token count and the timestamp of its last update. On a call, compute how much time has elapsed, add that many tokens at the refill rate up to the capacity, then try to spend one. No background thread ticks every bucket; the refill is arithmetic done at read time, so an idle key costs nothing until it is touched again. Capacity sets how large a burst is tolerated and the refill rate sets the sustained ceiling, and the two are configured independently.
Challenges
This is O(1) in time and O(1) in state per key, it allows a controlled burst up to capacity which is usually what you want from a real limiter, and it needs no sweeper thread. The leaky bucket is its close cousin, smoothing output to a strictly constant rate by draining at a fixed pace, which you would prefer when downstream needs an even flow, not tolerated bursts. Naming that as the one alternative you would reach for, and why, is the tradeoff answer the round rewards. The remaining work is entirely about making the two-number update safe when threads race, which is the next section.
So the state per key is two numbers, a token count and a last-refill timestamp, and every decision is a small read-modify-write over them. Hold that thought, because it is the exact thing concurrency will attack: two threads that both read the same token count, both see one token left, and both decide to spend it. The algorithm is settled; the correctness now lives in how that update is guarded.
A clean object design: the decorator
Now the object model, which is where a low-level design round spends most of its minutes. The requirement to wrap an existing service without changing it names the pattern outright: the decorator. A decorator implements the same interface as the thing it wraps, holds a reference to that thing, adds its behavior around the delegated call, and returns. Because it shares the interface, every caller keeps calling the same type and cannot tell a limiter was slipped in front. The limiting logic lives in exactly one class, and any service that fits the interface can be wrapped.
Read the diagram by role. Blue is the caller at the edge, violet is an object we write, green is the mutable state, and amber is the reject path. The wrapper and the limiter are separate collaborators on purpose: the wrapper knows how to delegate, the limiter knows how to count, and neither has to know the other's internals.
Name the classes and what each is responsible for, because the interviewer grades the boundaries as much as the code. Four types carry the design. Keep the split between them clean.
OrderService (the interface)
The contract shared by the real service and its decorator. It is what the caller depends on, and the reason the decorator is invisible: both the plain service and the wrapped one are this type, so nothing calling it changes when a limiter is added. Program to this interface, not to a concrete class.
RateLimiter (the policy interface)
One method, tryAcquire(String key), returning true when the call is within the limit. It is an interface so the algorithm is swappable: a token-bucket implementation today, a sliding-window one tomorrow, with no change to anything that holds a RateLimiter. This is the seam that keeps the counting logic in one replaceable place.
RateLimitedOrderService (the decorator)
Implements OrderService, holds a delegate OrderService and a RateLimiter. Its whole job is: ask the limiter, delegate on yes, reject on no. It has no counting state of its own, so it stays small and every service can be wrapped by the same pattern rather than each one growing its own limiter code.
TokenBucketRateLimiter and Bucket
The concrete policy. The limiter holds the ConcurrentHashMap of key to Bucket and the configured capacity and refill rate. Each Bucket holds the two mutable numbers, the token count and last-refill time, and owns the guarded update. Separating the map owner from the per-key state is what lets the locking be per key rather than global.
// The contract the real service and its decorator both satisfy.interface OrderService { OrderResult place(Order order);}// The policy, kept as an interface so the algorithm can be swapped.interface RateLimiter { boolean tryAcquire(String key);}// The decorator: same interface as the service it wraps, so the// caller cannot tell it is there. It counts nothing itself.final class RateLimitedOrderService implements OrderService { private final OrderService delegate; private final RateLimiter limiter; RateLimitedOrderService(OrderService delegate, RateLimiter limiter) { this.delegate = delegate; this.limiter = limiter; } @Override public OrderResult place(Order order) { if (!limiter.tryAcquire(order.clientId())) { throw new RateLimitExceededException(order.clientId()); } return delegate.place(order); }}Thread safety: where the design is won or lost
This is the section the round is really testing, because in an enterprise-Java loop the concurrency is where people are cut. The limiter is called by many threads at once, and the token bucket's two-number update is a read-modify-write: read the token count, add the lazy refill, check for a token, spend it. If two threads run that sequence interleaved on the same key, both can read one token remaining, both can decide to spend it, and the limit is breached by exactly the amount you were trying to prevent. Every fix below is about making that sequence indivisible. Work up from the version that looks safe and is not.
Approach
Store buckets in a HashMap, and in tryAcquire read the count, compare it to the limit, and increment. It passes a single-threaded test cleanly and reads as obviously correct.
Challenges
It fails two ways at once under threads. The HashMap itself is not safe for concurrent writes: two threads inserting new keys can corrupt its internal structure and, in older versions, spin a CPU forever. And the check-then-act on the count is a race, so a limit of a hundred lets through more than a hundred whenever calls arrive together, which is precisely when a limiter matters. This is the version to show you can spot and reject, because it is the one that looks fine in review and breaks in production.
Approach
Hold buckets in a ConcurrentHashMap and create each with computeIfAbsent, so the map handles concurrent lookup and one-time creation without a global lock. Put the two-number state inside a Bucket object and make its update method synchronized, so the read-modify-write for one key runs under that key's own monitor and cannot interleave with itself.
Challenges
This is correct and it is the answer to lead with. The lock is per bucket, not global, so different keys never contend and only threads hammering the same key serialize, which is the contention you actually want to serialize. The one honest cost to name is that a single very hot key funnels all its threads through one monitor, so that key's throughput is bounded by how fast the guarded section runs. For almost every key that never bites, and the guarded section is a few arithmetic operations, so it is short.
Approach
If a hot key's monitor is measurably the bottleneck, drop the lock and make the update atomic instead. Hold the bucket's state in an AtomicReference or pack the two numbers into an AtomicLong. Update it with a compare-and-swap loop: read the current state, compute the refilled-and-spent next state, and swap it in only if nothing changed underneath. If another thread won the race, the swap fails and you retry with the new value.
Challenges
This removes the lock and lets threads on one key progress without blocking. The cost is real complexity. The update has to be written as a pure function of the old state so a retry is safe, and a CAS loop is harder to read and to prove correct than a synchronized method. Reach for the synchronized bucket first, because it is correct and clear. Then offer compare-and-swap as an optimization you would apply only where profiling showed a hot key actually contending. That ordering reads as senior. Correctness and clarity first, lock-free only where it pays.
Say two Java specifics by name, because the panel is listening for them. First, computeIfAbsent on a ConcurrentHashMap creates a key's bucket exactly once even when threads race to create the same one, which is why the map needs no external lock for setup. Second, the guarded update has to cover the whole read-modify-write, not just the increment: refilling and spending are one indivisible step, or a thread can refill on stale tokens between another thread's read and write. The state that many threads mutate is small and its guard is narrow, and that keeps a per-key lock cheap.
// One bucket per key, in a ConcurrentHashMap, so lookup and// one-time creation are safe with no global lock.final class TokenBucketRateLimiter implements RateLimiter { private final int capacity; private final double refillPerMilli; private final ConcurrentHashMap<String, Bucket> buckets = new ConcurrentHashMap<>(); private final Clock clock; TokenBucketRateLimiter(int capacity, double refillPerSecond, Clock clock) { this.capacity = capacity; this.refillPerMilli = refillPerSecond / 1000.0; this.clock = clock; } @Override public boolean tryAcquire(String key) { // computeIfAbsent creates the bucket exactly once under a race. Bucket bucket = buckets.computeIfAbsent(key, k -> new Bucket(capacity, clock.millis())); return bucket.tryConsume(refillPerMilli, capacity, clock.millis()); }}// The mutable state lives here, and the whole read-modify-write// is guarded so refilling and spending are one indivisible step.final class Bucket { private double tokens; private long lastRefillMillis; Bucket(int capacity, long now) { this.tokens = capacity; this.lastRefillMillis = now; } synchronized boolean tryConsume(double refillPerMilli, int capacity, long now) { long elapsed = now - lastRefillMillis; tokens = Math.min(capacity, tokens + elapsed * refillPerMilli); lastRefillMillis = now; if (tokens >= 1.0) { tokens -= 1.0; return true; } return false; }}What the round rewards
The bar here is a clean object model and correct concurrency. Recruiters describe the design conversation as a discussion more than a whiteboard capacity exercise, so narrate the mechanism plainly. State the interface, pick the token bucket and say why, and name the decorator and why it keeps the service single-purpose. Then spend real time on the thread-safety and the tradeoffs. Expect the interviewer to push on the concurrency and the pattern vocabulary, because those are the surfaces this loop actually tests.
Reads as strong
- Opens with a thin interface and the decorator, keeping the limiter a policy separate from the service it wraps
- Picks the token bucket, explains lazy refill, and names the leaky bucket as the one alternative and when to prefer it
- Calls out the check-then-act race by name and guards the whole read-modify-write, not just the increment
- Uses a ConcurrentHashMap with computeIfAbsent for per-key buckets and a per-key lock, not a global one
- Offers compare-and-swap as an optimization for a proven hot key, after correctness with a synchronized bucket
Reads as off-target
- Reaches for capacity planning and a distributed cache before the single-process object design is even sketched
- Ships a fixed window counter without noticing the boundary burst that doubles the limit
- Stores buckets in a plain HashMap, or increments a shared counter with no guard on the read-then-write
- Locks the whole limiter globally so every key contends with every other key
- Fuses the limiting into the service, so each service reimplements the counting and nothing is swappable
Common questions
Why the decorator pattern for a rate limiter?
Because the requirement is to add limiting to an existing service without changing it, and that is what a decorator does. It implements the same interface, holds the real service, adds its check around the delegated call, and stays invisible to callers. The limiting logic ends up in one class that can wrap any service on that interface, instead of being copied into each service. It also keeps the algorithm swappable behind the RateLimiter interface it consults.
Which algorithm and why?
The token bucket for most cases. It holds constant state per key, two numbers, runs in constant time with a lazy refill that needs no background thread, and tolerates a controlled burst up to its capacity, which is usually the behavior you want. A fixed window counter is simpler but lets through double the limit at a window boundary. A sliding window log is exactly accurate but stores a timestamp per call. A leaky bucket smooths output to a strictly constant rate, which you would prefer when downstream needs an even flow.
How do you make it thread-safe?
Keep per-key buckets in a ConcurrentHashMap created with computeIfAbsent, so the map handles concurrent lookup and one-time creation without a global lock. Guard each bucket's update so the whole read-modify-write of refilling and spending a token is one indivisible step. A synchronized method on the bucket is the clear, correct default; different keys never contend because the lock is per key. If profiling shows one very hot key bottlenecked on its monitor, replace the lock with a compare-and-swap loop over the bucket's state.
What is the check-then-act race here?
It is the bug a limiter exists to avoid, turned on the limiter itself. Reading the token count, deciding a token is available, and spending it are three steps. If two threads interleave them on one key, both can read the last token and both can spend it, so the limit is breached. The fix is to make those steps atomic, with a lock or a compare-and-swap, so exactly one thread can spend the last token and the other is correctly denied.
How would this change for many servers?
The per-key state would move out of the JVM into a shared store, commonly Redis, where the counter or token bucket is updated atomically. That often uses a small server-side script so the read-modify-write stays indivisible across the network. That is a genuinely different problem with its own failure modes, latency, and consistency questions. So the single-process version is the right scope for a low-level design round, and the distributed one is named and set aside.
Key Takeaways
- ✓This is low-level design: the win is a clean object model and correct concurrency in one JVM. Name the distributed variant, then scope to a single process where the hard part lives.
- ✓Model it as a decorator. A thin service interface, a separate swappable RateLimiter policy, and a wrapper that delegates on a yes and rejects on a no, so limiting is composable and the service stays single-purpose.
- ✓Pick the token bucket with lazy refill: constant state and time per key, controlled bursts, no sweeper thread. Reject the fixed window for its boundary burst, and name the leaky bucket as the smooth-output alternative.
- ✓Win it on thread safety. Per-key buckets in a ConcurrentHashMap via computeIfAbsent, the whole read-modify-write guarded so it cannot interleave, a synchronized bucket by default, and compare-and-swap only where a hot key proves it is needed.
Morgan Stanley interview guideKnow what Morgan Stanley asks before you walk in.
11 coding · 4 system design · 19 behavioral questions
Get their questions first.
InterviewLegend is independent and not affiliated with or endorsed by any company it covers.