The Interview Edge Blog
← Back to all guides
Systems · System design

Rate Limiters Explained: Token Buckets, Sliding Windows & the Redis Race

One user’s flood can sink the ship: how fixed windows, sliding windows, token buckets, and leaky buckets decide who gets in — plus the atomic Redis trick and the 429 playbook, with the arithmetic done by hand.

Explain it like I’m five

Picture a nightclub with one very calm bouncer. He lets people in steadily — one at a time, no rush — and when the club is full, he holds up a hand: come back in five minutes. He is polite, he never loses count, and he does not care how badly you want in.

A rate limiter is that bouncer for your API. One login request is fine; ten thousand a second from one script is how you get outages, melted databases, and a very bad on-call night. The limiter counts requests, lets the reasonable ones through, and answers the rest with a firm, standardized no — HTTP 429 Too Many Requests — plus a hint about when to come back. Everything in this guide is just the bouncer’s counting trick, refined four different ways.

Intuition: why every backend needs a bouncer

“Design a rate limiter” shows up in almost every system-design interview — and most candidates fumble it, because they recite an algorithm instead of reasoning about the system. The interview is about what you protect, from whom, where the decision happens, and what breaks when the tradeoffs are wrong. By the end of this guide you’ll name the trap in the simplest design, derive two better ones, and know how the real systems build it.

Three reasons the limiter exists. First, abuse: your login endpoint is a credential-stuffing target — without a limiter, a script can brute-force accounts or burn your SMS budget all night. Second, cost: every request costs compute, and LLM APIs bill per token — an unbounded client can turn your invoice into a horror story. Third, fairness: one noisy tenant must not starve the other forty-nine sharing the same pool.

There are four classic ways to count, and the interview rewards you for reaching each one deliberately. Fixed window counts requests inside each clock minute — dead simple, but it bursts at the boundary. Sliding window counts the last sixty seconds relative to now — precise, but storing every timestamp costs real memory, so production versions approximate. Token bucket lets clients spend saved-up tokens and tolerates bursts. Leaky bucket drains at a constant drip and smooths everything into a steady stream.

One sentence worth memorizing

Put the limiter at the API gateway, answer 429 with a Retry-After header, and name the tradeoffs: fixed window is simple but bursts at boundaries, token bucket absorbs bursts, leaky bucket smooths them — then ask what the system actually needs.

How the four designs work — and the race at the heart of it

Fixed window: the trap at the boundary

The simplest counter: each key (a user, an IP, an API key) gets a counter and a one-minute window. First request sets the counter with a 60-second expiry; each request increments; past the cap, every request gets a 429 until the window rolls over. One integer per key — correct, cheap, and hiding a flaw you should volunteer before the interviewer asks.

A cap of 100 requests per minute. A client fires 100 at 12:00:59 — the last second of the window — then 100 more at 12:01:01, the first second of the next. Both counters saw at most 100, so both waves were admitted: 200 requests in 2 seconds against a stated limit of “100 per minute.” The counter never noticed, because windows are a bookkeeping convenience, not a physical fact. Any adversary, and any thundering retry loop, will discover this seam.

Sliding window log: exact, expensive

Fix the boundary by abandoning fixed windows entirely. Keep a sorted log of every request’s timestamp — a Redis sorted set is the canonical store. On each request, evict entries older than sixty seconds, count what remains, and admit only if the count is under the cap. There is no boundary, so there is no burst: the count always reflects exactly the last sixty seconds.

The cost is memory proportional to traffic. A client allowed a million requests per minute needs a million timestamp entries per key. For low-rate endpoints like login this is perfect; for a firehose API it is a Redis bill shaped like a hockey stick. Production systems therefore approximate.

Sliding window counter: the approximation that wins

Keep just two integers: the count for the finished minute and the count for the current minute. To estimate “requests in the last sixty seconds,” blend them: at 15 seconds into the current minute, the estimate is curr + 0.75 × prev, weighting the previous minute by how much of it still overlaps. Two integers per key — constant memory — and the error stays small.

Notice what happened: exactness traded for O(1) memory. In interviews, the follow-up is always “how much memory?” — the sliding-window log costs O(requests per window) per key, the counter costs O(1). Say both, then pick the counter for high-throughput keys and the log for precision-critical ones like login lockout.

Token bucket vs leaky bucket: cousins, opposite temperaments

The token bucket holds a fixed number of tokens — capacity B — and refills at a steady rate r tokens per second. Each request spends one token. Tokens arrive continuously, so a quiet client accumulates savings and spends them at once: a full bucket of 100 admits a 100-request burst instantly. The average rate is capped at r, but bursts up to B are legal. Empty bucket? 429.

The leaky bucket flips the idea. Requests pour into a queue and drain out at a fixed rate — ten per second, steady, no exceptions. A burst of 100 does not get through in one second; it gets processed ten per second, and overflow waits or drops. Where the token bucket absorbs bursts, the leaky bucket flattens them — ideal for a downstream service, a database, or a payment rail that needs a calm, steady drip.

Token bucket: refill, capacity, bursts in, admits and 429s outREFILL · +10 tokens/seccapacity 100saved tokens spend as burstsINCOMING · 150-request burstfirst 100 spend tokenstokens empty → 429100 admitted50 rejected429 + Retry-After: 5long-run rate = refill ratebursts ≤ capacity are free

Token bucket: bursts spend savings; the refill rate sets the sustainable speed. Leaky bucket is the mirror image — it holds requests and drains them at the fixed rate.

Token bucket vs leaky bucket — say it in one line

Token bucket admits bursts up to capacity and caps the average rate; leaky bucket admits a constant rate and absorbs bursts as queueing. Bursty API traffic: token bucket. A fragile downstream: leaky bucket.

The distributed race — and the Lua fix

One server? In-memory counters, done. But real systems run dozens of API servers, so the counters have to live somewhere shared. That is Redis. And that is where the interview gets interesting, because the naive implementation has a race.

The naive flow is two steps: read the counter, then increment it if under the limit. Two servers handle two requests from the same client at nearly the same instant. Both read 99 against a limit of 100. Both decide “under the limit.” Both increment. The limit was 100; 101 requests got through. Widen that gap across forty instances and the over-admission scales with the fleet. The check and the increment must be one indivisible operation.

Redis gives you exactly that with Lua scripts. Redis executes a Lua script atomically — no other command, from any client, can interleave with it — so a check-and-increment script reads the true current count, decides, and writes the increment before any other instance’s script may begin. Load the script once, invoke it per request with EVALSHA (the client library hashes the script body and sends only the hash after the first call, so the wire cost is about one INCR), and the race is structurally impossible rather than merely unlikely. One caveat worth naming: the atomicity holds per shard — and a failover can briefly lose the most recent writes, which over-admits rather than under-admits. The code section below is the real pattern.

Where the limiter lives — and how it says no

Enforce at the API gateway or edge, before the request touches your services: a rejected request should never consume backend compute. The gateway answers with 429 Too Many Requests — the status code RFC 6585 defined for exactly this (“the user has sent too many requests in a given amount of time”) — and a Retry-After header telling the client how long to wait before trying again. RFC 6585 also says 429 responses must not be stored by caches, which matters when a CDN sits in front of you.

Good APIs also advertise their headroom: OpenAI answers with x-ratelimit-remaining-requests and x-ratelimit-remaining-tokens headers, Anthropic with anthropic-ratelimit-requests-remaining, so well-behaved clients throttle themselves instead of discovering the cliff at full speed. If your gateway sets those headers too, say so — it is a small detail that signals production experience.

LLM APIs: rate limiting at planetary scale

The largest rate limiters in production right now meter AI traffic. OpenAI and Anthropic both enforce per-minute limits on two dimensions at once — requests per minute and tokens per minute — tiered by usage, and either dimension trips a 429. Their clients are the exact retry storms this guide is about: every inference server running continuous batching behind the limiter hammers retries the moment it gets throttled.

01 · Edge

Login, signup, and password-reset endpoints sit behind strict per-IP and per-account limiters at the gateway. This is abuse prevention: credential-stuffing scripts are throttled with a sliding window — precise enough that a human typing a wrong password twice never notices, but a 10,000-per-second script dies at the edge before it touches the auth service or the SMS budget.

02 · Meter

LLM APIs meter two scarce resources simultaneously: API calls and GPU tokens. OpenAI enforces requests-per-minute and tokens-per-minute tiers; Anthropic splits tokens further into input and output tokens per minute with separate anthropic-ratelimit-*-remaining headers. Hitting either dimension returns 429 with a Retry-After, and the token dimension is what binds: one long-context request can burn the token budget while the request counter barely moves. This is how the providers protect their GPU fleet — and why the token bucket is the natural mental model for their clients.

03 · Share

Multi-tenant SaaS and payment rails use per-tenant limiters so one customer’s flood cannot starve the rest. A payments API gives each tenant a token bucket sized to their plan — bursts for flash-sale checkouts, a capped average otherwise — enforced at the gateway with per-tenant keys in Redis. The limit is a product feature here: the fairness guarantee that makes sharing one fleet with forty-nine strangers acceptable.

Source: IETF RFC 6585 — defines 429 Too Many Requests and the Retry-After header ↗

The rejection protocol itself is standardized: 429 says “too many requests in a given amount of time,” Retry-After says when to come back, and caches are forbidden from storing the response — so every layer of the stack, from browser to gateway to retry library, agrees on what a rate limit looks like.

Work the token bucket by hand

Bucket capacity 100, refill rate 10 tokens/sec, fully stocked. A 150-request burst arrives at once, then traffic keeps coming at 20 requests/sec.

Burst150 requests, 100 tokens. The first 100 spend a token each and are admitted; the bucket is now empty. Requests 101–150 get 429 — each with a Retry-After header.
RetryToken 1 reappears after 0.1s; token 50 after 5s. So the 150th request’s correct Retry-After is 5 seconds — computable from the deficit, no guessing: (50 needed − 0 on hand) ÷ 10 per sec.
SustainAt exactly 10 requests/sec forever, every request finds a fresh token: 100% admitted. The refill rate is the sustainable rate, independent of capacity — capacity only sizes the burst.
FloodAt 20 requests/sec, the bucket drains net 10 tokens/sec until empty, then admissions converge to 10/sec and rejections to 10/sec. Over 10 seconds: 200 requests arrive, 100 admitted, 100 rejected. The long-run admission rate can never exceed the refill rate.
TrapFixed window, 100/minute cap: 100 requests at 12:00:59, then 100 more at 12:01:01. Each window saw ≤100, so all 200 were admitted — 200 requests in 2 seconds against a “100 per minute” limit. The sliding window counts 200 in the last 60 seconds and rejects the second wave. That is the boundary burst, made of arithmetic.

Three quantities run every interview number: capacity sizes the burst, refill rate sets the sustainable rate, and deficit-over-rate is the Retry-After.

Three production shapes, in Python

A token bucket, the sliding-window-counter approximation, and the Redis Lua check-and-increment — the three patterns behind almost every “design a rate limiter” interview. The Redis script runs server-side and atomically.

python · token bucket, sliding window, redis lua
import time
from collections import deque


class TokenBucket:
    def __init__(self, capacity, refill_per_sec):
        self.capacity = capacity
        self.refill_per_sec = refill_per_sec
        self.tokens = float(capacity)
        self.last = time.monotonic()

    def allow(self, cost=1):
        now = time.monotonic()
        # refill lazily: tokens earned since last request, capped
        self.tokens = min(
            self.capacity,
            self.tokens + (now - self.last) * self.refill_per_sec,
        )
        self.last = now
        if self.tokens >= cost:
            self.tokens -= cost
            return True
        return False

    def retry_after(self, cost=1):
        # seconds until `cost` tokens exist — the Retry-After value
        deficit = max(0.0, cost - self.tokens)
        return deficit / self.refill_per_sec


class SlidingWindowCounter:
    # O(1) memory: two ints + the standard overlap blend.
    def __init__(self, limit, window_sec=60):
        self.limit = limit
        self.window = window_sec
        self.prev_count = 0   # finished window
        self.curr_count = 0   # in-progress window
        self.window_start = time.monotonic()

    def allow(self):
        now = time.monotonic()
        if now - self.window_start >= self.window:
            gap = now - self.window_start
            # roll windows; a long idle gap discards stale history
            self.prev_count = self.curr_count if gap < 2 * self.window else 0
            self.curr_count = 0
            self.window_start = now
        elapsed = (now - self.window_start) / self.window
        # approximate count over the last `window` seconds
        estimate = self.curr_count + self.prev_count * (1 - elapsed)
        if estimate < self.limit:
            self.curr_count += 1
            return True
        return False


# --- distributed: atomic check-and-increment in Redis ---
import redis

r = redis.Redis(host="localhost", port=6379)
# register_script loads once (SCRIPT LOAD) and calls via EVALSHA after;
# the body re-sends only if the server ever answers NOSCRIPT.
check_and_increment = r.register_script("""
local current = redis.call('INCR', KEYS[1])
if current == 1 then
    redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
end
local allowed = 1
if current > tonumber(ARGV[1]) then allowed = 0 end
return {allowed, current, redis.call('TTL', KEYS[1])}
""")

allowed, count, ttl = check_and_increment(
    keys=["ratelimit:login:203.0.113.44"],
    args=[100, 60],   # limit 100, window 60s
)
if not allowed:
    # 429 Too Many Requests, Retry-After: <ttl>
    print("rate limited; retry in", ttl)

Three shapes, three answers: the token bucket for bursty single-process limits, the sliding-window counter for O(1) boundary-safe counting, and the Lua script when the counter must be shared across a fleet without a race.

Six questions that test the real understanding

Meta
“Design a rate limiter: 100 requests per minute per user.” Walk me through your first design.

What to say (≈90 sec): “I’d start with a fixed window: one Redis key per user, INCR per request, EXPIRE 60s, reject past 100. One integer per key — but it has a boundary trap: 100 requests at 12:00:59 plus 100 at 12:01:01 are 200 in 2 seconds, and the counter never notices. So I’d upgrade to a sliding-window counter: keep the finished and current minute counts, estimate the last sixty seconds as curr plus prev weighted by overlap. Two integers per key, boundary seam gone. Distributed piece: check-and-increment must be one atomic Lua script via EVALSHA — two instances both reading 99 against a limit of 100 both admit. Enforce at the gateway, answer 429 with Retry-After, publish remaining-quota headers.”

Likely follow-up: “Where does the limiter sit?” → At the API gateway, before any backend compute — a rejected request should cost nothing.

The answer that sinks you: “Fixed window with INCR — simple and done.” Why it fails: the interviewer was waiting for the boundary burst; volunteering the trap unprompted is what separates a senior answer from a tutorial recital.

Google
Token bucket or leaky bucket — when do you pick which?

What to say (≈90 sec): “Cousins with opposite temperaments. The token bucket holds capacity B, refills at rate r; each request spends a token. Quiet clients accumulate savings, so a burst up to B is legal while the average stays at r. The leaky bucket flips it: requests queue and drain at a fixed drip — bursty in, smooth out, overflow waits or drops. Token bucket for user-facing API traffic, where legitimate bursts like a flash sale should be absorbed, not punished. Leaky bucket in front of a fragile downstream — a database, a payment rail — that needs a calm, steady drip.”

Likely follow-up: “What should overflow do?” → Drop with 429 — an unbounded queue is latency debt, not kindness.

The answer that sinks you: “They’re the same thing — both rate limit.” Why it fails: the whole point is the burst semantics; saying they’re identical signals you memorized the names without the mechanics.

Amazon
Your counters live in Redis behind forty app servers. Two requests from the same client arrive at the same millisecond. What breaks?

What to say (≈90 sec): “Naive flow is read-then-increment: GET, compare, INCR if under. The race: server A reads 99 against a limit of 100, server B reads 99 before A’s increment lands — both admit, and 101 get through. Across forty instances the over-admission scales with the fleet. The fix is a Lua script via EVALSHA: Redis runs scripts atomically, so check-and-increment is one indivisible server-side operation. The library loads the script once and sends only its SHA after, so the wire cost is about one INCR. Caveat: atomicity holds per shard, and a failover can briefly lose recent writes — that over-admits rather than under-admits, so the failure mode is a slightly loose limit, not dropped traffic.”

Likely follow-up: “Why not MULTI/EXEC?” → Transactions can’t branch on values mid-flight without WATCH retry loops; Lua does read-decide-write in one shot.

The answer that sinks you: “INCR is atomic, so there’s no race.” Why it fails: INCR alone is atomic — but the limit check is a separate step, and the race lives in the gap between check and increment.

OpenAI
How should a rate-limited client know when to retry — and what does the server owe the client?

What to say (≈90 sec): “The contract is 429 Too Many Requests — the status RFC 6585 defined for exactly this — plus a Retry-After saying when to come back; RFC 6585 also says 429s must not be cached, which matters with a CDN in front. Good APIs advertise headroom: OpenAI returns x-ratelimit-remaining-requests and x-ratelimit-remaining-tokens, Anthropic returns anthropic-ratelimit-requests-remaining, so clients throttle themselves instead of discovering the cliff. I’d size Retry-After from the deficit, not a constant — a 50-token deficit draining at 10 per second means 5 seconds. And clients should honor Retry-After with jittered backoff: a synchronized retry wave at the window rollover is the burst problem wearing a new disguise.”

Likely follow-up: “Fixed or computed Retry-After?” → Computed from the deficit — the bucket already knows the exact wait.

The answer that sinks you: “Return 429 and let the client retry immediately.” Why it fails: immediate retries are a retry storm — you’ve built a thundering herd and called it a contract.

Anthropic
Sliding window log versus sliding window counter — defend the approximation.

What to say (≈90 sec): “The log is exact: store every timestamp in a Redis sorted set, evict older than sixty seconds, count the rest — no boundary, no burst. The price is memory linear in traffic: a million-requests-a-minute key carries a million timestamps. The counter keeps two integers — finished and current minute — and estimates the last sixty seconds as curr plus prev weighted by overlap: constant memory, with only a small error about which old requests fall inside the window. Log for precision-critical keys like login lockout, where traffic is low; counter for high-throughput API keys, where O(requests) memory per key is a Redis bill shaped like a hockey stick. State both memory costs, then pick per key class.”

Likely follow-up: “How wrong can the estimate get?” → Roughly one window’s skew at the boundary — a fraction of the previous window, never an order of magnitude.

The answer that sinks you: “The counter is exact enough, so the log is never needed.” Why it fails: “exact enough” without naming the error bound or the use cases where exactness is non-negotiable is hand-waving, not engineering.

Apple
Your limiter is live. At midnight, one tenant’s batch job hammers the API, gets 429s, and all its clients retry at once when the window rolls over. What went wrong, and how do you fix it?

What to say (≈90 sec): “That’s a retry storm. Every client slept until the window rollover, then fired simultaneously — reproducing the boundary burst on purpose. Three fixes. First, Retry-After with jitter: spread retries randomly across the window instead of stampeding at one second. Second, exponential backoff with decorrelated jitter on the client — the wave decays instead of resonating. Third, a token bucket instead of a hard window: the refill rate naturally spreads admissions, because tokens only regenerate so fast — there is no cliff edge to synchronize against. And if it’s one tenant, per-tenant quotas: one tenant’s flood must not become everyone’s latency.”

Likely follow-up: “Why jitter?” → Fixed backoffs keep retries synchronized — the herd moves together, just later; jitter breaks the alignment.

The answer that sinks you: “Raise the limit so the batch job succeeds.” Why it fails: the limit exists to protect the system — the tenant’s scheduling problem does not outrank forty-nine other tenants’ latency.

Key takeaways

  1. Fixed window is simple but bursts at the boundary — 200 requests in 2 seconds against a 100/minute cap, and the counter never noticed.
  2. Sliding window log is exact but costs O(requests) memory per key; the two-integer counter approximation keeps O(1) memory and kills the boundary seam.
  3. Token bucket absorbs bursts up to capacity and caps the average at the refill rate; leaky bucket drains at a constant drip and smooths bursts for fragile downstreams.
  4. Distributed limiters have a check-then-increment race — close it with an atomic Redis Lua script via EVALSHA, not two round trips.
  5. Enforce at the API gateway, answer 429 with a computed Retry-After, and publish remaining-quota headers so good clients throttle themselves.
  6. Compute Retry-After from the deficit and jitter the retries — a synchronized retry wave is the boundary burst wearing a new disguise.
Read nextContinuous Batching →