Design a Rate Limiter
- Scope a rate limiter that stops one client starving the rest, behind an Azure-style API gateway
- Compare the counting algorithms and defend token bucket against the fixed-window boundary burst
- Go deep on the fleet-wide over-admission race, atomic Redis counting, and counter-store outages
Understanding the problem
"Design a rate limiter" sounds small and turns into a real distributed-systems problem, which is why it tops the list of Microsoft design prompts. The job states easily. For every incoming request, decide in a millisecond or two: allow or reject. On a reject, return HTTP 429 with a Retry-After header so a well-behaved caller knows to back off. The limiter protects the API from overload and keeps one client's burst from starving everyone else. The hard part is the word "distributed." The decision runs across a fleet of API servers, so the count of what a client has spent can't sit on any one of them. It has to be shared, and sharing a fast-changing counter across a fleet is where this gets interesting.
Pin the goal before drawing anything. Abuse detection, authentication, and billing all live in other layers. What this system does is smooth load. A client gets a steady rate of requests plus a little room to burst. Past that we shed their excess, so the shared API stays healthy for everyone behind it. The limiter has to be far cheaper than the work it guards, since it runs on the hot path of every single call. And it has to honor the limit across the whole fleet even though no single server sees all the traffic.
Functional requirements
Keep the scope tight. The whole round turns on getting one decision right under load, so breadth buys little. The core is a per-request allow-or-reject decision, enforced per client across the whole fleet, with a clear 429 and a Retry-After when we reject. Controlled bursts are in scope, since real API traffic is spiky and a rigid limit would punish honest callers. Everything else sits below the line.
In scope
- Decide allow or reject for each request, per client key, across the whole server fleet
- Allow a controlled burst up to a cap, then hold the sustained rate
- Return
429with aRetry-Afterheader on reject - Keep one client's burst from starving the others (fairness)
Below the line (call it out, then skip)
- Authentication and API-key validation (a separate layer that runs before us)
- Abuse and fraud detection (different signal, different system)
- Usage metering for billing (this limiter shapes load; billing lives elsewhere)
- The DDoS / network-flood layer (handled upstream at the edge)
- Per-endpoint quotas as a product feature (we limit to keep the API up, not to package plans)
Non-functional requirements
These requirements decide the design, so reason through each one with a target. Latency first. The limiter sits on every request, so its own check has to cost a millisecond or two at most. A slow limiter is itself an outage. Correctness is next, and it is subtle. A perfect count is not the goal. Admitting a handful of requests over the limit during a burst is fine. What sinks the design is the limit failing wholesale, the fleet collectively admitting many times the intended rate because each server counted on its own. The bar is "globally close to the limit," not "exactly the limit."
Availability carries a real tension worth flagging now. The limiter depends on shared state. When that state goes unreachable, the textbook options are both bad. Fail open and allow the traffic, and you risk an overloaded API. Fail closed and reject it, and you risk the limiter taking down the very API it protects. The last deep dive comes back to this, because the answer is "neither, exactly." Fairness is the other first-class requirement, and it bites in practice. One large client running a spike must not eat all the shared capacity. If it does, smaller clients see 429s on traffic they had every right to send.
- ~1-2 ms Limiter budget. It runs on every request.
- Per client key Isolation unit. One bucket per API key.
- Global Enforcement. Across the whole server fleet.
- 429 + Retry-After Reject response. A retryable signal to back off.
The set-up
Core entities
Name the handful of objects the system moves around before drawing anything. Three carry the design:
Bucket
The per-client token bucket state, the only thing that changes per request. It holds two numbers: the current token count and the timestamp of the last refill. Nothing else needs to persist, a few bytes per client, which is what makes the limiter cheap.
LimitPolicy
The client's configured limit: the bucket size (how big a burst it can absorb) and the refill rate (sustained requests per second). Read-mostly config, set per plan or per key, and the yardstick the bucket state gets measured against.
Decision
The output for one request: allow, or reject with a Retry-After telling the caller roughly when a token frees up again. Nothing persists it; it is just returned. But producing it is the entire point of the system.
The store under the bucket state is Redis, and the choice is load-bearing, so be ready to defend it. We need one shared counter per client that every API server can read and update in well under a millisecond. Across the fleet that happens hundreds of thousands of times a second. A relational database is out. A disk-backed store with a row lock per client would be slower than the work it guards, which defeats the point. Redis keeps the bucket in memory, keyed by client id, so a check is a single round trip to a fast in-memory store. And a bucket is tiny and expendable in a way a real record never is. Lose one on a node failure and the client briefly gets a fresh full bucket, a harmless over-admission rather than lost data. We shard the keyspace across Redis nodes by client id once one node is not enough, since a bucket is only ever touched under its own client key anyway. On Azure this is the same shape as Azure Cache for Redis; on the diagram it stays "Redis" because that is the name an interviewer recognizes.
API
The limiter is a check the gateway runs against every incoming request before doing real work, plus a header on the response. So the contract has two halves: what the limiter exposes internally, and what the caller sees on a reject.
The cost parameter is worth noticing. Requests are not all equal, so an expensive call can pull more than one token from the bucket. That lets the same limiter throttle by the work a request does, not only by how many requests arrive. On the wire there are two outcomes that matter: a served request, or a 429 carrying Retry-After. That header is the contract with the caller. A good client waits the named seconds and resends, so a 429 costs it a short delay instead of a hard failure. The deep dives build the limiter that produces it.
High-level design
Walk the request path one piece at a time, and build the simplest version that works before hardening it. The shape is small. The gateway runs the same limiter check against one shared store before any request reaches the service behind it. In the diagram, color marks each component's role. Blue is the Client. Violet is the API gateway and the Limiter check running inside it. Green is the shared Redis bucket store. Amber is the 429 + Retry-After response that turns a rejected caller back. The walkthrough tags each component the same way.
1) Receive and identify the client. The Client sends a request with its API key, and the API gateway resolves that key to a client id. This is the unit of isolation: every client gets their own bucket, so one client's traffic can never spend another's allowance. The gateway does no real work yet, and it does not forward anything until the check clears. Skip the gate and you let load pile up unchecked, which is exactly how one busy client melts the shared API for everyone, so we gate first.
2) Run the limiter check. The Limiter check inside the gateway takes the client id and the request's cost, usually one token, and asks the shared store whether a token is available. This is the entire hot-path decision, and it has to be a single fast round trip. Anything slower than the work it guards is a tax on every call. One thing we punt on here: the check reads and updates a shared counter. If two gateway nodes run it against the same bucket at the same instant without care, both can see a free token and both admit. Making that update atomic is the core of the design and the first deep dive.
3) Check and decrement the bucket. Against the Redis bucket store, the check does three things as one indivisible step. First it refills the bucket by how much time has passed since the last refill (rate times elapsed seconds, capped at the bucket size). Then, if there is at least one token, it subtracts the request's cost and reports allowed, and otherwise reports denied. The bucket is just a count and a timestamp, so this is cheap. Running all three steps atomically inside Redis is what stops two nodes from racing. Pull the read into the app and write back separately and the race reopens. The punt here is fairness under stress. Per-client buckets isolate clients from each other, yet a hot fleet still needs to protect its most important traffic, which is deep dive three.
4) Allow, or reject with Retry-After. If the bucket had a token, the gateway forwards the request on to the service, and the client's remaining count drops by one. If the bucket was empty, the API gateway returns the 429 + Retry-After response immediately, naming how many seconds until the bucket refills enough for one token. The caller waits that long and resends, so the 429 costs a short delay and never a dropped request. The last thing to handle is what happens when the shared store itself is slow or down. The limiter must not become the outage, and that is the final deep dive.
Potential deep dives
Most of the interview signal lives here. The interviewer picks one piece and pushes hard, because the distributed, under-load behavior is the entire point of this round. Below are the ones that come up most on this prompt, each graded against the tiers an interviewer scores you on.
1) Which algorithm, and why token bucket?
API traffic is bursty but bounded. A client sits quiet, then fires a cluster of calls when a job runs or a page loads, then goes quiet again. A good limiter has to absorb that burst up to a point and then hold a steady sustained rate. The algorithm we pick decides how well it does both. Work up through the options, because the popular simple one has a sharp edge.
Approach
Count requests per client inside a fixed clock window, say a minute, and reject once the count passes the limit. At the top of the next minute the count resets to zero. One integer per client, trivial to reason about.
Challenges
The window boundary lets through twice the limit. With a cap of 100 a minute, a client can send 100 requests in the last second of one window and 100 more in the first second of the next. That puts 200 inside a two-second span, and each window still looks compliant. The reset is a cliff, and an attacker or an unlucky burst lands right on it. The number you advertise stops being the number you actually hold callers to, which is why nobody ships this for a real API.
Approach
Fix the boundary problem by always looking at the trailing window, not the current clock window. The sliding window log stores a timestamp per request and counts the ones inside the last minute, which is exact. The lighter sliding window counter blends the current partial window with a weighted slice of the previous one, approximating the same trailing view with two integers instead of a list.
Challenges
The exact log is memory-heavy. It keeps one entry per request, so a client doing thousands of requests a second stores thousands of timestamps per bucket, and the limiter is supposed to be cheap. The counter variant fixes the memory at the cost of being an approximation. And neither version has a real notion of a burst. They cap a rolling count, which is close. But there is no clean way to say "you may burst up to N, then refill at rate r," and that is the exact shape API traffic takes.
Approach
Give each client a bucket that holds up to N tokens and refills at a steady rate r tokens per second. Every request takes one token; a request that finds the bucket empty is rejected. The bucket size N is the burst the client may spend at once, and the refill rate r is the sustained rate they settle to. This matches API traffic. A quiet client lets tokens accumulate to the cap and can then fire a burst, and a client hammering steadily is held to r once the saved tokens run out. The state is two numbers, a token count and a last-refill timestamp, so it is far cheaper than a per-request log. The refill is lazy. Instead of a background job topping up every bucket, the check itself adds r * elapsed_seconds tokens (capped at N) using the timestamp, so an idle bucket costs nothing until it is touched.
Challenges
It needs careful clock handling, since the refill is computed from elapsed time. The token count and timestamp have to move together as one unit or two nodes race on them (deep dive two). It also rejects hard rather than queuing: a request that misses gets told to come back later. For an API that is the right call. Holding a request open to wait for a token would pin a connection, whereas a 429 with Retry-After hands the waiting back to the client, where it belongs. A close cousin, GCRA, computes the same behavior from a single "theoretical arrival time" value if you want to avoid storing the count explicitly.
| Algorithm | Strengths | Weaknesses |
|---|---|---|
| Fixed window | One integer per client; dead simple to reason about | A boundary burst admits 2x the limit; the reset is a cliff an attacker lands on |
| Sliding window log | Exact trailing-window count; no boundary problem | One stored entry per request; memory grows with the request rate |
| Sliding window counter | Two integers, cheap; smooths the boundary | An approximation, not exact; does not model bursts as a concept |
| Token bucket (the pick) | Burst up to N, then sustain r; state is just a count and a timestamp; lazy refill, so idle buckets cost nothing | Needs clock and atomicity care; a hard cutoff with no built-in queue |
The Redis lesson covers the in-memory counters and atomic operations these algorithms lean on.
2) The limiter runs on 100 gateway nodes. How do we not over-admit?
This is the deep dive the design exists for. The decision runs on a fleet, call it 100 gateway nodes, and they all enforce the same client's limit. The question is how they share the count without either drifting wildly over the limit or racing each other into admitting too much. The arithmetic makes the failure concrete: if each of 100 nodes keeps its own local allowance, the fleet collectively admits 100 times the intended rate. Work up through it, because the obvious shared-counter fix still has a race hiding in it.
Approach
Every gateway node tracks each client's usage in its own memory and enforces the limit locally. No shared store, no network hop, the fastest possible check.
Challenges
There is no global limit at all. A client capped at 100 requests a second, hitting a fleet of 100 nodes spread evenly, is allowed 100 a second on each, so 10,000 a second get through, a hundredfold over the cap. The limiter advertises a number it cannot enforce, and the bigger the fleet the worse the gap. This is the n-node fleet admitting n times the limit, and it is why the state has to be shared rather than per-node.
Approach
Move the count to a shared store so the whole fleet enforces one limit. Each node reads the client's token count from Redis, checks whether a token is free, and if so writes back the decremented count. One source of truth, and the fleet now shares a single allowance instead of one per node.
Challenges
The read and the write are two separate steps with a gap between them, and that gap is a race. Two nodes handling the same client both read "1 token left," both see a free token, both decrement, both admit, so a bucket with one token serves two requests. This is a time-of-check-to-time-of-use race: the world moves between the moment you look and the moment you act, and under load it admits steadily over the limit. So the shared store closed the fleet-wide drift and opened a fresh hole, the concurrent read-modify-write.
Approach
Collapse the read, refill, check, and write into a single atomic operation that Redis runs server-side with nothing able to interleave. A Lua script does exactly this. Redis executes it as one indivisible unit, so the whole "refill by elapsed time, take a token if one is there, write back" sequence happens with no gap for a second node to slip into. Every gateway node makes one round trip per request, hands Redis the client id and the limit policy, and gets back allow or deny. Close the window between the check and the act and the race has nowhere to live. The bucket holds a token count and a last-refill timestamp, and the script refills lazily from the elapsed time so idle buckets cost nothing.
Challenges
The atomicity is exactly within one bucket key, so the script and the key must live on the same Redis node. That works because we shard by client id, and a bucket is only ever one key. Redis itself is now a hard dependency on the hot path, which the next deep dive hardens. And the script has to be correct: refill before checking, cap the refill at the bucket size, and update the count and timestamp together. Get the order wrong and you either leak tokens or never refill.
-- KEYS[1] = bucket key for this client-- ARGV: capacity, refill_rate (tokens/sec), now (sec), costlocal capacity = tonumber(ARGV[1])local refill_rate = tonumber(ARGV[2])local now = tonumber(ARGV[3])local cost = tonumber(ARGV[4])local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')local tokens = tonumber(state[1])local ts = tonumber(state[2])-- first time we see this client: start with a full bucketif tokens == nil then tokens = capacity ts = nowend-- lazy refill: add rate * elapsed, capped at capacitylocal elapsed = math.max(0, now - ts)tokens = math.min(capacity, tokens + elapsed * refill_rate)local allowed = 0if tokens >= cost then tokens = tokens - cost -- take the token(s) allowed = 1endredis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)-- let the bucket expire if untouched, so idle clients cost no memoryredis.call('EXPIRE', KEYS[1], math.ceil(capacity / refill_rate) + 1)-- retry_after = seconds until one token refills (0 when allowed)local retry_after = 0if allowed == 0 then retry_after = (cost - tokens) / refill_rateendreturn { allowed, retry_after }The handling contention material and the Redis lesson cover atomic server-side operations and why a read-modify-write across the network races where a single scripted operation does not.
3) Fairness across client tiers, and graceful degradation under fleet stress
Per-client buckets already buy the base fairness: one client's burst spends only their own tokens, so a single spiking client cannot drain a small client's allowance. But two harder questions sit on top. Clients are not equal, so a large paying account should get a bigger bucket than a free-tier one. And when the whole fleet is hot, not just one client, isolation alone is not enough. Every client can honor their own limit and the total can still add up to more than the API can serve. We need to protect the most important traffic when the system as a whole is under stress.
Tiering is the easy half. The bucket size and refill rate live in the LimitPolicy, looked up per client, so a bigger tier simply gets a larger N and a higher r. Tuning a row of config does it. No code moves, and the mechanism stays uniform. Everyone runs the same token-bucket script, only the numbers differ. The harder half is fleet-wide stress, and the answer layers more limiters on the request rate limiter we already built.
Request rate limiter
The token bucket per client we have been building. It caps how fast any one client can call, and it is the main line of defense against a single client overwhelming the API.
Concurrent request limiter
A cap on how many requests a client has in flight at once. Arrival rate is the rate limiter's job; this one watches what is still running. A handful of slow, expensive calls can tie up capacity even at a low request rate, so this bounds the simultaneous in-flight work per client. It is a second small counter, incremented when a request starts and decremented when it finishes.
Fleet load shedder
When the entire fleet is busy, reserve a slice of capacity for critical request types by shedding non-critical traffic first. Bulk and list calls get rejected before a write that moves real state does, so the path that matters stays alive while the fleet is saturated.
Worker utilization shedder
As a single node's own utilization climbs toward its ceiling, it sheds requests by priority, dropping the lowest-priority work first. This is the last backstop, protecting an individual node from tipping over even after the other limiters have done their part.
Approach
Run one token bucket per client, with the bucket size and refill rate drawn from the client's tier. A large account gets a deep bucket and a fast refill, a free-tier client gets a modest one. Each client is isolated, so a burst from one spends only their own tokens and never another's.
Challenges
This handles one client overrunning their share, but it is blind to fleet-wide load. If many clients each stay inside their own limit but the sum exceeds what the API can serve, every bucket says "allowed" while the fleet falls over. Walling clients off from each other does nothing to keep the fleet as a whole from drowning.
Approach
Layer four limiters. The request rate limiter and a concurrent request limiter bound each client. On top, two shedders watch system-wide health. When the fleet is hot, the fleet shedder reserves capacity for critical request types and rejects non-critical traffic first. As an individual node nears its ceiling, the worker utilization shedder drops the lowest-priority requests. Tag each request type with a criticality, so a write that changes real state outranks a bulk list call. Then, under pressure, shed from the bottom of that ranking instead of cutting everyone equally. A small client trying to complete an important write during a fleet-wide spike still gets through, because their critical request outranks a big account's bulk export.
Challenges
More moving parts and a real classification problem: someone has to decide and maintain which request types are critical, and a misclassification sheds the wrong traffic. The shedders also need a fast, current read of fleet and node health to act on, which is its own small system. The payoff is that the API degrades gracefully under stress, protecting the requests that matter, instead of tipping over or starving small clients when a large one surges.
4) What happens when Redis itself is the bottleneck or down?
The limiter now depends on Redis on the hot path of every request, which means Redis is a single point that can take the whole API down with it. The interviewer will push here, because a limiter that becomes the outage is worse than no limiter. There are two questions: what we do when Redis is unreachable, and how we keep one Redis node from becoming a bottleneck as the fleet grows.
Start with the unreachable case, because the obvious framing is a false binary. Failing open means allow all traffic when Redis is down, which risks overloading the API exactly when it may already be unhealthy. Failing closed means reject all traffic, which turns a Redis blip into a full API outage, the limiter killing the thing it protects. Neither extreme survives contact with production. A better answer is a local approximate fallback that degrades gracefully instead of picking one of the two cliffs.
Approach
Pick one: on a Redis timeout, either reject every request until Redis returns (fail closed) or allow every request (fail open). Simple, one branch in the code.
Challenges
Fail closed makes the limiter the outage. A momentary Redis hiccup, a failover that takes a few seconds, turns into every client getting 429s, so the tool built to protect availability is the thing taking it down. Fail open drops the protection at the worst possible time, since Redis tends to struggle precisely when traffic is already high and you need the limit most. Each pure choice just swaps one disaster for the other.
Approach
When the shared store is unreachable, each node falls back to a local in-process token bucket sized conservatively. The fleet still bounds load roughly, admitting somewhat over the true global limit but nowhere near unbounded. This is the degraded mode the system drops into under failure, and it lands on "mostly protected" instead of an all-or-nothing cliff. For the bottleneck side, shard the bucket keyspace across many Redis nodes by client id, so no single node sees the whole fleet's traffic. Each shard keeps a replica that can take over on a node failure. Because a bucket is only ever touched under its own client key, sharding by client costs nothing in correctness. The limiter is far cheaper than the work it guards: one in-memory round trip, compared with a real downstream call. In normal operation it stays well clear of being the bottleneck.
Challenges
The local fallback is approximate by construction: with the shared count gone, n nodes each enforcing a local cap admit more than the global limit, which is the deliberate price of staying available. Replica failover has a short window where a few writes can be lost, which for a rate limiter means a handful of buckets briefly reset, a harmless over-admission. And the fallback needs to be exercised, since a degraded path that has never run is the one that fails when you finally need it.
The Redis lesson covers replication, failover, and why an in-memory store is the right fit for hot, expendable counters like these.
What is expected at each level
Microsoft gates the distributed design round at Senior (L63+) and in Azure orgs, so the depth expected scales hard with level. Here is how much you need to land, by the level you are interviewing for.
SDE II (L61/L62)
Mostly breadth, and at this level a full distributed round is not guaranteed. Get to a working single-node limiter: a token bucket per client, a request takes a token, an empty bucket returns 429 with Retry-After. You should be able to explain why token bucket suits bursty API traffic and contrast it with a fixed window's boundary problem. The interviewer drives the distributed and failure parts. The bar: a clean account of one token bucket producing a correct allow-or-reject and a sensible 429, even if the multi-node and Redis-outage cases need prompting.
Senior (L63/L64)
This is where the design round is really gated, so breadth then real depth on the distributed core. You are expected to reach the shared-state answer on your own. That means the bucket in Redis, the over-admission race when two nodes read-modify-write the same bucket, and the atomic Lua script that closes it. You should pick token bucket and defend it against the alternatives, and add per-client buckets sized by tier for fairness. The bar: you find the over-admission race yourself and resolve it with an atomic check-and-decrement. You justify Redis as a deliberate choice for a hot, expendable counter rather than reaching for a database.
Principal (L65+)
Depth across the hard parts and a widening scope. Expect to lead on the layered limiter stack and on load shedding by request criticality, so the writes that matter survive a fleet-wide spike. Expect the fail-open-versus-fail-closed tension and the local approximate fallback that resolves it. Expect Redis as a dependency to harden through sharding and replicas. And expect the rule that the limiter must be cheaper than the work it guards. You raise fairness and degradation before anyone asks, and you keep driving while the interviewer redirects rather than rescues. The bar: surviving a Redis outage and protecting critical traffic under stress sit at the center of your design, never tacked on as edge cases. And you reason about the fleet's behavior end to end.
Key Takeaways
- ✓Rate limiter is the most-asked Microsoft design prompt, often framed as Azure throttling. Token bucket fits an API because traffic is bursty but bounded. The bucket size absorbs the burst, the refill rate holds the sustained rate, and the state is just a token count plus a timestamp. A fixed window leaks 2x the limit at the boundary, and naming that trap unprompted is the classic-follow-up win.
- ✓The limiter runs on a fleet, so the bucket lives in shared Redis. A read-modify-write in app code races (two nodes both see a free token); make the check-and-decrement atomic with a Redis Lua script so there is no gap between the check and the act.
- ✓Per-client buckets give base fairness. Size them by tier, and layer more limiters (request rate, concurrent, fleet shedder, worker shedder) to shed non-critical traffic first and keep the writes that matter alive under fleet stress.
- ✓Do not fail open or fail closed on a Redis blip. Fall back to a local approximate bucket, shard and replicate the store by client id, and keep the limiter far cheaper than the work it protects.
Microsoft interview guideKnow what Microsoft asks before you walk in.
21 coding · 3 system design · 24 behavioral questions
Get their questions first.
InterviewLegend is independent and not affiliated with or endorsed by any company it covers.