InterviewLegend

Design a Rate Limiter

Frequently asked
Alex Chen44 min readUpdated 299 real interview reports
In this lesson
  • Design a distributed rate limiter with per-key tiers that returns a 429 and Retry-After
  • Pick the counting algorithm, then keep one count correct across many instances
  • Go deep on atomic updates, local token leases, and failing open versus closed

Understanding the problem

The prompt

Design a rate limiter that sits in front of a service and protects it from being overwhelmed. It caps how many requests any one client can make in a window. The limit is per API key, with tiers, so a free caller, a paying customer and an internal service each get their own number. A caller over its limit gets a 429 and a Retry-After header that tells it when to come back. The check adds almost nothing to the latency of the requests that pass. The catch is the shape of the service. It runs as many instances behind a load balancer, and all of them have to enforce one shared limit, so a client cannot collect its full allowance from every instance at once. Handle the counting algorithm, then the shared count across instances, then what the limiter does when its own store is down.

This is the design most teams reach for first when they want to protect something, so it shows up early and often in a senior round. The idea is small enough to state in a sentence and deep enough to fill an hour. A rate limiter guards a service from abuse and from accidental overload. Think of a client stuck in a retry loop, a scraper hammering an endpoint, a paid tier trying to spend more than it bought. The interviewer is rarely testing whether you know what a token bucket is. They are testing whether you can keep one limit correct when the service is a fleet of instances and the thing counting the requests can itself fail.

The Cisco angle
At a networking and infrastructure company, rate limiting is a control-plane primitive they build into the products they sell, not a toy prompt. A firewall polices flows. An API gateway in front of a security cloud or a monitoring service caps how hard any tenant can push. The dashboard API behind a cloud-managed network hands out a request budget per key, and the path-monitoring API does the same. So an interviewer here has almost certainly shipped or operated a limiter, and a generic token-bucket recital gets probed fast. The two places they push are the ones that separate a diagram from a system. Keeping the count exactly right when many instances share one limit. And deciding what the limiter does at the worst moment, when its own counter store is unreachable and traffic is still arriving. Get those two right and the rest follows.

Functional requirements

Keep the core tight and let the interviewer widen it. There are four jobs. First, decide allow or reject for every incoming request, fast, before it reaches the service. Second, enforce a limit per identity, usually per API key or client id, sometimes per IP for anonymous traffic. Third, support tiers, because a free caller does not share one number with a paying customer or an internal service. Fourth, when a request is rejected, return a 429 with a Retry-After so a well-behaved client backs off instead of hammering harder. One requirement carries more weight than it looks. The limit is global across the fleet, not per instance, so the count has to be shared.

In scope

  • Decide allow-or-reject for every request before it reaches the service, adding almost no latency to the ones that pass
  • Enforce a limit per identity: per API key or client id, and per IP for anonymous callers
  • Support tiers and named rules, so a free key, a paid key, and an internal caller each get their own budget
  • Return 429 with a Retry-After header on rejection, so clients back off cleanly instead of retrying in a tight loop
  • Keep one shared limit correct across many service instances behind a load balancer

Below the line (name it, then move on)

  • Authentication and identifying the caller. We assume the request already carries a resolved client id or key; issuing and validating keys is a separate system
  • The load balancer and TLS termination in front of the limiter. We take the request as it is delivered to us
  • Billing and quota accounting over long periods (a monthly plan). We enforce a short-window rate, not a monthly usage ledger
  • DDoS scrubbing and network-layer flood protection. Volumetric attacks are absorbed upstream; we limit application requests by identity
  • Full per-endpoint routing and API management. We are the limiting stage, not the whole gateway around it

Non-functional requirements

Reason each target from what the limiter is for. Added latency leads, because this thing sits in the hot path of every single request, so whatever it spends is spent on all of them. A budget under a millisecond is the goal, small enough to vanish against the work the service itself does. Correctness comes next, and here it means one thing: the limit holds across the whole fleet. A client with a hundred-per-second budget can't quietly collect a hundred from each of ten instances. Then availability, and this is the sharp one. The limiter guards the service, so if the limiter falls over it must not take the service down with it. That forces a decision, fail-open or fail-closed, that we come back to. Last, the numbers can be slightly soft. Letting a few requests over the line during a race is usually fine; being exact to the single request rarely is what matters.

Bank this slack now: approximate is often good enough. A limiter exists to stop abuse and overload, not to meter a billing charge to the exact request. If a client rated at a hundred a second occasionally gets a hundred and three because two instances raced, nothing breaks. That tolerance lets a limiter keep a small local cache and touch the shared store a little less often, trading a sliver of exactness for a lot of latency and load. We will spend that slack deliberately later, on local pre-checks and on the sliding-window approximation. Where it does not apply, on an endpoint that costs real money or mutates real state, we tighten back up and pay for exactness.

  • ~100K/sec requests through the limiter. Every one pays a limit check, so the check has to be cheap in latency and in load. Illustrative teaching figure, not an audited count.
  • < 1 ms added-latency budget per check. Small enough to disappear against the service's own work. The limiter is invisible on the happy path or it is a problem.
  • ~1M keys registered clients. Roughly a hundred thousand active in any window, each needing a tiny scrap of counter state. Storage is trivial here.
  • ~100 bytes state per active key. A token count and a timestamp. The store's op rate is the pressure, not the bytes it holds.

Sizing the check

Put the numbers on the board before the boxes, and derive them out loud, since a good interviewer redoes the arithmetic with you. Treat every figure as an illustrative teaching estimate. Say the fronted APIs peak at a hundred thousand requests a second. Every one of those requests hits the limiter first, so the limiter does a hundred thousand checks a second, all day and all night. That single fact sets the tone. The check has to be cheap in both latency and load, because it happens on everything, not on a sampled slice.

Now size the state, and watch where the pressure actually lands. A limit is enforced per key, and the state for one key is tiny. For a token bucket it's a couple of numbers, the tokens left and the instant they were last refilled, call it a hundred bytes. Say a million keys are registered and a hundred thousand are active in any given window. A hundred thousand keys at a hundred bytes is ten megabytes. Even all one million at once is a hundred megabytes. Storage is a rounding error. The pressure is somewhere else, and you should say where. It's the op rate against the shared store: a hundred thousand reads-and-writes a second aimed at a handful of hot keys, plus the network hop each one costs on the request path.

  • The check rate: ~100K checks/sec. one per request. The limiter is in the hot path of everything, so the per-check cost, not the bytes stored, is the whole game.
  • State size: ~10 MB active. a hundred thousand keys at ~100 bytes each. Memory is trivial. A rate limiter is not a storage problem.
  • Store ops: ~100K ops/sec. if every check is a round trip to a shared counter, that is near the ceiling of a single store node, which is why hot keys and sharding come up.
  • Added latency: < 1 ms target. a network round trip to the store is ~0.5 ms and up, enough to double a fast endpoint's latency, so you co-locate or you cache.

Two lines in that grid set the agenda. The check happens on every request, so its latency and its load on the counter store are the whole problem, not the bytes it stores. And a shared store touched a hundred thousand times a second is where correctness and hot keys collide. So the design keeps a few things true at once. The check stays under a millisecond. The count stays correct across every instance. And the limiter degrades to something safe when its store is unreachable. Each deep dive below is one of those.

The set-up

Core entities

Name the objects before drawing anything. Four carry this design, and the one to pin down first is the rule, because everything else is in service of enforcing it.

1

Client

The thing being limited, identified by an API key or client id, or by IP when the traffic is anonymous. Its identity is the key the limiter counts against, so resolving it correctly, and not letting one client pose as many to multiply its budget, sits upstream of everything here.

2

Limit rule

The budget for a class of client: a rate and a burst (a hundred requests a second, bursting to two hundred), attached to a tier. There are only a handful of these. They change rarely and live in a small config store the limiter reads and caches in process.

3

Counter (a bucket)

The live state for one client against one rule. For a token bucket it is the tokens remaining and the last-refill time. There is one per active client, so millions could exist, and it is read and written on every single request. This is the object the shared store holds.

4

Decision

The output of a check: allow or reject, plus a Retry-After when rejected. It is computed fresh per request and never stored. The whole system exists to produce this one small answer as fast as it can, then get out of the way.

Pin the stores next, because the interviewer will ask where each piece lives. The counters live in a fast in-memory store, Redis, keyed by client and rule. Every request does a read-modify-write on a single tiny key, which is exactly what an in-memory store is built for. The limit rules live in a small durable config store the limiter loads and caches in process, since there are only a few and they change rarely. There is no big database here and no queue. A rate limiter is a decision on the request path, not a data pipeline, so adding storage it does not need only adds latency it cannot afford.

API

Keep the surface thin. The limiter is mostly invisible, a stage the request passes through, so its real interface is the decision it returns and the header it sets when it says no. There is also a small admin surface for the rules. Reference these by name in the deep dives.

The interface
RequestServer push
ClientLimiterevery request passes through the limiter, which lets it through or rejects it based on the caller's remaining budget.
any request carrying an API key or client id
allowed (continues to the service) or 429
the limiter is inline, so this is not an extra call the client makes; it is the check on the call it already made.
LimiterClienton rejection, tell the client how long to wait before it retries.
429 Too Many Requests + Retry-After: <seconds>
a Retry-After turns a rejected client into a patient one; without it, a rejected client often retries at once and makes the overload worse.
read the remaining budget without spending it, for a client that wants to self-throttle.
GET /v1/limit?key=<id>
{ limit, remaining, reset }
the same numbers many APIs expose as rate-limit headers; a well-behaved client reads them and paces itself.
define or change a limit rule or a tier without a redeploy.
PUT /v1/rules/{tier}
{ tier, rate, burst }
a handful of rules, changed rarely; the limiter reloads them and caches them in process.

One of these carries the design, and it is the first one. The allow-or-reject check runs on every request and has to finish in well under a millisecond. Everything hard about the limiter hides inside that one line. Where does the counter live, how does the count stay correct across instances, and what does the check return when the counter store is unreachable? The Retry-After on the reject path is the small detail that separates a limiter that calms traffic from one that amplifies it. Reject without it and clients retry in a tight loop, turning your defense into a second wave of load.

High-level design

Follow one request through. A Client sends a call, and it arrives at the API gateway + limiter before the service ever sees it. The limiter resolves the caller's identity, loads the matching Limit rules (cached in process), and does the real work: a read-modify-write against the Counter store to see whether this caller has budget left. If it does, the request passes to the Protected service and a token is spent. If it does not, the limiter stops the request right there and returns a 429 with a Retry-After. The diagram colors each box by role (blue for the client, violet for the gateway and the service, green for the two stores), and the walkthrough reuses the same tags.

Loading diagram...

1) The request arrives at the limiter first. A Client makes an ordinary API call, and the API gateway + limiter intercepts it before the Protected service sees it. The limiter resolves the caller's identity from the API key or client id on the request. Everything downstream keys off that identity, so getting it right, and not letting one caller pose as many to multiply its budget, is the quiet precondition for the whole design.

2) The rule is loaded, cheaply. The limiter looks up the caller's Limit rules: which tier, what rate, how much burst. Because there are only a handful of rules and they change rarely, the gateway caches them in process, so this step is a memory read and not a network call. The interviewer rarely lingers here, and that is the point of caching it. One less thing on the hot path.

3) The counter check is the real work. The limiter does a read-modify-write against the Counter store for this caller: how many requests or tokens are left in the current window, decrement, decide. This one operation is where the design lives. It has to be atomic, so two instances checking the same key at the same instant cannot both think there is room. And it has to be fast, because it happens on every request. The first two deep dives are both about this line: what the counter math should be, and how to make it correct across the fleet.

4a) Allowed: the request passes through. If the caller has budget, the limiter lets the request continue to the Protected service and records the spend. From the client's point of view nothing happened. The check added a fraction of a millisecond and got out of the way. That invisibility on the happy path is a requirement, not a nicety, because the happy path is almost all of the traffic.

4b) Rejected: a 429 with a Retry-After. If the caller is over its limit, the limiter stops the request at the gateway and returns a 429 with a Retry-After telling the client when its budget refills. The service never sees it. The Retry-After matters more than it looks. It is what turns a rejected client into a patient one, instead of one that retries in a tight loop and makes the overload worse.

5) When the counter store is unreachable. The one path the diagram does not draw is the failure path, and it is the most important call in the design. If the Counter store is down or slow, the limiter cannot do its check, and it has to decide in that instant whether to allow the request anyway (fail-open) or reject it (fail-closed). That decision, and the clock and hot-key problems that ride alongside it, is the last deep dive.

Technology choices

Naming the box is the easy part; the work is picking a concrete tool, defending why its class fits, and knowing how it breaks under this exact load. Put a common pick in each slot, and expect every follow-up to come out of the failure modes.

  • Counter store is an in-memory data store: Redis, because the access pattern is a read-modify-write on one tiny key, millions of times a second, and an in-memory store serves that in microseconds. To make it correct, do the whole check inside a Lua script that Redis runs atomically. The read, the refill math, the decrement, and the decision happen as one indivisible step, and no two instances can race on the same key. The failure story is the store itself going down, which is the fail-open versus fail-closed decision, and a hot key overloading one shard, which pushes you toward local pre-checks and key sharding.
  • The limiter runs inside an API gateway (Envoy or NGINX, or a managed cloud gateway), its natural home because the gateway already terminates every request and already knows the caller. Putting the limit there means rejected traffic never reaches the service or even its own connection pool. The failure mode to plan for is the gateway fleet scaling out: more instances means more concurrent hitters on the same hot key, which is why the shared-counter correctness cannot be hand-waved.
  • The Limit rules live in a small config store (a Postgres table, a config service, even a versioned file) that the gateway loads and caches in process. Rules are few and change rarely, so this is not on the hot path. Get propagation right. When a tier's limit changes, every gateway instance should pick it up within seconds, so the cache carries a short TTL or a push invalidation rather than living forever.
  • For the counter math itself, the default is a token bucket held as a couple of fields per key. It is the practical choice because it bounds the sustained rate while still allowing a controlled burst, which is how real traffic actually behaves. A sliding-window counter is the approximate alternative when you want smoother enforcement without storing every request. Both are a few bytes of state and an O(1) check, which is what keeps the limiter cheap on the hot path.
The Cisco angle
Reach for Redis with a Lua script here and be able to say why the script matters, because at an infrastructure company the interviewer knows the naive version has a race. A plain read-then-write against a shared counter (read the count, add one, write it back) is two round trips with a gap in the middle. Under real concurrency two gateway instances read the same value, and both allow a request that should have pushed the caller over. Folding the whole operation into one atomic script closes that gap. Redis hands you the atomic primitive, and a correct distributed limiter is still yours to build. Deciding what to make atomic, how to keep the round trip cheap, and what to do when Redis itself is unreachable is the design, and that is the part they are testing.

Potential deep dives

The deep dives are where the rest of the score gets decided. A rate-limiter round almost always pushes on the same three corners. Which counting algorithm do you pick and why, how do you keep one count correct across a fleet, and what does the limiter do when its own store fails? The middle one is the correctness bar an infrastructure company reaches for first. The last one is the judgment call they most want to watch you make.

1) The counting algorithm: from fixed windows to a token bucket

Start with how you actually count, because the naive version has a flaw the interviewer will find in about thirty seconds. The job sounds simple: allow a client up to N requests per window, reject the rest. The trouble is what "per window" means. A fixed clock window is the obvious reading and the wrong one, and walking from there to a token bucket is the arc the round wants to see. Show the counter math at each step, because that is where the flaw and the fix both live.

BadA fixed window counter

Approach

Keep one integer per client per clock window, say per minute. On each request, increment the counter for the current minute; if it is over the limit, reject; when the minute rolls over, the counter resets to zero. It is the first thing anyone writes, it is one increment and one comparison, and for a rough cap it works.

Challenges

It allows a burst of up to twice the limit across a window boundary, and that is not a corner case. It is the common one. Take a limit of a hundred a minute. A client fires a hundred requests at 11:00:59, all counted in the 11:00 window and allowed. Then it fires a hundred more at 11:01:00, a fresh window, counter reset, all allowed. That is two hundred requests in about a second across the boundary, and the limiter waved every one of them through. A client that wants to overload you simply learns to aim for the seam. The fixed window has no memory of the requests that happened a moment ago on the other side of the line.

GoodA sliding window, by log or by counter

Approach

Make the window slide with the request instead of snapping to the clock. The exact version is a log. Store the timestamp of every request for a client. On each new one, drop the timestamps older than the window, count what's left, and allow only if it's under the limit. Now the window is always the last sixty seconds ending right now, so the boundary burst is gone. The cheaper cousin is a sliding-window counter. It keeps just the current and previous window counts and weights the previous one by how much still overlaps: count equals this-window plus last-window times the fraction still in view. Seventy-five percent into the current minute, it reads the current count plus a quarter of last minute's.

Challenges

The log is exact but expensive. It stores one timestamp per request in the window, so a client doing a thousand a second holds sixty thousand timestamps, and every check scans and trims them. On a hot key that memory and work pile up fast. The sliding-window counter fixes the cost by approximating, but the approximation assumes the previous window's requests were spread evenly, which they were not, so it drifts a little around a real burst. Both beat the fixed window. Neither one naturally expresses the thing real traffic actually needs, which is a steady allowed rate that still tolerates a short, bounded spike.

GreatA token bucket

Approach

Model the limit as a bucket of tokens. The bucket holds up to B tokens (the burst) and refills at R tokens per second (the sustained rate). Each request removes one token; if the bucket is empty, reject. You do not run a timer to refill. You compute it lazily on each request from the elapsed time: tokens equals the minimum of B and the tokens you had plus the seconds since you last looked times R. Store just two things per client, the token count and the last-refill timestamp, and the whole check is that one line of math. Say a client is rated at a hundred a second with a burst of two hundred. It can fire two hundred at once when its bucket is full, then settles to a hundred a second as the bucket refills at that rate. That is exactly how you want a limit to feel: forgiving of a normal spike, firm on the sustained average.

Challenges

The two parameters, rate and burst, are a real choice you reason about per endpoint. Too small a burst and you reject legitimate spikes; too large and you let a client concentrate a big slug of load. The state is tiny and the check is O(1), which is why this is the default. But the refill math depends on time. Once this bucket lives in a store shared by many instances, you inherit two problems the single-node version hides. The read-modify-write has to be atomic, and everyone has to agree on what time it is. Those are the next two dives. A sibling worth naming is the leaky bucket, which drains at a fixed rate and smooths output instead of allowing bursts. The token bucket wins when you want to permit the burst, which is usually.

Fixed windows are simple and wrong: they leak a double burst across every boundary. Slide the window to kill that burst, then reach for a token bucket as the default. It bounds the sustained rate while still allowing a controlled spike, for two numbers and one line of math per client. The token bucket stands out on the list. Its two knobs, rate and burst, map onto how you actually want to describe a limit, and its tiny state keeps the check cheap on the hot path.

2) One limit across a fleet: the shared counter

Now put that algorithm behind a load balancer, because that is where a clean single-machine limiter quietly stops being correct. The service is not one process. It is many gateway instances, and a client's requests land on whichever one the balancer picks. If each instance counts on its own, the limit multiplies by the number of instances, which is not the limit anyone asked for. This whole dive is about making N instances enforce one number, and doing it without a round trip on every request wrecking the latency budget.

BadLocal per-instance counters

Approach

Let each gateway instance keep its own token bucket in local memory, with no shared state at all. It is the fastest possible check, a memory access with zero network cost, and if traffic were pinned to instances it would be perfect.

Challenges

The limit is now per instance, not global, so it silently multiplies by the fleet size. Ten instances sit behind the balancer, and the rule says a hundred a second. A client spreading its requests across all ten collects a hundred from each: a thousand a second against a limit of a hundred. It gets worse as you scale out, because adding instances to handle load also loosens every limit at the same time. You could pin each client to one instance with sticky routing. Then you've traded the limiter's correctness for a routing constraint that fights load balancing and falls apart when that instance dies.

GoodA shared counter in Redis

Approach

Move the count into one shared store every instance reads and writes, Redis. Now there is a single counter per client that all instances share, so the limit is global again regardless of which instance a request lands on. The obvious implementation is an increment on the client's key with an expiry to reset it each window, or a read of the bucket followed by the refill math and a write back. Every instance sees the same count, and the multiplication problem is gone.

Challenges

Two things bite. First, correctness under concurrency. If the check is a read, a decision and a write as separate calls, two instances can read the same value at the same instant. Both see room and both allow, so the limit leaks a little under exactly the load it most needs to hold. The plain increment-and-expire has its own version of this, where the expiry can be lost and a key lives forever. Second, latency and load. Every request is now a network round trip to Redis, half a millisecond or more. A hundred thousand requests a second is a hundred thousand Redis ops a second, which is near what one node wants to do. The shared store fixed correctness of placement but opened correctness of concurrency, and it put a network hop on the hot path.

GreatAn atomic Lua bucket, with a local token lease

Approach

Close the race by making the whole check atomic, and cut the round trips by not always making them. For atomicity, run the read, the refill, the decrement and the decision as one step in a single Lua script, with no interleaving. No two instances can slip between a read and a write anymore, because there's no gap. For latency and load, keep a small local token cache in each instance. An instance leases a little bundle of tokens from Redis and serves the next few requests locally, only going back to the store when its lease runs low. That turns one Redis call per request into one per batch. It leans on the slack banked earlier, that a few requests over the line is fine, to buy a large cut in latency and store load. For the hottest keys, shard the counter across Redis nodes or approximate harder, so no single key is the whole system's bottleneck.

Challenges

The atomic script is non-negotiable and cheap; the trade is in the local lease. Leasing tokens locally means an instance can hold unspent budget while another instance is starved. The global limit becomes slightly approximate and slightly bursty at the seams. That's the price of getting off the network on every request. You tune the lease size to taste: larger leases mean fewer round trips and looser accuracy, smaller ones the reverse. And you reserve the strict, every-request-atomic path for the endpoints where exactness genuinely matters, spending the approximation only where the slack is real.

The bug hiding in a shared counter is the read-modify-write race, and it leaks under exactly the load the limiter is there to stop. Read the count, see room, write back the increment: if two instances do that at the same instant, both read the pre-increment value and both allow. A hundred-per-second limit quietly passes a hundred and two, or a hundred and ten under heavy concurrency. The fix is not a lock around the counter, which would add its own round trip and failure mode. It is to make the read, the decision and the write one atomic operation the store runs for you, which is what a Lua script buys. The busier the client, the more instances hit its key at once, and the more a non-atomic check leaks.
The Cisco angle
Networking and infrastructure interviewers listen for the distributed-counter correctness bar. It separates a limiter that works on a laptop from one that works behind a load balancer. Anyone can draw a token bucket. The senior signal is naming, unprompted, that a read-then-write against a shared counter races under concurrency, and reaching for a single atomic operation to close the gap. The reason it lands here is that this company runs fleets. The gateway fronting a monitoring API or a security cloud is dozens of instances. "Roughly a hundred a second" won't do when the limit protects a control plane or gates a paid tier. Walk from local counters to a shared store to one atomic script, and say what each step fixed and what it cost. That's the exact path their own systems took.

Behind a load balancer, a local counter enforces the limit times the number of instances, which is nobody's limit. Put the count in one shared store so the limit is global, and make the whole check a single atomic operation so concurrent instances can't race. Then lease tokens into a small local cache so the common request never pays a network round trip. The correctness comes from atomicity. The speed comes from spending the little bit of slack you already decided you had. Naming both halves, and what each one costs, is the bar.

3) When the limiter fails: fail-open, clock skew and hot keys

The last dive is the one that separates a design from a system, and it starts with an uncomfortable question: what does the limiter do when its own counter store is down? This is not a rare hypothetical. Redis has a bad minute, the network to it hiccups, a failover is in progress, and in that window the limiter cannot do its check. It still has to answer every request, right now, allow or reject. There is no third option and no time to think. How you answer defines the whole system's behavior at its worst moment.

BadFail closed on everything

Approach

Take the strict reading: if the limiter cannot verify a request is under its limit, reject it. Return a 429 and protect the service no matter what. It sounds like the safe and conservative choice, and for a moment it feels responsible.

Challenges

It turns a limiter outage into a full service outage, which is exactly backwards. The limiter exists to keep the service available. Failing closed on everything means a blip in a Redis you added for protection now rejects every request to a service that is otherwise perfectly healthy. You have coupled the service's availability to the availability of its own rate limiter and made the whole thing more fragile than if you had never added the limiter at all. For the overwhelming majority of traffic, an ordinary read or a normal API call, this is the wrong instinct.

GoodFail open on everything

Approach

Flip it: if the limiter cannot reach its store, allow the request through unchecked. The service stays up, the limiter gets out of the way when it is broken, and normal traffic is never punished for the counter store having a bad minute. This is the right default for most systems, and if you only get to state one rule, state this one.

Challenges

Failing open means that while the store is down, there's no limit at all. A client already abusing you, or a thundering herd of retries, sails straight through to the service unthrottled. Most of the time that is an acceptable risk for a short blip. But it is not acceptable everywhere. An endpoint that costs real money per call, that mutates critical state, or that triggers an expensive downstream job should not suddenly become unlimited because a cache is unreachable. A single global fail-open is too blunt for those.

GreatDecide per route, with a local fallback

Approach

Make fail-open versus fail-closed a per-endpoint decision, and add a backstop so you rarely face the raw choice. Fail open for the ordinary traffic that is almost everything: reads, normal API calls, anything where a brief unlimited window is survivable. Fail closed for the narrow set of endpoints where an unlimited burst is genuinely dangerous, the ones that cost real money, that mutate important state, or that fan out to something expensive. Then keep a small in-process approximate limiter in each gateway as a fallback. When the shared store is unreachable you degrade to a rough local limit instead of to no limit, catching the worst abuse while the real counter is gone. You are choosing per route what a failure should cost, and giving yourself a soft landing instead of a cliff.

Challenges

This asks you to classify your endpoints, which is real work and a conversation with whoever owns each one, and to run and reason about a second, weaker limiter for the fallback path. The local fallback is approximate, so during a store outage the limit is looser and per-instance rather than global, which is a deliberate downgrade and not a bug. The point is that you decided all of this on purpose, ahead of time, rather than discovering your limiter's failure behavior in the middle of the incident it was supposed to help with.

Two edges ride alongside the failure question, and the first is time. A token bucket refills based on elapsed seconds, and a sliding window is defined by "the last sixty seconds," so both depend on a clock. The moment the state is shared and many instances touch it, whose clock counts? If each gateway stamps the bucket with its own wall clock and the clocks disagree by even a little, the refill math drifts and the limit wobbles. The clean fix is to make the store the single source of time. Do the refill math inside the atomic script using Redis's own clock, so every instance reads the same "now" and no one's local skew leaks into the count.

The second edge is the hot key. Limits are per client, and clients are not equal. One big customer, or one abusive one, can send a huge share of all traffic against a single counter key. That concentrates load on whatever Redis shard owns that key, and a hundred thousand ops a second aimed at one slot can hot-spot a node while the rest sit idle. The mitigations stack. Pre-check locally so most requests never reach the shared key. Lean on the token lease from the last dive so a hot client is served from a local bundle. And if one key is still too hot, split it into a few sub-counters across shards and sum them, trading a little accuracy for spread. A hot key is where the abstract "shared counter" meets the physical reality that one key lives on one machine.

  • Store is down: fail open, mostly. allow ordinary traffic through unchecked so a limiter blip never becomes a service outage; fail closed only on the few endpoints where an unlimited burst is dangerous.
  • Local fallback: degrade, do not vanish. a small in-process approximate limiter catches the worst abuse while the shared store is unreachable, so failure means a looser limit, not no limit.
  • Clock authority: one clock, not many. do the refill math with the store's own clock inside the atomic script, so no instance's skew wobbles the limit.
  • Hot key: pre-check and shard. serve hot clients from a local lease and split a too-hot key across shards, so one client's key is not the whole system's bottleneck.
The Cisco angle
The fail-open call is the moment a networking company is really weighing your judgment, because they have lived the failure where a safeguard took down the thing it guarded. A limiter that fails closed on everything is the classic self-inflicted outage: the protection layer has a bad minute and rejects traffic to a healthy service. Fail open for the traffic where a brief unlimited window is survivable, which is nearly all of it. Fail closed for the handful of endpoints where an unlimited burst costs real money or breaks real state. And keep a local approximate limiter so a store outage degrades the limit rather than removing it. Say you decided this per endpoint ahead of time instead of finding out during the incident.

A limiter that fails closed can take down the service it was built to protect, so the default is fail open, with eyes open. Fail open for ordinary traffic so a store blip never becomes an outage, and fail closed only where an unlimited burst is genuinely dangerous. Keep a local approximate limiter so failure means a looser limit rather than none. Then make the store the one clock so skew cannot wobble the count, and keep hot keys off a single shard with local leases and sub-counters. The algorithm is the easy half. Surviving your own limiter's bad day is the half that gets you the level.

What is expected at each level

This design has less surface than a full data platform, which raises the bar on getting its few hard parts exactly right. Cisco keeps this round light below senior. At senior and above it becomes a genuine, level-gating discussion that often starts on a system you've shipped before moving to a prompt like this one. Picture the rungs as a gradient: a correct single-node limiter at mid, the distributed-counter correctness owned at senior, and the failure judgment carried at staff.

Mid-level engineer

Lean toward breadth, call it eighty to twenty. Land one coherent design: requests pass through a limiter at the gateway, the limiter checks a counter per client against a rule, and it returns allow or a 429 with a Retry-After. Know the algorithms and be able to say why a fixed window is not enough and why a token bucket is the usual default, with its rate and its burst. The phase that earns the pass is scale. When the interviewer says there are ten instances behind a load balancer, reason toward a shared counter store rather than local counting. A correct limiter you can defend, with the interviewer drawing out the harder corners, clears this rung.

Senior engineer

Depth takes over now, about sixty to forty, and this is the rung the round is built around. Raise the hard facts yourself. Derive the check rate out loud, note that the state is tiny but the store is hit on every request, and use that to justify an in-memory counter store and a sub-millisecond budget. Make the distributed-correctness call without being asked: a shared counter alone still races under concurrency, so the read-modify-write has to be one atomic operation, a Lua script in Redis. Flag the round-trip cost and reach for a local token lease to cut it. Reaching a near-complete design whose algorithm and shared-counter correctness and latency you handled on your own is the bar here.

Staff and beyond

Here depth runs through the whole picture, roughly forty to sixty. Own the failure story end to end. When the counter store is down, the limiter still has to answer. Fail open for ordinary traffic and fail closed only where an unlimited burst is dangerous, with a local approximate limiter as the backstop. Carry the edges too, the store as the single clock so skew cannot wobble the limit, hot keys kept off one shard with local leases and sub-counters. Hold the algorithm and the shared-counter correctness and the failure behavior as three separate levers, and know which bit of slack you are spending at each. You steer, and the interviewer nudges rather than rescues. The bar is keeping the check under a millisecond and the count correct across the fleet through every refinement, and having decided the failure behavior on purpose before the failure.

Key Takeaways

  • ✓A rate limiter is a decision on the hot path of every request, so the whole design is organized around making one small check, allow or reject, cheap and correct. Derive the check rate early. A hundred thousand requests a second is a hundred thousand checks a second, so the state is tiny but the store op rate and the added latency are the real constraints.
  • ✓Get the algorithm right by walking it. A fixed window leaks a double burst across every boundary, and a sliding window fixes that. A token bucket is the practical default: its two knobs, rate and burst, bound the sustained rate while allowing a controlled spike, for two numbers and one line of math per client.
  • ✓The correctness bar is distributed counting. Behind a load balancer, a local counter enforces the limit times the instance count. Move the count to a shared store and make the whole read-modify-write one atomic operation (a Lua script in Redis) so concurrent instances can't race. Lease tokens into a local cache so the common request skips the network round trip.
  • ✓The judgment call is failure. When the counter store is down the limiter must still answer. Fail open for ordinary traffic, since a limiter that fails closed can take down the service it protects. Fail closed only where an unlimited burst is dangerous, and keep a local approximate limiter as a backstop. Make the store the one clock, and keep hot keys off a single shard. Deciding all of this before the incident is the senior signal.
Had your Cisco interview?Tell us what they asked. Reviewed before it goes in, never with your name.
Cisco interview guide

Know what Cisco asks before you walk in.

19 coding · 4 system design · 15 behavioral questions

Get Premium$49 a year · Cancel anytime
Or start freeNo card.

Get their questions first.

InterviewLegend is independent and not affiliated with or endorsed by any company it covers.