Requests passing through a rate-limiting gate

Design a rate limiter

Token bucket vs sliding window, distributed enforcement, and where to place limits — worked through Tatkal-style booking quotas end to end.

At a glance

Protect backends from abusive or accidental overload. Limits may be per API key, IP, user ID, or endpoint. Decisions must be fast (microseconds to low milliseconds) and correct enough under concurrency.

Analogy diagram: temple token counter protecting the sanctum API.
Temple queue tokens — only N devotees enter per minute; protect the sanctum.
01Requirements

Per user / IP / API key · 429.

02Algorithms

Token bucket · sliding · fixed.

03Placement

Gateway first · service last.

04Redis

Atomic counters / Lua scripts.

05Distributed

Shared state · fail open/closed.

06Client UX

429 + Retry-After headers.

Takeaway

Clarify who you're limiting (user, IP, API key, route) and over what window, pick an algorithm with its tradeoffs stated, and enforce as early as sensible against shared Redis state.

Whiteboard order

Draw in this order so algorithm choice is grounded in traffic numbers, not vibes.

  1. First — dimensions: who (user/IP/API key), limit N, window W, action on exceed (429), burst allowed?
  2. Second — capacity: gateway QPS, unique keys, Redis ops per request. Prove the limiter itself will not melt.
  3. Third — algorithm sketch: token bucket (or sliding window) diagram + Redis key pattern + Lua atomicity note.
  4. Then deep dives: placement (edge vs post-auth), fail open/closed, multi-region, 429 headers, hot keys.
Takeaway

Who/how many/window → capacity → algorithm + Redis → placement/failure mode. Don't open with Lua before the dimensions exist.

Requirements

Rate limiting is a product decision wearing an infra costume. Nail the dimensions first.

Functional

  • Reject or delay excess requests with HTTP 429.
  • Configurable limits per user ID, IP, API key, and/or endpoint.
  • Tiered limits (free vs paid) if the product has plans.
  • Optional: soft throttle (queue/slow) vs hard reject.

Non-functional

  • Low latency — the limiter must not become the new bottleneck.
  • Distributed — consistent across many gateway instances.
  • Accuracy vs cost — exact vs approximate is a valid tradeoff.
  • Failure mode — fail open (allow) vs fail closed (reject) when Redis is down.
Takeaway

Write the dimensions on the board — who, how many, what window, what happens on exceed — and state fail-open vs fail-closed before anyone asks.

Capacity math worked example

Size the limiter like any other service — it sits on every request and can become the bottleneck.

Napkin math: 50k QPS at the gateway times one Redis round trip equals 50k Redis commands per second, and 100k keys at roughly 100 bytes each is about 10MB of memory.
The napkin math that justifies Redis before you've written a line of code.
  • Assumptions: API gateway handles 50k QPS peak. Limits are per API key; ~100k active keys in a window; typical rule 100 req/min/key with burst 20.
  • Redis ops: token bucket via one Lua script ≈ 1 round trip per request → 50k Redis cmds/sec at peak. One well-sized Redis primary (or cluster shard) handles this; still plan replicas for HA.
  • Memory: each key stores a few integers (tokens, timestamp) ≈ ~100 bytes. 100k hot keys ≈ ~10 MB — trivial. Sliding window log storing every timestamp would be far heavier; mention why you might avoid it at this QPS.
  • Local optimization: optional in-process token cache for ultra-hot keys can cut Redis QPS 2–5× at the cost of slight over-allow across gateway instances — call the accuracy tradeoff.
  • Reject path: even at 10% limited, that is 5k 429s/sec — cheap responses, but metrics/logging must not synchronously block.

Why this napkin matters: it's easy to put the limiter behind a slow DB, or do a non-atomic read-modify-write across gateways. At 50k QPS, both fail immediately. The math also justifies Redis and explains why sliding-window-log (store every timestamp) can be memory-expensive under abuse. Worth anticipating: hot API keys hammering one Redis slot, multi-region global quotas, and what happens when Redis latency spikes.

Takeaway

One atomic Redis round trip per request holds at 50k QPS with a trivial memory footprint. That's what justifies Redis over a slow DB and over storing every timestamp.

Algorithms to compare

A rate limiter is a promise about the worst case — design it for the moment traffic spikes.

Compare a few algorithms and pick one. Draw the visual early.

Token bucket: tokens refill at a rate; each request removes one; empty bucket rejects.
Token Bucket — refill rate / second · bucket size = throttle · no tokens → reject.
Sliding window log timeline rejecting the 9th request in a rolling minute limited to 8.
Sliding Window Log — count exact timestamps in [now−W, now]; reject when over limit.
  • Fixed window — counter per time bucket (e.g. per minute). Simple; bursty at window edges (2× limit across the boundary).
  • Sliding window log — store timestamps of requests; count those in [now−W, now]. Smooth and exact; more memory.
  • Sliding window counter — hybrid of fixed windows with weighted previous bucket. Approximate but efficient.
  • Token bucket — tokens refill at rate r up to capacity b; each request consumes one. Allows controlled bursts — the same shape as the Tatkal booking window itself, a fixed quota that refills the next minute.
  • Leaky bucket — smooths outflow to a constant rate; good for shaping traffic to a downstream.
Fixed window counter chart with succeeded and rejected requests per minute window.
Fixed Window Counter — counter resets each window; easy edge bursts across the boundary.

Pick: Token bucket in Redis for API gateways (burst-friendly). Mention sliding window as the smoother alternative when exact-ish fairness matters more than burst UX.

Why token bucket is the usual MVP pick: APIs want short bursts (upload retries, page loads firing parallel calls) without letting a client sustain 2× the long-term rate. Fixed window is simpler to implement with INCR+EXPIRE but has the edge-burst bug you should name — that's the rejected-alone alternative. Sliding window log is fairest and most memory-hungry — rejected for MVP on cost, not correctness. Worth anticipating: how you refill tokens atomically, and whether burst capacity b equals the sustained rate or is intentionally higher.

Takeaway

Compare at least three algorithms out loud, then pick token bucket for MVP — and name the fixed-window edge burst as the reason you didn't stop there.

Where to place the limiter

Placement is as important as the algorithm. Wrong layer = either too late or too coarse.

Client to Gateway with RL box routing to three microservices.
API Gateway Rate Limiter — enforce once at the door before fan-out.
RL embedded inside each microservice behind a gateway.
In-Process Rate Limiter — each service owns its counters (no shared accuracy across replicas).
Microservices check a global rate limiter service.
Dedicated Service Rate Limiter — services check a central limiter before proceeding.
Defence in depth: Edge enforces per-IP limits, Gateway enforces per-user and per-API-key limits after auth, Service is the last-resort layer — each rejects some traffic with a 429 before the next layer sees it.
Coarse at the edge, precise after auth, last-resort at the service — three layers, each catching what the last one missed.
  • API gateway / edge — first line of defence; blocks bad traffic early; best for IP and API-key limits.
  • App middleware — finer rules per route or user role after auth.
  • Service mesh sidecar — per-service limits in microservices.
  • Downstream service — last resort; upstream should have caught most abuse.

Often you'll use both: coarse IP limits at the edge, finer per-user/API-key limits after authentication in the gateway or middleware.

Placement is a product decision: edge IP limits stop scrapers before auth burns CPU, but NAT and mobile carriers share IPs, so a per-IP limit alone punishes innocent users behind the same CGNAT block. Post-auth per-user limits are fairer and match billing tiers — the same reason Tatkal checks your account's quota at the booking gateway rather than after you've already reached payment. Defence in depth: coarse at the door, precise once you know who they are. Worth anticipating: internal service-to-service calls need separate, higher limits, and the limiter has to run synchronously on the request path — async rate limiting is close to useless for admission control.

Takeaway

Coarse IP limits at the edge, per-user and per-API-key limits after auth — defence in depth, and CGNAT is why per-IP alone isn't fair.

Redis approach

Redis is the usual shared counter store because it's fast and supports atomic ops.

  1. Centralize counters in Redis with atomic INCR + EXPIRE (fixed window) or a Lua script (token bucket / sliding).
  2. Key patterns: rl:{apiKey}:{route}:{window} or rl:{userId}:tokens.
  3. Use a Lua script (or Redis Cell / Redis Gears module) for check-and-consume in one round trip.
  4. Return remaining tokens / reset time to populate response headers.
  5. Optional local cache for hot keys to cut Redis QPS — accept slight over-allow.
# Fixed-window sketch (conceptual)
INCR rl:user:42:202603221430
EXPIRE rl:user:42:202603221430 60
# if count > limit → 429

# Token bucket needs atomic read-refill-consume
# → prefer a single Lua script in production
Left: two gateways each read a stale count of 9 and both allow the next request, over-allowing to 11 against a limit of 10. Right: a single Lua script serializes the read, refill, and consume steps in Redis, so the second call correctly sees zero tokens left and is rejected.
Non-atomic read-then-write over-allows by however many gateways raced; one script closes the gap.
API gateways get bucket data from sharded Redis; success to server or 429 to client.
Redis Sharding — each shard owns a different set of users' buckets.
API Gateway routes via consistent hashing to Redis primaries with async read replicas.
Redis Failover — consistent hashing to the right shard · async replication to read replicas.
Gateways watch ZooKeeper for rules and Redis for bucket data.
ZooKeeper (or etcd) — watch for rule changes; Redis still holds the counters.

Token bucket needs to read tokens, refill based on elapsed time, and consume — three steps that must not interleave across gateways. A single Redis script gives you that atomicity in one round trip. The trade-off: the script has to stay short and CPU-light at 50k QPS; don't push business logic into Redis. Worth anticipating: Redis Cluster key hashing (keep related fields on one hash tag) and clock source (use Redis's own TIME, not each gateway's local clock).

Takeaway

Redis plus an atomic Lua script (or INCR+EXPIRE for fixed window) is the default distributed design. A multi-step non-atomic check is never correct under concurrency.

Distributed considerations

With multiple gateway instances, local in-memory counters don't work — each instance thinks it has the full quota. That's like every booth counting turnout independently and declaring victory.

  • Shared state — Redis (or similar) so all instances see one quota.
  • Clock skew — prefer server-side Redis time; don't trust client clocks for windows.
  • Multi-region — per-region limits are easier; global limits need a global store or approximate sync (harder).
  • Redis failure — fail open (availability) vs fail closed (safety); revisit the call made in Requirements.
  • Hot keys — one viral API key hammers one Redis key; consider local smoothing or key sharding with aggregation.
  • Idempotent retries — clients retrying 429s need backoff; document Retry-After.

Local in-memory counters fail the distributed story because N gateways each allowing the full quota effectively multiplies the limit by N. Shared state fixes that but creates a dependency — which is exactly why the fail-open vs fail-closed decision matters. Exact multi-region quotas are genuinely hard, since a single source of truth means every request pays cross-region latency; a more honest design starts with per-region limits plus a coarser global budget.

Left: US, EU, and APAC regions each round-trip to one exact global Redis store, paying cross-region latency on every request. Right: each region enforces its own local budget against a local Redis and gossips usage to the others every few seconds, drifting slightly but staying fast.
Exact and slow, or fast and approximate — say which one the prompt actually needs.

Hot-key mitigations, concretely: a single viral API key or IP hammering one Redis key can bottleneck on that one key's CPU cost even when the cluster overall has headroom. Shard the counter itself — rl:{key}:{shard} across N shards with the request hashed to one shard, then sum for reporting — trading a slightly looser limit (N× burst in the worst case) for no single hot shard. Or keep a short-lived local approximate counter per gateway instance for that one key and only true up against Redis periodically, accepting the same over-allow trade the token-bucket cache mentioned earlier makes.

Redis failover mitigations, concretely: a primary failover (or a slow, GC-pausing replica) can turn your rate limiter into your outage if you don't plan for it. Set an aggressive client-side timeout (a few milliseconds, not the driver default) on the Lua call so a slow Redis doesn't stack up request latency; wrap the call in a circuit breaker so repeated timeouts trip to the pre-agreed fail-open/closed default instead of retrying against a dying node; and prefer Redis Sentinel or Cluster with replicas so failover is measured in seconds, not the outage window of a single box. The one thing to say out loud: whatever you pick, name the timeout number, not just the word "timeout".

Takeaway

Shared Redis, atomic ops, an explicit fail-open/closed call already made, and honesty that exact global limits across regions are hard — that's a complete distributed story.

Response and headers

Good rate limiters communicate clearly so clients can back off instead of retry-storming.

HTTP/1.1 429 Too Many Requests
Retry-After: 32
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1710000032
  • Return 429 with a clear body message.
  • Include Retry-After (seconds) when possible.
  • Expose limit / remaining / reset headers for API clients.
  • Log and metric rate-limit hits by key and route for ops.

The Retry-After header does more than politeness — it's the thing that stops a retry storm. A client that gets a bare 429 with no guidance tends to retry immediately (or on a fixed short interval), and a thousand clients doing that at once turns one rate-limit event into a synchronized thundering herd against the same endpoint the moment the window resets. A concrete number tells well-behaved clients exactly when to come back, spread out. On the ops side, emit a counter for rate-limit hits tagged by key/route and a histogram of remaining-tokens at request time — the first tells you who's being throttled and whether a limit needs raising, the second gives you an early warning that a key is trending toward its cap before it starts erroring.

Cost and performance levers

Two levers worth naming together because they trade against each other. Memory via TTL: every rate-limit key should carry an expiry roughly matching its window (a minute key TTLs in ~60s, a token-bucket key TTLs after it would have fully refilled) — without it, a small percentage of keys that go cold (churned API keys, one-off IPs) accumulate forever and your ~10MB napkin estimate quietly becomes unbounded. Local cache accuracy: caching a hot key's token count in each gateway's memory for even a few hundred milliseconds cuts Redis round trips dramatically, but every instance is now enforcing against a slightly stale count — the more instances, the more the effective limit can overshoot. The honest framing for an interviewer: TTL is close to free correctness, local caching is a deliberate accuracy-for-throughput trade you should only take once Redis, not the algorithm, is the bottleneck.

Algorithm picker with examples

Distributed race

Two gateway pods INCR without Redis atomicity ⇒ over-allow. Use Redis INCR/Lua or a single rate-limit service. For multi-region, decide: global limit (hard) vs per-region budget (simpler).

Failure modes to mention — specific to this system

Redis goes slow, not down. A GC pause on a replica or a noisy-neighbor primary adds tail latency to every Lua call — the limiter is on the hot path of every request, so this shows up as your whole API getting slow, not as an error. Mitigation: a tight client-side timeout on the rate-limit call specifically (a few ms, tighter than the request's overall timeout budget) with a circuit breaker that falls back to the pre-agreed fail-open/closed default rather than blocking.

Redis fails over. A primary crash triggers a Sentinel/Cluster failover that takes seconds, during which writes to the old primary fail. Mitigation: replicas plus a client that retries once against the new primary — but cap that retry, since a naive unlimited retry during a failover is exactly the retry-storm pattern you warned the client about with Retry-After.

A hot key. One viral API key or IP concentrates load on a single Redis key/shard even when the cluster is otherwise idle. Mitigation: shard that key's counter across N sub-keys and sum for reporting, or accept a short local approximate count per gateway instance for just that key.

Clock skew. If any part of the logic reads a gateway's local wall clock instead of Redis's own TIME command, gateways with drifted clocks compute different window boundaries or refill amounts for the same key — one instance might refill tokens a few hundred ms early or late relative to another. Mitigation: always derive time inside the Lua script from Redis's clock, never the caller's.

What is expected at each level

Mid, senior, and staff expectations for rate limiter design interviews.
Breadth → depth shifts with level. Senior rushes algorithms to spend time on distributed hard parts.

Architecture sketches by level

Three progressive drawings — use the matching sketch in the interview and say the trade-off out loud.

Beginner rate limiter: in-memory counter at the API gateway.
Beginner / mid spine — gateway + local counter (not enough alone across replicas).

Pros: Fast to draw — enforce early, return 429 + Retry-After. Cons: Per-instance counters don't agree across replicas — attackers multiply quota by N gateways.

Mid rate limiter: Redis token bucket with atomic Lua.
Mid / senior default — Redis token bucket with an atomic script.

Pros: Shared Redis + atomic script gives consistent limits; token bucket allows controlled bursts. Cons: Redis is a critical dependency; non-atomic GET/SET races; sliding-window-log burns memory under abuse.

Pro rate limiter: multi-dimension limits, Redis cluster, fail-open versus fail-closed.
Senior / staff production depth — multi-dimension keys, HA Redis, explicit fail policy.

Pros: Multi-dimension keys, HA Redis, and an explicit fail-open/closed policy show production judgment. Cons: Local approx caches trade accuracy for QPS; global multi-region quotas are hard — don't claim perfect global sync unless you design for it.

Sample answers to say out loud

Defence in depth: Edge per-IP, Gateway per-user and API-key, Service last resort.
Reject early, but in layers.
Dimensions, token bucket, Redis atomic script, 429 headers, fail-open versus fail-closed.
Five boxes you can sketch fast under time pressure.
Exact global Redis vs per-region budgets with gossip.
Name the exact-vs-approximate trade before they ask.
Interview takeaway

Mid: Token Bucket + Gateway + Redis + "we'd shard." Senior: algorithm trade-offs, atomicity, fail policy, hot keys/HA without hand-holding. Staff+: ops, multi-region, canaries — or skip this question for harder infra.

← Lattice