Skip to content
Backend & systems

Design a Rate Limiter: A System Design Interview Walkthrough

“Design a rate limiter” sounds small until the interviewer adds a second gateway node, a retry storm, or a Redis outage. A strong answer moves in a fixed order: pin down the requirements, decide where the check runs, choose an algorithm and name its weak spot, make the shared state atomic, and define exactly what a throttled client receives. This walkthrough follows that order. The token bucket code uses an injected clock, so its output below is exact rather than timing-dependent.

By Published

9 min readEditorial analysisUpdated
  • system design
  • rate limiting
  • Redis
  • API design
  • interview practice
What to remember

Limit on a key you can trust, enforce it where the expensive work starts, and make check-and-count a single atomic step. Then decide, per endpoint, whether a limiter outage fails open or closed, and always tell the throttled client when it may retry.

Start with requirements, not algorithms

Before naming an algorithm, ask what is being protected and from whom. Is the limit per API key, per user, per IP address, or per tenant plan? Is it one rule, such as 100 requests per minute, or a sustained rate plus an allowed burst? Must the limit never be exceeded, or is approximately right acceptable? Does it apply in one region or globally? The answers decide the data structure, where the state lives, and how much accuracy you pay for.

Carry one concrete rule through the rest of the answer. This walkthrough uses a public API where each API key may burst 5 requests and then sustain 2 requests per second; the numbers are small so the trace fits on screen, and real limits come from configuration. State the non-functional requirements too: the check runs on every request, so it needs a small, bounded latency cost, and the limiter must not become the one component whose failure takes the whole API down.

  • Identity: an API key or user ID for authenticated traffic; an IP address only as a coarse fallback.
  • Rule shape: requests per window, or a rate plus a burst; per endpoint or for the whole API.
  • Accuracy: a hard cap or an approximation, and whether rejected calls count.
  • Scope: one gateway instance, one region, or every region.

Decide where the check runs

A client-side limiter is advisory only, because you cannot trust the client. A gateway or reverse proxy rejects excess traffic before it reaches expensive code: NGINX's limit_req module describes its method as a leaky bucket, while Envoy's local rate limit filter applies a token bucket. Note a default that trips people up: NGINX rejects with 503 unless limit_req_status changes it, whereas Envoy returns 429. A limiter inside the service knows business context, such as a tenant's plan or the cost of a query.

Next comes local versus global state. A per-instance limiter is fast, but behind a load balancer with N gateway instances, a client whose traffic spreads evenly can get up to N times its limit. A global limiter keeps the count in shared storage, usually Redis, and every instance consults it, as the figure shows. A practical hybrid keeps a generous local limit to shed floods cheaply and a precise global limit for the contract you publish to customers.

API gateway runs an atomic check against a shared Redis counter, forwards allowed requests to the service and returns 429 with Retry-After for denied ones
One atomic call per request decides allow or deny. Notice the separate decision for the moment Redis itself is slow or unavailable. RecallDeck diagram

Compare the five algorithms on one rule

A fixed window counter keeps one counter per key per window, such as per minute. It is cheap, but a client can send 100 requests at 12:00:59 and another 100 at 12:01:00, so 200 land within about a second while each window looks compliant. A sliding window log stores a timestamp per request and is exact, at the cost of memory that grows with the limit. A sliding window counter weights the previous window: with a limit of 100 per minute, 80 requests last minute, 30 so far, and a quarter of this minute elapsed, the estimate is 80 × 0.75 + 30 = 90, so the request passes.

A token bucket holds up to a fixed capacity of tokens, refills at a steady rate, and spends one token per request, so it admits a burst up to the capacity and then the sustained rate. A leaky bucket models a queue that drains at a constant rate; requests beyond the queue are rejected, which smooths output but can delay requests. In NGINX, the burst parameter sizes that queue, and nodelay serves requests within the burst at once instead of delaying them. Interviewers mostly want each algorithm's burst behavior and its weak spot.

Rate limiting algorithms for a single key
AlgorithmState per keyBurst behaviorWeak spot
Fixed windowOne counter with expiryUp to 2× at a boundaryWindow-edge bursts
Sliding window logOne timestamp per requestExactMemory grows with the limit
Sliding window counterTwo countersSmoothed estimateAssumes an even spread
Token bucketTokens and last refill timeUp to capacityTwo parameters to tune
Leaky bucketQueue level and timeQueued, drained evenlyAdds latency when queued

Run a token bucket with an injected clock

Injecting the clock makes the limiter testable without sleeping: the test sets the time, then calls try_acquire. Refill is lazy, computed from elapsed time on each call, so no background timer is needed. The method returns whether the call is allowed and, when it is not, how long until enough tokens exist. That second value is what the gateway turns into Retry-After, rounded up to whole seconds, because the header carries an integer number of seconds or a date.

Running it with Python 3.12 prints three lines: t= 0.0: YYYYYnn retry_after=0.5, then t= 1.0: YYn retry_after=0.5, then t=10.0: YYYYYn retry_after=0.5. The first five calls drain the bucket, and one token takes 0.5 seconds at 2 per second. At t=1.0 exactly two tokens have refilled. At t=10.0, 18 tokens would have accrued, but the cap holds the bucket at 5, so idle time cannot be banked beyond capacity. The class is not thread-safe; across processes, this state belongs in shared storage.

class TokenBucket:
    def __init__(self, capacity, rate_per_sec, clock):
        self.capacity, self.rate, self.clock = capacity, rate_per_sec, clock
        self.tokens, self.last = float(capacity), clock()

    def try_acquire(self, cost=1):
        """Return (allowed, seconds until enough tokens)."""
        now = self.clock()
        self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
        self.last = now
        if self.tokens >= cost:
            self.tokens -= cost
            return True, 0.0
        return False, (cost - self.tokens) / self.rate


class FakeClock:
    now = 0.0

    def __call__(self):
        return self.now


clock = FakeClock()
bucket = TokenBucket(capacity=5, rate_per_sec=2, clock=clock)
for t, n in [(0.0, 7), (1.0, 3), (10.0, 6)]:
    clock.now = t
    results = [bucket.try_acquire() for _ in range(n)]
    allowed = "".join("Y" if ok else "n" for ok, _ in results)
    print(f"t={t:>4}: {allowed} retry_after={results[-1][1]}")

Make the shared check atomic

The classic bug is read-then-write. Two gateway instances read a count of 99, both decide the request is allowed, and both increment, leaving 101. Redis INCR avoids this because it returns the new value atomically, so decide on the returned value rather than on a separate read. The Redis documentation's own rate limiter pattern shows a subtler race: incrementing a counter and setting its expiry as two separate commands can leave a key with no TTL if the client fails between them.

The fix the documentation gives is to run both steps in one Lua script sent with EVAL; Redis guarantees a script executes atomically, blocking other commands while it runs. A token bucket follows the same shape: store tokens and the last refill time in one hash, and let one script refill, decide, and write. Pass every key the script touches as an explicit key argument, which the Redis docs require for correct execution in both standalone and clustered deployments. Expect one network round trip per request, and measure that latency in your own environment.

Define the 429 contract and the failure mode

RFC 6585 defines 429 Too Many Requests for a client that sent too many requests in a given time. The response may include Retry-After and must not be stored by a cache, and RFC 9110 allows Retry-After as an HTTP date or a number of seconds. For remaining quota there is no RFC yet: as of November 2026, the IETF draft defining the RateLimit-Policy and RateLimit fields is at revision -11, dated May 2026, and is still a working-group Internet-Draft. Many APIs publish their own headers; GitHub, for example, documents x-ratelimit-remaining and returns 403 or 429 when its primary limit is exceeded.

Then decide what happens when Redis is slow or down. Failing open allows traffic and keeps the API available, at the risk of overload or abuse during the outage. Failing closed rejects traffic, which protects costly operations but turns a limiter outage into an API outage. Choose per endpoint: an endpoint that sends SMS codes should fail closed, while a read endpoint can fail open with a local fallback limit. Put a tight timeout on the limiter call and record every allow, deny, and fallback decision as a metric.

Practice the five-minute answer

Say it in the same order every time: requirements, placement, algorithm with its weak spot, atomic shared state, client contract and failure mode. Then rehearse the follow-ups interviewers reach for. How do limits differ by plan? Store the rule per key and load it with the bucket. What about several regions? Either give each region a share of the global quota and accept some unfairness, or pay cross-region latency for a single store. Name the trade-off rather than claiming one design always wins.

Rewrite the token bucket from memory with an injected clock and check that it prints the same three lines. If you review system design with RecallDeck flashcards, make one card per decision, such as fail open versus fail closed, rather than one card per algorithm name. The articles below cover neighboring topics: retries that interact with your limiter, and the caching layer that often shares the same Redis deployment.

Quick answers

Frequently asked questions

Should a rate limiter return 429 or 503?

Use 429 when a specific client exceeded its quota; that is the case RFC 6585 defines. A 503 says the service is unavailable more broadly. Check your proxy's default: NGINX's limit_req rejects with 503 unless you set limit_req_status to 429.

Do rejected requests count against the limit?

Decide and document it. Counting rejections is cheap with a plain INCR and penalizes clients that retry in a tight loop. Not counting them is friendlier but needs an atomic check before the increment. The token bucket above does not spend a token on a rejected call.

Is a token bucket the same as a leaky bucket?

They are closely related, and both bound the long-run rate. The practical difference is what happens to a burst: a token bucket admits it immediately up to its capacity, while a leaky bucket used as a queue releases requests at a steady rate and may delay them.

Can I rate limit by IP address alone?

Only as a coarse fallback. Many users can share one public address behind NAT or a corporate proxy, and one attacker can rotate addresses. Prefer an authenticated identity such as an API key, and keep IP limits for unauthenticated endpoints like login or sign-up.

Source notes

References and review policy

Information checked on November 7, 2026. Section links identify sources for factual claims and technical explanations. Interpretations, practice scenarios and preparation recommendations are RecallDeck’s editorial work.

From reading to recall

Practice the full interview loop.

RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.

Start studying

Keep going