System design interview guide
Rate Limiter System Design
An API gateway must enforce per-client limits (by user, IP, API key, or route) to protect backends and keep usage fair. The hard part is three things at once: the check has to stay very fast even when traffic is huge, every gateway machine must see the same count for the same client (not its own private counter), and you must say what happens when Redis or the limiter briefly fails—either let traffic through (fail open) or block it (fail closed). That last choice is a product and security call you should make out loud, not leave vague.

Problem statement
You’re designing a rate limiter in front of APIs: enforce configurable limits per client identity (user id, API key, IP, or combination), per route or service, within time windows, with low overhead on every request.
What the system must do: allow or deny (or optionally delay) each request based on usage so far, support more than one limit style (fixed window, sliding window, or token bucket), put remaining-quota headers on responses, and often whitelist internal callers. What “good” feels like under load: the check sits on the critical path, so it needs a tiny time budget (often under a millisecond), it must work across a fleet of gateway machines, and it must survive huge request rates. Typical whiteboard scale is millions of checks per second and tens of millions of distinct clients—so memory per key and hot keys matter.
What interviewers reward: a clear algorithm, a distributed state story, atomic updates, a failure mode, and honest HTTP behavior—not a box labeled Redis with no behavior behind it.
Introduction
Before this guide: If rate limiting concepts are new, read the rate limiting series first—fundamentals and algorithms—then return here for the full whiteboard walkthrough.
Your mobile app hits “retry” on a flaky network. Twenty requests leave the phone in one second. Without a limiter, they all reach checkout—and twenty charges are possible. With a bad limiter, each gateway counts locally and the user gets ten times the allowed quota.
That is why this interview is not about drawing a box labeled Redis. Every gateway must check the same shared count for each client before allowing work. When ten gateways each think “this client still has room left,” one client can send far more traffic than you intended, and you pay for servers that are busy serving junk while real users wait.
Interviewers want an algorithm, where state lives, atomic updates, and fail-open vs fail-closed—stated as product choices, not afterthoughts.
If you remember one thing: Local counters on each gateway multiply allowed traffic; global limits need one source of truth per key (or an honest hierarchical approximation).
Why teams bother with rate limiting
The checkout story above is not rare. Without shared limits, one bad script, one broken client that keeps retrying, or one partner integration can soak almost all of your capacity. That feels unfair to customers and expensive to the company, because you pay for servers and databases that are busy serving junk traffic while real users wait.
Rate limiting shows up in places you have already seen as a user, even if you did not know the name. Login pages often limit failed password attempts so attackers cannot guess millions of passwords. Email products limit how many messages one account can send per hour so spam stays under control. Public APIs publish rules like “1,000 calls per hour per API key” so one integration cannot starve everyone else.
The goal is not only to stop attackers. It is also to share limited server power fairly and to keep the core system alive during spikes. A good limit is strict enough to protect backends but generous enough that normal users rarely hit it. Limits that are too loose waste money; limits that are too tight create support tickets from paying customers.
How to approach
Spend the first few minutes turning a vague prompt into a contract you can design against. Talk through clarifying questions out loud, then walk one request end to end before you name Redis commands. The goal is not to sound clever—it is to stop yourself from designing the wrong limiter.
Clarifying questions (say these in the room)
Start with identity, because every later choice (Redis key, burst policy, abuse story) hangs off who is being limited.
You: “Are we limiting by user id, API key, IP, route, or a combination? If someone is behind a shared office NAT, limiting only by IP can punish many people for one noisy neighbor.”
Interviewer: “Start with authenticated user id per route; you can mention IP as a secondary abuse signal for anonymous traffic.”
That answer tells you the default Redis key shape and warns you not to over-index on IP for logged-in traffic.
You: “When a mobile client retries after a flaky network, do we allow a short burst above the steady rate, or is the window completely rigid—exactly N calls per minute with no burst?”
Interviewer: “Product wants bursts for mobile retries—token bucket is fine.”
Now you know fixed window alone is a weak primary pick, and you can defend token bucket without sounding random.
You: “At peak, do we need exact counts, or is a small over-allow acceptable if backends stay protected? Exact sliding windows cost more Redis work per check.”
Interviewer: “Protect backends; a small over-allow is OK if you say the bound.”
That permission lets you choose approximate structures under huge QPS instead of pretending every design is perfectly exact.
You: “If Redis times out, should /login and /catalog behave the same? Letting all login traffic through during an outage is a security risk; blocking the whole catalog may be an availability risk.”
Interviewer: “You choose—and defend it.”
Strong answers pick fail-closed on auth-sensitive routes and may pick fail-open on read-heavy catalog routes, with a reason for each.
You: “I want to keep network DDoS scrubbing, a long-term analytics warehouse, and client-only throttling out of scope so we can focus on server-side request limiting in the gateway. Does that match what you want?”
Interviewer: “Yes—focus on server-side request limiting.”
Saying out of scope early stops you from burning half the interview on CDN edge cases.
Minute pacing (about 40 minutes)
| Minutes | Focus |
|---|---|
| 0–5 | Clarify key, burst, exactness, fail mode, out of scope |
| 5–12 | Capacity + high-level boxes + one request story |
| 12–25 | Algorithm pick + Redis keys + atomic check (the dig) |
| 25–35 | Failure, hot keys, headers |
| 35–40 | One production war story + questions |
In the room (a fuller opening you can actually say): “I’d start by agreeing who we limit—usually authenticated user id plus route, with IP only as a coarse signal for anonymous abuse. I’d ask whether the product needs short bursts for retries; if yes, I’d lean token bucket, and I’d keep sliding window in reserve when minute-boundary fairness matters. I’d store counters in a shared Redis cluster and update them with an atomic Lua or INCR path so two gateways cannot both approve the same last request. On failure I’d fail closed for login and password reset, and I might fail open for catalog reads with a tight local shadow cap. Every deny returns 429 with Retry-After so clients back off instead of hammering harder.”
If you remember one thing: Tell one request story before you recite command names.
Out of scope (and why you park these)
Write a short out-of-scope list on the board so the interviewer sees you can bound the problem. Explain each line in a sentence, not only as a bullet label.
Network-layer DDoS mitigation (CDN, WAF, scrubbing). Those systems absorb volumetric floods before traffic reaches your gateway. They matter in production, but they are a different interview (and a different team’s tooling). Your design still assumes some bad traffic arrives at the API edge.
Client-only rate limiting as the only control. Apps and browsers can be modified, replayed, or ignored. Client throttling is a courtesy to reduce load; server-side enforcement is what makes the limit real.
A long-term analytics warehouse for every allow or deny. Sampling decisions to Kafka for later analysis is useful, but building a full warehouse, retention policy, and BI pipeline will consume the round without proving you can protect the request path.
Perfect globally exact counts across every region with zero latency cost. Cross-region agreement on every increment fights physics. Prefer regional budgets, sticky routing, or an explicit over-allow window—and say which accuracy you are buying.
Capacity estimation
Rough numbers to anchor the whiteboard (tune in the room):
| Input | Order of magnitude | Design implication |
|---|---|---|
| Aggregate checks/s | 10M+ (illustrative scale) | No O(log N) structures per check unless unavoidable; prefer O(1) key ops |
| Distinct keys | 10²–10⁸ (clients × policies) | Memory per key × TTL discipline; evict idle keys |
| Hot key QPS | Single client abusive or NAT | Shard keyspace, sub-partition, or hierarchical budget |
| Check budget | Sub-ms p99 on gateway | Redis RTT + serialization dominates; pool connections |
Worked memory sketch: If you keep about 100 bytes of state per active key and you have on the order of 100 million distinct clients in a window, that is roughly 10 GB of counter state before replicas. Idle keys must expire with the window (TTL), or memory grows forever. You cannot afford a full distributed consensus round per request. You pipeline, Lua-batch multi-window checks, and isolate abusive keys before they starve the cluster.
If you remember one thing: Sub-ms budget means O(1) key ops—not consensus per request.
High-level architecture
Treat the limiter as a data-plane check on the request path and a control-plane for policies. The edge / API gateway receives traffic, runs authentication (to know user_id or api_key), then runs the limiter with a normalized key (for example tenant:route:user). The limiter executes atomic logic against a low-latency store (most interviews: Redis Cluster). A config service or feature flags push limit definitions (tokens per minute, burst, whitelist). Observability records check latency and decisions—otherwise you fly blind when Redis slows down.
Who owns what:
- API gateway — TLS, routing, authn, first-line abuse signals, invokes the limiter before expensive backend work.
- Rate limiter library or thin service — Key derivation, policy lookup, Lua scripts, and header formatting so gateways stay dumb and fast. Prefer an in-process library calling Redis unless many languages share one fleet and you need a dedicated limiter tier.
- Redis / shared store — Authoritative counters; sharded by hash(key); prefer primary for increments unless you explicitly accept stale reads.
- Control plane — Policy CRUD, canary rollouts, emergency global throttle.
Async work is minimal on the hot path: policy sync can be cached in-process with TTL. Optional async analytics (sample blocks to Kafka) must not block allow/deny.
[ Client ]
→ [ LB ] → [ API Gateway ]
|
| 1 sync: derive key, check quota
v
[ Rate limiter lib ] ──2 atomic──► [ Redis Cluster ]
| (counters + TTL)
| 3 allow / 4 deny
v
[ Upstream services ] [ 429 + Retry-After ]
Control plane (async): [ Policy store ] ──► gateway cache (TTL refresh)
In the room: Narrate one request end-to-end: key string, single RTT (or Lua), 200 vs 429, headers. Then say what happens when Redis is slow (timeout → fail-open vs closed).
If you remember one thing: Gateway checks before expensive upstream work; Redis is authoritative for global counts.
Core design approaches
Decision matrix (pick in the room)
| Algorithm | Burst behavior | Redis cost | Fairness at window edge | When to pick |
|---|---|---|---|---|
| Fixed window | Worst—can allow ~2N near boundaries | Lowest (INCR + TTL) | Poor | Coarse abuse caps |
| Sliding window (exact) | Smooth | Highest | Best | Support-ticket magnet on fairness |
| Sliding approx | Near-smooth | Medium | Good enough | High QPS with admitted error |
| Token bucket | Smooth up to capacity B | Medium (Lua refill) | Good | API products that promise bursts |
| Leaky bucket | Smooth outflow; queues or rejects | Medium | Steady rate | When you want constant drain, not burst |
Fixed window
Partition time into buckets (for example minute boundaries). INCR per bucket key, EXPIRE after the window. Cheapest; worst at edges—two windows back-to-back can allow 2N requests in a short span.
Limit N=100 / minute
:59 ████████░░ 100 allowed
:00 ████████░░ 100 allowed again
└─ ~200 in ~2 seconds at the edge (2N burst)
Sliding window (exact or approximate)
Track events in a sliding interval—sorted sets, a ring of sub-buckets, or Redis recipes with bounded error. Fairer; more CPU and memory per key.
Token bucket
Refill tokens at rate r, bucket capacity B. Smooth bursts; maps well to product language (“burst then steady”). Implement with last refill timestamp plus atomic read-modify in Lua.
tokens ████████░░ capacity B
refill →→→ at rate r
each allow consumes 1 (or weight)
tokens == 0 → deny (429)
Leaky bucket
Think of requests entering a bucket that drains at a constant rate. If the bucket is full, new requests wait or get rejected. Compared with token bucket, leaky bucket emphasizes a steady outflow rather than “spend a burst of tokens.” In interviews, say token bucket when product marketing talks about bursts; say leaky bucket when you need a constant processed rate into a downstream that cannot spike.
When to pick: Fixed window for coarse abuse caps; token bucket for API products that promise bursts; sliding window when fairness at the minute boundary is a support ticket magnet; leaky bucket when the downstream needs a flat rate.
If you remember one thing: Pick the algorithm for burst shape and Redis cost—not because it sounds fancy.
Data model (Redis keys and policies)
Without a key schema, “use Redis” is not a design.
Counter keys (data plane)
| Key pattern | Example | Fields / value | TTL |
|---|---|---|---|
| Fixed window | rl:fw:{policy}:{principal}:{yyyyMMddHHmm} | integer count | ~2× window |
| Token bucket | rl:tb:{policy}:{principal} | hash: tokens, updated_at_ms | idle eviction (e.g. hours) |
| Sliding approx | rl:sw:{policy}:{principal}:{slot} | counts per sub-window | window + 1 slot |
| Hot-key shard | rl:tb:{policy}:{principal}:{shard} | same as parent algo | same |
Principal is usually user:123, key:abc, or ip:1.2.3.4. Policy encodes route and tier so /login and /search do not share one counter by accident.
Policy records (control plane)
Store separately from counters (config DB or feature-flag payload), then cache on each gateway:
policy_id: "checkout_user_v3"
version: 17
algorithm: token_bucket
rate_per_sec: 10
burst: 50
scope: "route:/checkout + principal:user"
fail_mode: fail_closed # or fail_open
Gateways should include version in metrics so mixed deploys are visible. Emergency overrides (whitelist / blocklist) are short-TTL keys checked first.
Architectural dig: one Check() call
This is the interview meat: what happens inside the boxes when two gateways race, and why naive designs fail.
1) Gateway check path
Owns: TLS termination context, auth identity, building the limit key, local policy cache, Redis connection pool, attaching headers or returning 429 without calling upstream.
Lifecycle: Authenticate → resolve policy (local cache; rare miss to control plane) → build key tier:route:principal → optional local hard cap (hierarchical) → Redis atomic check → allow with headers or deny with Retry-After.
Why not a separate limiter microservice on every request? An extra network hop adds latency on the critical path. Prefer an in-process library talking to Redis unless many languages must share one implementation.
What breaks first: Redis connection pool wait—gateways queue while Redis still looks “up.” Mitigate with pool sizing, bulkheads per tier, and an SLO on limiter.check p99.
Interview land: “We authenticate, build one key, do one atomic Redis RTT, then either forward or 429—never charge upstream on deny.”
2) Redis counter plane and atomicity
Owns: Authoritative counts, TTLs, hash-slot placement.
Bad design: GET then SET
t0 Gateway A: GET count → 99
t0 Gateway B: GET count → 99
t1 A: SET 100 B: SET 100
both allowed the 100th and 101st → over-allow
Great design: single RTT atomic update
Use INCR + EXPIRE for fixed windows, or a Lua script for token-bucket refill + consume so read-modify-write cannot interleave.
-- Pseudocode: token bucket in one Redis Lua call
tokens, updated = HGETALL key
refill based on now - updated, rate r, cap B
if tokens < cost then return DENY, retry_after
else tokens -= cost; HSET; return ALLOW, tokens
Why not Memcached for the same design? You lose convenient atomic Lua-style scripts and rich TTL patterns; teams that stay on Memcached need careful compare-and-set loops and still fight races.
Interview land: “Atomicity is the design—never GET then SET for a global cap.”
3) Token bucket runtime inside one key
State is small: remaining tokens and last update time. On each check, refill as if time passed continuously, then try to consume. Capacity B is the burst; rate r is the steady refill. Weighted requests (large uploads) consume more than one token when the product needs that.
Scale limit inside the box: Complex Lua on a hot key pegs one shard’s CPU. Profile Lua like application code; split abusive principals across sub-keys when needed.
4) Control plane and policy rollout
Policies change slowly compared with request QPS. Gateways cache policies with TTL and watch version. A canary raises burst or lowers rate for 5% of shards first. An emergency kill switch can push a global tighter cap through the config channel without waiting for every TTL.
Disaster after Redis wipe or failover: Counters reset → temporary over-allow. Accept a short burst, optionally lower limits for a few minutes, and alert on sudden drops in 429 rate paired with traffic spikes. Do not pretend failover is invisible.
Interview land: “Counters are ephemeral protection; policies are versioned. Failover means a brief over-allow we monitor.”
Numbered happy path (put it together)
- Gateway authenticates and builds limit key.
- Load policy from local cache (versioned).
- Atomic check-and-update in Redis (Lua or INCR pipeline).
- Allowed → attach rate-limit headers; forward upstream.
- Denied → 429 + Retry-After; do not call upstream.
Idempotency and charging
Clarify whether retries of the same logical operation consume quota twice. Strong answers use Idempotency-Key for writes and may exclude duplicate retries from counting when the backend dedupes.
If you remember one thing: 429 should not call upstream—save money and protect backends.
Key challenges
- Correctness vs cost: Exact sliding windows at 10M checks/s per cluster may require approximation; admit error bounds.
- Hot keys: One
client_idmaps to one Redis slot—CPU and network collapse there; mitigation is part of the design, not an afterthought. - Layered limits: Per-user + per-IP + global must compose without N sequential RTTs—Lua or batched keys.
- Clocks: Windows are usually server-based; clients must not trust client clocks for enforcement.
- Multi-tenant fairness: A noisy neighbor on shared Redis can raise everyone’s p99—quotas, isolation by tenant prefix, or dedicated clusters for large customers.
- Multi-region consistency: If each region has its own Redis, a user who fails over regions can get roughly R × the limit for a short window unless you use sticky routing, regional budgets sized for that drift, or a central authority for strict global caps. Say the over-allow window you accept (for example tens of seconds of eventual merge) instead of claiming perfect global exactness.
If you remember one thing: Hot keys and layered limits are design requirements—not production surprises.
Scaling the system
- Shard Redis by hash(key); high-cardinality principals usually spread naturally. Hot OAuth clients still need sub-keys or isolation.
- Horizontal gateways scale statelessly; Redis scales out until ops cost hurts—then a dedicated limiter tier with batching.
- Regional limits: stale cross-region counts are acceptable for many products; global strict caps need central authority or sticky routing—name latency vs accuracy.
- Read replicas for limiter increments are risky—prefer primary for updates unless you design probabilistic local caches on purpose.
If you remember one thing: Gateways scale statelessly; Redis scales until you need sharding and abuse isolation.
Failure handling
| Scenario | Bad outcome | Mitigation |
|---|---|---|
| Redis timeout | Unbounded traffic (fail open) or outage (fail closed) | Circuit breaker; short cached decision only where safe; policy per route |
| Redis failover / wipe | Counter reset → temporary over-allow | Accept burst; temporarily lower limits; alert on anomaly |
| Gateway deploy | Mixed policy versions | Version keys; canary; graceful cache warm |
| Thundering herd after 429 | Clients ignore Retry-After | Jitter in Retry-After; document exponential backoff |
| Policy cache stampede | Control plane thunders after mass TTL expiry | Stagger TTLs; soft TTL + background refresh; singleflight fetch |
Degraded UX: Users see more 429s or slower responses. An outage is when gateways error without a policy—avoid that for auth paths.
If you remember one thing: Fail-open vs fail-closed is a product/security choice—never “we’ll decide later.”
API design
Rate limiting is usually not a standalone public REST product in the interview—it is behavior on existing APIs. Still, spell out how clients observe limits.
Gateway-injected headers (common pattern):
| Header | Role |
|---|---|
X-RateLimit-Limit | Max requests per window for this policy |
X-RateLimit-Remaining | Decrements on success; can be approximate |
X-RateLimit-Reset | Unix time when window resets |
Retry-After | Seconds (or HTTP-date) when returning 429 |
Some stacks are moving toward standardized RateLimit / RateLimit-Policy headers; in the room, naming the older X-RateLimit-* pattern is still widely understood—say you will match whatever the company’s public API already documents.
429 response body: Machine-readable code, human message, optional retry_after_ms—helps mobile clients.
Internal admin API (sketch):
GET /v1/admin/policies/{id}
PUT /v1/admin/policies/{id} # burst, rps, scope
POST /v1/admin/overrides # temporary whitelist / blocklist
Request flow (hottest read):
GET /v1/resource
→ Gateway: auth → limiter.Check(key) → Redis Lua
→ 200 + X-RateLimit-* → upstream
→ 429 + Retry-After (stop)
Errors: 401 before limiter if unauthenticated; 429 for throttle; 503 if upstream overloaded—distinct from throttle so clients do not backoff incorrectly.
If you remember one thing: Retry-After on 429 is part of the API contract—not optional polish.
Observability (what you measure)
Do not watch only “count of 429.” That misses the failure mode where everything is slow and almost nobody is throttled.
| Signal | Why it matters |
|---|---|
limiter.check p99 / p999 | Critical-path tax on every request |
| Redis error rate + pool wait | Distinguishes “Redis down” from “pool starved” |
| Top keys by QPS / Lua CPU | Finds hot keys before users blame “the app” |
| 429 rate by route and policy version | Catches bad deploys and mixed versions |
| Allow/deny sampling (async) | Debug without blocking the hot path |
Alert ideas: limiter.check p99 above budget; single key above shard CPU threshold; sudden drop in 429s during a traffic spike (possible fail-open or counter reset).
Security and abuse
Authenticate before you apply user-scoped limits so anonymous callers cannot burn another user’s budget. Prefer API key or user id over raw IP when users share NATs; still keep a coarse IP or edge limit for unauthenticated flood. Stolen keys need fast revoke in the control plane. Do not treat the app limiter as a replacement for WAF or DDoS scrubbing—those sit further out and buy different protection. On login and password-reset paths, fail closed when the store is unavailable so a Redis blip does not become an open credential-stuffing window.
Cost awareness
Every allow that should have been a deny wastes upstream CPU, database, and support. Every deny that should have been an allow wastes conversion and trust. Redis memory and Lua CPU are real dollars at 10M checks/s—approximation and hierarchical local caps exist to buy protection without paying exactness everywhere. Returning 429 without calling upstream is the first cost win; isolating hot tenants is how you stop one customer from buying pain for everyone else on a shared cluster.
When users are stuck but the dashboards look fine
Textbooks teach boxes and arrows. On-call teaches this: the graph can look fine while users are furious—or worse, while abusers quietly get fifty times the quota you advertised.
Rate limiting is economics and physics: you buy fairness and survival with Redis round-trips, Lua CPU, and user trust when 429s hit real traffic. The interesting failures are not only “too many 429s.” They are silent over-allow from local counters, hot keys with flat 429 graphs, policy deploys that silently shift quotas, and gateways queuing while Redis looks green because slow is not down.
Scene 1: Fifty gateways, one “100/min” promise—and a 5,000/min reality
The moment: Product published “100 API calls per minute per user.” Support is quiet for weeks. Then a partner integration starts spawning clients across many regions. Usage graphs look “busy but fine.” Overnight, one power user burns through quota that should have been impossible—and a shared database starts timing out for everyone else.
The trap: Each of the 50 gateways kept its own counter in memory. Every machine thought the user still had room until that machine hit 100. Spread across the fleet, the real ceiling was roughly 50 × 100 = 5,000 requests per minute. Dashboards that only charted “429 rate” stayed green because almost nobody was being denied—the limiter was under-enforcing, not over-enforcing.

Figure: Local memory multiplies your limit by the fleet size. Shared Redis keeps one budget.
What to build: One authoritative counter per policy key in Redis (or another shared store), updated atomically. Optional tight local caps are fine as a shock absorber—but they must sit in front of the global check, not replace it. Alert on allowed QPS by principal, not only on 429 count, so silent over-allow shows up.
Scene 2: Redis CPU pegged, 429 mix looks “normal”
The moment: APIs feel slow; checkout times out; 429 rate barely moves—users blame “the app,” not “rate limited.”
The trap: One hot key hammers a single Redis slot. Lua does too much work per check. Redis is fast until one key serializes every request. Average Redis CPU across the cluster looks fine while one shard melts.
What to build: Shard counters (user:123:shard:k); probabilistic structures when exactness is optional; isolate worst tenants on dedicated Redis. Profile Lua like application code. Alert on top key QPS and per-shard CPU, not only cluster averages.
Scene 3: Legitimate users throttled right after a deploy
The moment: Support spike: login and checkout 429 right after a release; rollback fixes it.
The trap: Mixed limiter versions during deploy; failover resets counters; a feature flag tightened defaults without canary.
What to build: Canary policies per route; watch X-RateLimit-Remaining distribution; synthetic probes on critical keys post-deploy. Example timeline: T+0 canary flag on; T+8m support tickets on checkout; T+12m rollback; root cause was policy v17 with burst cut in half on the default tier.
Scene 4: Gateway queues grow while Redis “healthy”
The moment: p99 through the gateway jumps; thread pools fill; 503 appears before many 429s. Redis status page still says healthy.
The trap: Synchronous Check on every request; starved connection pool to Redis; one tenant fills the pool. Slow is not the same as down—so teams that only page on Redis “up/down” miss the outage.
What to build: SLO on limiter.check p99 separate from origin; bulkheads per tier; alert on pool wait and blocked connections, not only Redis CPU average.
[ Spike traffic ] → [ Gateway queue ] → [ Limiter: Redis slow ]
|
p99 latency ↑
still 200 until queue drops or timeout → 503
In the interview: Pick one scene. Tell it like a short story—what users felt, which metric lied, what you would ship next. You do not need every scene; you need to sound like someone who knows green is not the same as correct.
Bottlenecks and tradeoffs
Exactness vs throughput
The tension — Sliding windows feel fair; exact structures cost more Redis work per check.
What breaks — p99 for Check blows the sub-ms budget at 10M checks/s.
What teams do — Approximate sliding windows; token bucket with Lua refill; admit small error bounds.
Say in the interview — Name fairness vs Redis CPU—not “we use sliding window” alone.
Central store vs edge pre-check
The tension — Edge-only is fast; global caps need a remote round trip.
What breaks — Pure edge under-enforces; pure central adds RTT.
What teams do — Hierarchical: local hard cap + Redis global refine in one Lua pipeline.
Say in the interview — Draw two layers when they ask about 50 gateways.
Availability vs abuse on failure
The tension — Fail-open keeps revenue flowing; fail-closed stops credential stuffing when Redis blips.
What breaks — Wrong default on login during Redis outage = security incident.
What teams do — Route-level policy; circuit breaker with short cached decision only where safe.
Say in the interview — “Catalog read fail-open; login fail-closed”—and mean it.
If you remember one thing: The limiter can become the bottleneck that protects nothing if Redis or the gateway queue is ignored.
Interview tips
You have the full design in mind now. These five back-and-forths are how interviewers test whether you can defend it under pressure. Each one starts with a tempting shortcut, an interview push, and a clear answer.
Each gateway keeps its own count — your limit multiplies by the fleet
You might say: “Each API server keeps its own count in memory for every client. That is fast and simple.”
Interview Push: “You have 50 gateways and a published limit of 100 requests per minute per user—how many requests can that user actually get through?”
Land here: Do the math out loud. Each gateway allows up to 100 before its counter fills. Spread across 50 machines, that is roughly 50 × 100 = 5,000 requests per minute—fifty times the product promise. Local memory is not shared; Gateway A never sees what Gateway B already allowed. Put the authoritative counter in Redis (or another shared store) and update it atomically. A tight local hard cap is fine as a shock absorber only if a global Redis check still enforces the real budget.
Fixed windows look fair—until two minutes meet at the edge
You might say: “We reset the counter every minute. One hundred requests per minute means one hundred, period.”
Interview Push: “Can a client send about twice the average rate in a few seconds around the clock boundary?”
Land here: Yes. At 12:00:59 the user can spend the last 100 of the old minute; at 12:01:00 a fresh window opens and they can spend another 100 immediately. That edge burst is why fixed windows feel “correct” on paper and unfair in production.

Figure: Minute boundaries can double a “100/min” promise in a two-second edge burst.
Prefer token bucket when the product needs controlled bursts, or a sliding window (exact or approximate) when minute-boundary fairness causes support tickets. If you keep fixed windows for coarse abuse caps, say the edge-burst tradeoff out loud.
Redis is down—do you let everyone through?
You might say: “If Redis fails, we fail open and let traffic through so the site stays up.”
Interview Push: “Is that OK for password reset and login?”
Land here: Fail-open keeps availability but invites abuse—credential stuffing loves an open door. Fail-closed protects auth paths but can look like an outage for catalog reads. Strong answers pick different policies per route: fail-closed on login and password reset; maybe fail-open on read-heavy catalog with a tight local shadow cap. Never treat one global default as “neutral.”
A bare 429 teaches clients to hammer harder
You might say: “We return HTTP 429 when the user is over limit.”
Interview Push: “What should the client do next—and what happens if you give no guidance?”
Land here: Return Retry-After (seconds or an HTTP-date) plus remaining/limit/reset headers when you can. Without backoff hints, well-meaning clients and broken retry loops retry immediately, which turns a throttle into a thundering herd. Distinct status codes matter too: 429 means “slow down”; 503 means “origin unhealthy”—clients should not treat them the same.
One hot client saturates one Redis shard
You might say: “We shard Redis randomly, so load should spread.”
Interview Push: “One OAuth client sends a million checks per second on a single key—what happens to the cluster?”
Land here: Hashing does not help if every check uses the same key. That principal maps to one hash slot; that shard’s CPU and network saturate while other shards look idle. Mitigate with sub-keys (client:shard:k), hierarchical budgets, a dedicated cluster for abusive tenants, or probabilistic counting when exactness is optional. Watch top keys, not only average Redis CPU.
Red flags to avoid without correcting yourself: “GET then SET,” “each node counts locally for a global cap,” “429 with no Retry-After,” “one fail-open policy for every route.”
After each interview push, close with one real part of the design you would build—a shared Redis key, a Lua check, a route-level fail mode—not a vague line like “we will scale it later.”
What should stick
After reading this guide, you should be able to explain:
- Shared state — Global limits need one authoritative counter per key (or an honest hierarchy). Local counters multiply the limit by the fleet size.
- Algorithm choice — Fixed window is cheap but spiky; token bucket for product bursts; sliding for boundary fairness; leaky bucket for steady drain.
- Atomicity — INCR+EXPIRE or Lua for read-modify-write in one RTT; never GET then SET.
- HTTP contract — 429 +
Retry-After+ limit headers; distinct from 503. - Failure is policy — Fail-open vs fail-closed per route; hot keys need sharding; watch
limiter.checkp99 and top keys, not only 429 count.
Tell it in the room: “Every request: the gateway builds tier:route:principal, runs one atomic Redis check, then either returns 200 with limit headers or 429 with Retry-After without calling upstream. I’d use token bucket when the product needs bursts. Login fails closed if Redis times out; catalog may fail open with a tight local shadow cap. One hot client id means I shard that counter key so one Redis slot cannot take down the cluster.”
Key Takeaways
- Clarify first — Who is limited, burst vs rigid window, exactness, fail mode, and what is out of scope.
- One shared counter — Local per-gateway counts under-enforce by about the number of machines.
- Atomic updates — Lua or INCR in one round trip; GET-then-SET races at the limit.
- Pick the algorithm from burst shape — Fixed window, token bucket, sliding, or leaky bucket—with a cost tradeoff.
- Headers are part of the API — Limit / Remaining / Reset and Retry-After on deny.
- Deny before upstream — A 429 should not call the expensive backend.
- Operate the critical path —
limiter.checklatency, top keys, policy version, pool wait. - Green is not correct — Silent over-allow and queueing-behind-slow-Redis both hide on the wrong dashboard.
Related Topics
- Rate Limiting and Throttling - Fundamentals behind gateway limits
- Rate Limiting Algorithms - Fixed window, sliding window, token bucket deep dive
- Vaccine Booking System Design - Admission control and flash traffic patterns
- Distributed Cache System Design - Shared in-memory state and hot keys
- Notification Service System Design - Async work that must not block the request path
Quick revision notes
Use this as a last look before a mock interview, not as a substitute for the dig above.
Clarify first. Agree who is limited (user, key, IP, route), whether short bursts are allowed, how exact counts must be, what happens when Redis fails on sensitive routes, and what you are leaving out of scope. If you skip this, you often design a clever limiter for the wrong problem.
Draw one request path. Client hits the gateway, the gateway builds a key, Redis updates the counter in one atomic step, then the gateway either forwards upstream with limit headers or returns 429 with Retry-After and never calls the expensive backend.
Remember the dig, not just the boxes. Name the Redis key shape, say Lua or INCR for atomicity, and explicitly reject GET-then-SET because two gateways can both approve the last request.
Pick the algorithm from burst shape and cost. Fixed window is cheap but spiky at boundaries. Token bucket fits product bursts. Sliding window (or an approximation) fits fairness complaints. Leaky bucket fits a steady drain into a fragile downstream.
Treat HTTP as part of the design. Limit, Remaining, and Reset help well-behaved clients; Retry-After stops a thundering herd after a 429. Keep 429 distinct from 503.
Operate what you drew. Watch limiter.check latency, top keys, and policy version—not only the raw 429 count. Prefer fail-closed on login and password reset; catalog reads may fail open with a tight local shadow cap if you say so out loud.
Practice next: Open Design a Rate Limiter on Practice System Design and run a timed attempt using the clarifying questions and one-request story above—without peeking at this dig until you have a draft on the board.
What interviewers expect
- Clarify dimensions first: limit by user id, API key, IP, route, or composite key; define burst (token bucket) vs rigid window semantics before you pick Redis data structures.
- Name at least one algorithm and its failure mode: fixed window (cheap, boundary spikes), sliding window (fairer, more work), token/leaky bucket (smooth bursts, product-friendly).
- Distributed state: where counters live (Redis cluster, dedicated limiter service), atomicity (INCR+EXPIRE, Lua, CAS loops), and why each gateway counting locally is wrong for global caps.
- Latency story: budget for check (e.g. under 1 ms p99); optional local pre-check + remote refine; pipelining and connection pools.
- HTTP contract: 429, Retry-After, X-RateLimit-Limit / Remaining / Reset (or vendor equivalents); idempotency of POST vs charging GET—state your product rule.
- Hot keys: one OAuth client or NAT IP; mitigation (sub-keys, hierarchical budgets, regional caps).
- Failure: fail-open (availability, abuse risk) vs fail-closed (security); circuit breakers; stale allowance—justify for login vs public read API.
- Observability: limiter check latency, Redis error rate, top blocked keys, not just 429 count.
Interview workflow (template)
- Clarify requirements. Confirm functional scope, users, consistency needs, and which non-functional goals matter most (latency, availability, cost).
- Rough capacity. Estimate QPS, storage, and bandwidth so your data model and partitioning story are grounded.
- APIs and core flows. Define a minimal API and walk 1–2 critical read/write paths end to end.
- Data model and storage. Choose stores for each access pattern; call out hot keys, indexes, and retention.
- Scale and failure. Add caching, sharding, replication, queues, or fan-out as needed; say what breaks in failure modes.
- Tradeoffs. Name alternatives you rejected and why (e.g. strong vs eventual consistency, sync vs async).
Frequently asked follow-ups
- Token bucket vs sliding window—when do you pick each?
- How do you implement distributed rate limiting without crushing Redis?
- What happens when two gateway nodes race on the same key?
- How do you handle a hot key for one abusive client?
- Fail open or fail closed—what do you choose for an API vs a login endpoint?
Deep-dive questions and strong answer outlines
How does a token bucket work for HTTP APIs?
Tokens refill at a steady rate; each request consumes one (or weighted). Allows smooth bursts up to bucket capacity. Contrast with fixed window where a client can spike at window boundaries.
Where do you store counters at 10M checks/s?
In-memory per process is wrong for global limits. Use a fast remote store (often Redis) with sharding by key, pipelining, and TTL aligned to windows. Mention hot-key mitigation (sub-keys, regional budgets) if pushed.
How do you make increments correct under concurrency?
Single-key atomic ops: INCR + EXPIRE, or Lua script for check-and-set in one round trip. For sliding windows, use sorted sets or approximate structures and admit error bounds if they ask for scale.
What do you return when limited?
HTTP 429, meaningful body, Retry-After seconds or timestamp, optional X-RateLimit-Remaining and reset time. Helps clients backoff without hammering.
How do layered limits (per user + per IP + global) compose?
Check innermost budget first for cheap rejection, then outer caps—often nested keys or a pipeline of checks in one Redis round trip with Lua. Define precedence when budgets disagree (e.g. authenticated user id beats IP for fairness).
How do you test rate limits without flaky tests?
Deterministic clock injection for windows, unit tests on pure counter math, and integration tests with controlled Redis or an embedded fake with the same semantics—not wall-clock sleeps in CI.
AI feedback on your design
After a practice session, InterviewCrafted summarizes strengths, gaps, and interviewer-style expectations—similar to a written debrief. See a static example report, then practice this problem to get feedback on your own answer.
FAQs
Q: Do I need exact counts or is approximate OK?
A: Many production systems accept a small error (for example sliding-window approximations) so they can stay fast at huge scale. Say the tradeoff out loud: exact Lua scripts cost more CPU per check; approximate structures and a little local leakage cost less and still protect backends.
Q: Is Redis always the answer?
A: Often, because of atomic ops and TTL. Alternatives include a dedicated limiter service, an edge SDK with sync, or hierarchical limits (a tight local cap plus a global Redis refine). Show why you picked one for this product, not that Redis is a default stamp.
Q: How is this different from a queue?
A: Rate limiting rejects or delays excess traffic on the request path; queues buffer work for later. You may combine them (429 vs 503 with a queue upstream), but the usual interview question is synchronous allow or deny.
Q: How do I talk about global vs per-region limits?
A: Exact global counts across regions are hard because of latency. Options include sticky routing, regional budgets that can drift for a short window, or a central authority for strict global caps—name consistency versus latency.
Q: Token bucket vs sliding window—which should I pick in an interview?
A: Pick token bucket when the product promises short bursts then a steady rate. Pick sliding window (or an approximation) when minute-boundary spikes create support tickets. Mention fixed window only as the cheap coarse option and call out the edge-burst problem.
Q: What is a hot key in rate limiting, and how do you fix it?
A: A hot key is one client identity that maps to a single Redis hash slot and absorbs so much check traffic that that shard’s CPU saturates while other shards look fine. Mitigate with sub-keys, hierarchical budgets, dedicated clusters for abusive tenants, or probabilistic counting when exactness is optional.
Practice interactively
Open the practice session to use the canvas and stages, then review AI feedback.