Case Studies
Rate Limiter
Fixed vs. sliding window counters, and why the window boundary is where most answers lose points.
One of the most-recurring building-block prompts across API and infra-heavy System Design loops — precisely because so many other designs end up needing one.
Understanding the Problem
Think of a highway on-ramp metering light — the kind that lets one car merge every few seconds during rush hour. It doesn't stop cars from wanting to get on; it just controls how fast they're let in, so the highway itself never gets so congested it grinds to a halt. A rate limiter does the same job for an API: it doesn't stop a client from sending requests, it just decides, request by request, how many actually get let through.
An API needs to cap how many requests each client can make in a given window, so that one noisy or abusive client can't degrade service for everyone else. The limiter has to make an allow/reject decision on the hot path of every request, so its own latency and availability matter as much as its correctness.
When it does reject, it needs to reject immediately rather than queue the request for later. Queuing would mean holding memory for a request that might never get served, and it would make response times unpredictable for every well-behaved client waiting behind it — the opposite of what a rate limiter is supposed to buy you.
With that shape in mind, here's what we actually need to build.
Functional Requirements
- Given a client identifier (an API key or user ID) and an endpoint, decide whether to allow or reject the request against a configured limit (e.g. 100 requests/minute).
- Return enough information for the client to back off intelligently — how many requests remain, and when the window resets.
- Support different limits for different client tiers (free vs. paid).
Out of scope
- Network- or CDN-layer DDoS mitigation for unauthenticated traffic — this design assumes an already-identified, authenticated client.
- Rate limiting by raw IP address for anonymous requests.
- Long-term analytics or historical reporting on rate-limit activity — this design tracks only the live count needed for the next decision, not a queryable history of past ones.
Non-Functional Requirements
- The check itself must add negligible latency to the request path — under 5ms at p99, since it runs in front of every API call, not just some of them.
- Assume 50,000 requests/sec at peak across all clients, with up to 10,000 distinct rate-limited identifiers active at any given moment.
- The limiter must not become the bottleneck it exists to prevent — it needs to survive at the same scale as the traffic it's gating.
These numbers are what the rest of this design leans on, especially the Capacity Estimation right below — worth pinning down before deciding anything about implementation.
Capacity Estimation
Redis memory footprint here is trivial by design: at up to 10,000
concurrent identifiers, and a sliding window counter needing at most two
small counters per identifier (current window + previous window, each
maybe 50 bytes including the key itself), that's on the order of 1MB
total — nowhere near a capacity concern. The number that actually matters
is throughput, not memory: 50,000 checks/sec, each a single Redis INCR,
sits well within what a single, well-provisioned Redis node handles (Redis
routinely sustains 100,000+ simple ops/sec). That's why this design can
stay a single Redis instance rather than needing to shard the limiter
itself — the constraint that would actually force sharding is a single
identifier's traffic exceeding one node's throughput, not the aggregate
across all 10,000 identifiers.
With the scale pinned down, here's the state that scale actually has to be stored in.
Core Entities
type RateLimitState = {
identifier: string; // e.g. "apiKey:abc123"
windowStart: number; // epoch ms
count: number;
};
type RateLimitRule = {
tier: "free" | "paid";
limit: number; // requests allowed per window
windowSeconds: number;
};
The FR above calls for different limits per client tier, and that needs its
own small piece of state, separate from the live counter — a RateLimitRule
can change (a client upgrades from free to paid) without touching the
counter that's already mid-window for that client.
API Interface
POST /internal/rate-limit/check
{ identifier: string, limit: number, windowSeconds: number }
-> { allowed: boolean, remaining: number, resetAt: number }
That's the internal contract between the gateway and the limiter. What the actual API client sees is different — plain HTTP, not a JSON envelope:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1706812800
Retry-After: 42
{ "error": "rate_limit_exceeded" }
The gateway is what translates the internal check's { allowed, remaining, resetAt } into these headers — and it does it on every response, not just
rejected ones. A client still under its limit gets the same
X-RateLimit-Remaining header on a normal 200, so it can see how much room
is left well before it ever gets a 429.
High-Level Design
Two questions shape this design: where does the check actually run, and what does it check against. Let's take them in order — the second only makes sense once the first is settled.
Where the check runs
In-process (Bad)
Client
App Server (1 of many)
its own local counter
Backend logic
never sees other servers' traffic
Gateway-integrated (Great)
Client
API Gateway
check runs inline, before forwarding
Redis
one shared counter
Here's the in-process failure mode with real numbers. Say the limit is 100 requests/minute, and one client's traffic happens to land on 5 different app server instances behind a load balancer. Each server sees roughly 20 requests/minute from that client — comfortably under 100 by its own count — while the client's actual total across all 5 is already at the limit. No single counter ever crosses 100, and the effective limit for that client is 5x what was configured.
A dedicated service fixes that: one shared source of truth means the count is finally global. But it adds a full round trip to a separate service on every request, purely to ask "was this one allowed?" The gateway-integrated approach gets the same global correctness by talking to Redis directly from the gateway — a call the gateway needs to make either way, since it's already the thing deciding whether to forward the request. There's no separate rate-limiting service to add a hop to, deploy, or monitor on its own.
Chosen: gateway-integrated. That's why the diagram below draws the check as a step inside the gateway's own path through the request, not a detour to another service.
The check itself
Client
API Gateway
rate-limit check runs inline
Redis
INCR rate:{id}:{window}
Backend Service
only reached if allowed
The simplest working version is a fixed window: increment a Redis counter
keyed by identifier and the current window, set to expire at the end of
the window on the first increment of each window, and reject once the count
exceeds the limit. Redis's atomic INCR means two concurrent requests for
the same identifier can't both read a stale count and both get admitted —
the increment itself is the check, with nothing to coordinate around it.
This is the same publicly-documented pattern behind APIs that expose their
own rate limit state in response headers (a remaining/reset pair, the
way a well-known payments API does) — the client-facing shape mirrors what
the limiter itself has to track internally.
A fixed window is a fine starting point, but it has a real gap worth naming before it becomes the shipped version — which is exactly what the trade-offs below work through.
Trade-offs
Two decisions are worth defending if your interviewer pushes: which window algorithm to use, and whether a sliding window is even the right shape of algorithm compared to the alternative most real APIs reach for instead.
-
Fixed window vs. sliding window counter: a fixed window is simple — one counter per identifier per window — but allows up to 2x the limit across a window boundary (a burst at 0:59 and another at 1:01 both pass, because they land in different windows). A sliding window counter approximates a true sliding log by weighting the previous window's count proportionally to how much of it overlaps the current moment, closing that gap at a small accuracy cost. Chosen: sliding window counter as the default, since the boundary-burst problem is a real gap for a limiter whose whole job is precision.
-
Sliding window log (exact) vs. counter (approximate): an exact log — one timestamp stored per request — gives perfect accuracy, but costs memory proportional to the limit itself (a 10,000-request limit means up to 10,000 stored timestamps per identifier). The counter approximation trades a small amount of that accuracy for a fixed, tiny footprint — two counters per identifier, regardless of how high the limit is.
That trade isn't just theoretical. At least one large CDN operator has published production numbers for exactly this approximation, measured against a true sliding log across hundreds of millions of real requests: on the order of a few percent average difference between the estimated and actual rate, and well under 1 in 10,000 requests ending up allowed or rejected incorrectly as a result. Rejected for this design's scale: at 10,000 concurrent identifiers, the exact log's memory footprint is a meaningfully larger cost than the counter's, for a level of precision the NFRs don't require — and that real-world error rate shows the extra precision isn't buying much even where it's been measured at real scale.
Sliding window counter (chosen)
Request arrives
INCR current window
one atomic Redis op
Weight previous window
e.g. 70% overlap + current count
Allow if weighted total ≤ limit
Token bucket
Request arrives
Read tokens + last refill time
Compute refill for elapsed time
capped at bucket size
Allow, consume 1 token, write back
-
Sliding window counter vs. token bucket: token bucket works differently from every algorithm above. Each client gets a bucket holding a maximum number of tokens (its burst capacity); tokens refill at a steady rate; every request spends one, and gets rejected if none are left. The real upside: a client that's been idle can burst — spend a pile of saved-up tokens all at once — while a client sending a slow, steady drip never runs dry, since it's never spending faster than the refill rate replenishes. That's not just theoretical: a well-known payments API runs token bucket in production specifically because its traffic is bursty by nature — a client scripting a quick batch of calls after being idle is normal, not abuse — and a hard per-window cap would reject legitimate spikes a token bucket absorbs for free.
The real cost is on the state-update side. A sliding window counter's entire check is one atomic
INCR— Redis does the read, increment, and write as a single indivisible step, so there's nothing to coordinate. A token bucket's check is read-then-compute-then-write: read the current token count and last refill time, compute how many tokens should have accrued since then, then write the new count back. Two gateway instances doing that at the same instant can both read the same starting token count, both compute "yes, a token's available," and both allow a request that should have exhausted the bucket after the first one — the exact race a singleINCRhas no room for. Fixing it means wrapping the whole read-compute-write sequence in one atomic unit — a Redis Lua script, not a single command. If you've never written one, don't worry — you don't need to reproduce it in an interview, just understand what it buys you: the entire sequence runs as one indivisible step, the same guaranteeINCRgives you for free.Chosen: sliding window counter. Nothing in this design's FRs calls for letting an idle client bank unused quota and spend it in one burst later — the requirement is a clean, predictable cap that closes the boundary-burst loophole, not intentional burst tolerance. Given that, the simpler atomicity story (one
INCR, no Lua script) is the better fit for the sub-5ms p99 NFR. If a future requirement specifically wanted burst tolerance — a client that legitimately needs to fire off a quick batch after sitting idle — that's exactly the case where token bucket would earn its extra complexity back.
Final Design
Put every piece from above together and this is the shape it settles into:
Final design
Client
API Gateway
rate-limit check runs inline
Redis
shared counter state
Backend Service
only reached if allowed
Each piece here scales — or doesn't need to — on its own terms:
- API Gateway instances scale horizontally behind a load balancer. None of them hold any rate-limit state themselves, so adding more instances never changes the answer to "is this client over the limit."
- Redis is the one piece every gateway instance shares. See the Follow-Up Questions below for what changes once its throughput — not the traffic it's gating — becomes the bottleneck.
- Backend Service never sees a request that was already going to be rejected, which is the entire point of checking at the gateway instead of after.
Operations & Observability
A rate limiter that only gets exercised in tests isn't finished — you need to know, in production, whether it's protecting the system or quietly breaking it for legitimate clients. Naming this without being asked is a senior/staff-level signal here, same as on every other design in this series.
Watch the rejected-request (429) rate, broken out by identifier and tier first — not just a global count, since a global spike could mean one abusive client or a limit set too strict for everyone, and those call for very different responses. Pair it with the near-threshold-but-allowed rate (the share of requests landing in, say, the last 10% of a client's quota) — a client trending toward that line is an early warning, both for reaching out before they start seeing 429s and for confirming the sliding window's weighting is actually behaving the way Trade-offs above assumed.
Watch Redis check latency (p99) directly, separate from overall API latency — since this check sits on the hot path of every single request, a creeping p99 here degrades every request the gateway handles, rate-limited or not, which is exactly the risk the sub-5ms NFR exists to catch early.
One subtler metric, specific to a sliding window: clock skew across
gateway instances. The window key (rate:{id}:{window}) is only correct
if every gateway instance agrees on which window "now" falls into. If two
instances' clocks drift apart near a window boundary, they can write to two
different window keys for what should be the same moment — silently
splitting one client's count across two counters and letting them through
at up to double the configured limit, without either counter ever looking
wrong on its own.
Level Expectations
Mid-level: implements the fixed-window version correctly, understands why the increment needs to be atomic rather than a read-then-write, and — with a little prompting — can explain why the check runs at the gateway rather than inside each backend server.
Senior: proactively raises the window-boundary burst problem and proposes the sliding window counter without being asked, can weigh sliding window counter against token bucket by naming a real reason a client might prefer one over the other rather than asserting a single "right" answer, and names the rejected-request rate and Redis check latency as what they'd actually watch in production, not just what they'd build.
Staff: reasons about what happens when Redis itself is unavailable — whether the limiter fails open (risk: an outage becomes an abuse window) or fails closed (risk: an outage becomes a full API outage) — and can argue for one over the other given the system's actual risk profile. Additionally reasons about what changes once a single Redis node's throughput, not the traffic being gated, becomes the bottleneck, and can connect clock-skew- driven window-key drift to why it matters more once that scale is reached.
Follow-Up Questions
Interviewer:One client is sending far more traffic than anyone else — does the shared Redis instance become a bottleneck under that specific load?
Not from raw throughput — INCR is O(1) and Redis handles far more ops/sec
than this design's NFRs need. The real risk is that one abusive client's
key gets hit constantly, which serializes on that single key regardless
of how many Redis nodes exist, since sharding by identifier doesn't help
a single identifier. In practice this is self-limiting: once that
client's count crosses the threshold, every subsequent request short-circuits
to a reject without touching the backend at all.
Interviewer:You flagged fail-open vs. fail-closed as a Staff-level question. Which would you actually pick?
Neither in its pure form — fail-open unconditionally turns a Redis outage into an unbounded abuse window; fail-closed turns it into a full API outage for every client, including well-behaved ones. A middle option: fail open, but fall back to a much stricter, coarse, in-process local limit for the duration of the outage — most legitimate traffic stays under it, and an actual abuse burst still gets meaningfully throttled even without Redis.
Interviewer:Two different endpoints need very different limits for the same client. Does the current key scheme handle that?
The key already includes the window, but needs the endpoint added
explicitly — rate:{identifier}:{endpoint}:{window} — otherwise a
client's budget on a cheap endpoint gets consumed by calls to an
expensive one. Worth naming out loud: this multiplies the number of
distinct active keys, which is a real revision to the "10,000 distinct
identifiers" NFR, not a free change.
Interviewer:Capacity Estimation assumed 50,000 checks/sec fits comfortably on one Redis node. What changes if that assumption breaks — say, requirements grow 20x overnight?
The check itself doesn't change, only where each identifier's counter
lives. Shard Redis by a consistent hash of the identifier — not the raw
request — so every check for one client always lands on the same shard,
and that shard alone ever needs to know the true count for that client.
Ten shards, each comfortably handling its share of the load, absorbs a
20x jump without touching the check logic — still one INCR, just
against whichever Redis instance the identifier hashes to. Worth naming
explicitly: this reintroduces a version of the placement problem from
earlier, since the gateway now needs to know how to route to the right
shard. Redis Cluster handles that routing natively, which is why it's
the practical default over hand-rolling consistent hashing at the
gateway layer.
Worth being precise about, once Redis Cluster is actually in the
picture: rate:{identifier}:{endpoint}:{window} above is this lesson's
own placeholder notation, not Redis's syntax. Redis Cluster picks a
key's shard by hashing whatever's inside literal curly braces, so a real
key needs braces around just the identifier — e.g.
rate:{apiKey:abc123}:checkout:2026-09-03T07:40 — otherwise a client's
current- and previous-window counters could land on different shards
instead of the same one.
Try It Yourself
Try it yourself
The design assumes one Redis instance is enough. Sketch what changes if the rate limiter needed to survive a single Redis node failing — specifically, what does "the increment itself is the check" (this lesson's core correctness argument) require from whatever replaces a single node?