2. Rate Limiter Beginner → Advanced+Distributed

A. Realistic Interview Prompt

"Design a rate limiter that restricts how many requests a client can make to an API within a given time window — for example, 100 requests per minute per user."

This problem is a deliberate contrast to URL Shortener: there, the interesting complexity was mostly architectural (caching, sharding, service separation). Here, a huge fraction of the signal comes from a much narrower, more algorithmic question — which counting algorithm, and how do you make counting atomic under concurrency — before the architecture even matters. Candidates who skip straight to "I'll use Redis" without picking and justifying an algorithm are leaving most of the interview on the table.

B. Difficulty

Beginner-friendly entry point (everyone can describe "count requests, reject over the limit") that becomes Advanced + Distributed the moment a second server enters the picture — which happens almost immediately in any real follow-up. Unlike URL Shortener, this problem front-loads a genuine algorithms-and-data-structures decision (five real algorithms, each with a real flaw) before the distributed-systems layer even starts.

C. What This Problem Tests

D. Requirements

Functional

  1. Limit how many requests a client (identified by user ID, IP, or API key) can make within a configurable time window (e.g., 100 requests/minute per user)
  2. When a client exceeds its limit, reject the request with HTTP 429 Too Many Requests and informative metadata (remaining quota, reset time) — not just a bare rejection
  3. Support different limit rules for different clients/endpoints (configurable, not one hardcoded global number)

Non-Functional

Out of scope: authenticating the client's identity itself (assume that already happened upstream — the limiter just consumes a client ID), billing/quota-purchase workflows.

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1Is this a standalone service in front of many backends, or a library embedded per-app?Changes the entire placement discussionDeep Dive 7 (S)
2Per-user, per-IP, or per-API-key? Can rules differ per endpoint?Determines whether "one global counter" is even the right modelCore Entities (H)
3Should the response include remaining-quota metadata?Lets well-behaved clients self-throttle instead of blindly retryingAPI Design (I)
4Is slight overcounting during a node failure acceptable?If yes, avoids expensive strong-consistency coordination entirelyConsistency Model (V)
5Single region or global enforcement from day one?Multi-region rate limiting has no clean canonical answer — worth flagging earlyDeep Dive 6 (R)
6What happens if the rate-limiting infrastructure itself goes down — block everything or allow everything?A real product decision with no universally "correct" answerDeep Dive 4 (P)
7Do legitimate clients need burst tolerance (e.g., batch jobs)?Directly favors token bucket over stricter algorithmsDeep Dive 1 (M)

F. Assumptions

G. Capacity Estimation

A key difference from URL Shortener: there, QPS was derived from user behavior (DAU × clicks/day). Here, the target QPS is really a provisioning target for abuse/burst survival, not a number you derive from "average" traffic — a rate limiter that's sized for average traffic and falls over during the exact burst it exists to defend against has failed at its one job. So the 1M req/sec figure below is stated as a design target (matching Hello Interview's own framing), not derived from DAU × avg-requests the way Deep Dive numbers elsewhere in this document are.

MetricValue / CalculationWhy It Matters
Throughput target1M requests/sec (design target, not DAU-derived)Sets the Redis-sharding requirement in O
Per-check latency budget<10msRules out anything disk-backed; rules out an extra network hop per check if avoidable (O, S)
Single Redis instance throughput~100K–200K ops/sec (well-established Redis benchmark range)1 instance can't hit 1M/sec alone → sharding is mandatory, not optional (O)
Shards needed1,000,000 ÷ ~100K per shard ≈ 10 shardsConcrete shard count to reason about in O
Fixed-window counter memory~44 bytes/client (8B ID + 4B window-timestamp + 2B count + ~30B store overhead) × 10M active clients ≈ 440MBTrivially fits one node — memory is not the bottleneck for this algorithm
Sliding-window-log memory (illustrative, sourced)~120GB for a 500 req/hour limit across 10M users (storing one timestamp entry per request)Shows exactly why sliding-window-log is memory-prohibitive at scale — motivates the hybrid algorithm in M
Sliding-window-counter memory (same scenario)~16GB (a ~92% reduction vs. the log approach)The concrete payoff of choosing the hybrid algorithm over the "obviously correct" log approach

H. Core Entities

EntityPurposeKey Fields
RuleA configured limitrule_id, scope (user/IP/API-key), limit, window_seconds, algorithm
ClientThe entity being limitedclient_id (a user ID, IP, or API key — whatever the rule scopes on)
Counter (ephemeral)The live, per-client-per-rule count statelives entirely in the in-memory store (K) — never a durable row; losing it on restart is harmless

I. API Design

Internal check interface (called by the Gateway on every request):
  isAllowed(client_id, rule_id) -> { allowed: bool, remaining: int, reset_at: timestamp }

HTTP contract exposed to the end client of the protected service:
  200 OK (allowed)
    Headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
  429 Too Many Requests (denied)
    Headers: same as above, plus Retry-After
    Body: { "error": "rate_limit_exceeded", "retry_after": <seconds> }

Why return quota metadata instead of a bare 429: it lets well-behaved clients back off precisely until reset_at instead of blind-retrying (which just adds more load exactly when the system is already stressed) — a product decision as much as a technical one, and worth stating explicitly rather than assuming a bare rejection is enough.

J. Basic / Naive High-Level Design (V1)

The simplest thing that satisfies the functional requirements in isolation: an in-process counter, local to each API server instance.

flowchart LR C(["Client"]) --> LB["Load Balancer"] LB --> S1["API Server A
local in-memory counter"] LB --> S2["API Server B
local in-memory counter"] S1 --> BE["Backend"] S2 --> BE

V1 — each server counts independently. Looks fine with one server; breaks the instant there are two.

Why this breaks immediately, not eventually: with N servers behind a load balancer, a client hitting different servers on different requests gets counted N separate times — effectively an N× larger limit than intended, with zero coordination between servers to catch it. Unlike URL Shortener's V1 (which works fine at small scale and degrades gracefully), this V1 is already broken the moment there's more than one server — which is true of essentially any real deployment. This is exactly why a shared external counter store is not an optimization here, it's a correctness requirement from step one.

K. Data Model & Store Choice

Why Redis (or an equivalent in-memory KV store), not a relational DB: the access pattern is an extremely high-frequency read-and-increment on small, short-lived keys — exactly what an in-memory store with atomic single-command operations and native TTL/expiry is built for. A relational DB would add durability guarantees this data doesn't need (losing a rate-limit counter on restart is harmless — the client just gets a fresh window) and disk-backed latency this problem's <10ms budget can't afford.

Key schema: ratelimit:{rule_id}:{client_id} — using Redis Cluster hash-tag braces ({...}) around the part of the key that must colocate on one shard, which matters specifically for the sliding-window-counter algorithm's two-key design (current + previous window) in M, since both keys for one client must live on the same shard to be read together.

L. End-to-End Flow

sequenceDiagram participant C as Client participant GW as API Gateway participant R as Redis (sharded) participant BE as Backend C->>GW: Request GW->>GW: extract client_id, resolve rule GW->>R: EVALSHA rate_limit_script(client_id, rule) R->>R: atomic read + calculate + write (Lua) R-->>GW: {allowed, remaining, reset_at} alt allowed GW->>BE: forward request BE-->>GW: response GW-->>C: 200 + X-RateLimit-* headers else denied GW-->>C: 429 + Retry-After end

M. Deep Dive 1 — Choosing the Rate-Limiting Algorithm

Requirement this addresses: the functional limiting itself — but the choice of algorithm is where most of the real trade-offs live, and it's worth resolving before any distributed-systems concern.

AlgorithmHow It WorksMemoryBurst BehaviorFatal Flaw
Fixed window counterIncrement a counter for the current time bucket (e.g. "12:01"), reset at each boundaryLowest — one integer per clientUp to 2× the limit at a window boundary (e.g. 100 requests at 12:00:59 + 100 more at 12:01:00)The boundary-burst problem — simple but genuinely exploitable
Sliding window logStore a timestamp per request; count entries within the trailing windowO(n) — one entry per request, even rejected onesNone — exact enforcementMemory cost scales with request volume itself (see G: ~120GB at modest scale) — exact, but expensive
Sliding window counterTwo fixed-window counters (current + previous), blended by a weighted average based on how far into the current window we areTwo small keys per clientSmooths the boundary — no clean 2× exploitApproximate, not exact — acceptable given D's NFR, but worth naming explicitly
Token bucketA bucket holds up to capacity tokens, refilling at a fixed rate; each request consumes one tokenOne hash per client (tokens + last-refill time)Controlled bursts up to capacity — a feature, not a bug, for legitimate bursty clientsTwo parameters to tune (capacity, refill rate) instead of one — more configuration surface
Leaky bucketRequests queue and drain at a fixed rate (policing, not counting)One hash per clientNone — strictly steady output rateOld queued requests can block newer ones during a surge — bad if recency matters more than strict ordering

A real accuracy data point worth citing: Cloudflare's own production measurement of their sliding-window-counter implementation found only 0.003% of requests were wrongly allowed or rejected out of 400 million requests — strong evidence that the "approximate" algorithms are accurate enough in practice to justify their far lower memory cost over the exact-but-expensive sliding-window-log.

Recommendation: token bucket when burst tolerance for legitimate clients matters (the common case — most real APIs have some batch/retry-driven burstiness); sliding-window counter when the priority is smoothing out the fixed-window boundary exploit with minimal memory, and burst tolerance isn't a goal. Both are reasonable defaults — naming the boundary-burst flaw of fixed-window and the memory cost of sliding-log is the real signal, more than which of the remaining two you personally prefer.

N. Deep Dive 2 — Atomicity Under Concurrency

Requirement this addresses: correctness when multiple Gateway instances check the same client's counter concurrently — the single most important correctness property in this entire design.

The race, explicitly: "read the current count," "check against the limit," and "increment" look like one operation but are three. If two Gateway instances both read the count as 99 (limit 100) at nearly the same instant, both may independently decide "allowed" and both increment — the client is now at 101, and the check that was supposed to prevent exactly this just failed. This is the identical shape of bug as two threads racing for the last parking spot in the LLD handbook's Parking Lot problem — a check-then-act race — just expressed here as a distributed counter instead of a shared object.

The fix — an atomic Lua script executed server-side inside Redis, not two separate round-trips from the application. A simplified token-bucket version (adapted from Redis's own published rate-limiter recipes):

-- runs atomically inside Redis; no other client's script can interleave
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local now = redis.call('TIME')[1]              -- server time, not the caller's clock
local tokens, last_refill = tonumber(bucket[1]) or capacity, tonumber(bucket[2]) or now
local refills = math.floor((now - last_refill) / refill_interval)
if refills > 0 then
  tokens = math.min(capacity, tokens + refills * refill_rate)
  last_refill = last_refill + refills * refill_interval
end
local allowed = 0
if tokens >= 1 then tokens = tokens - 1; allowed = 1 end
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
return {allowed, tokens}

Why Lua, not MULTI/EXEC: Redis transactions (MULTI/EXEC) queue a fixed batch of commands and run them atomically, but they can't branch on a value read earlier in the same transaction — you can't say "read the count, and only increment if it's below the limit" inside one MULTI block. A Lua script runs as a single atomic unit but is a real program: it can read, compute, and conditionally decide to write, all inside that one atomic step. That conditional-branch-on-a-just-read-value is exactly what this problem needs and MULTI/EXEC structurally cannot provide.

Why the script calls Redis's own TIME command instead of the caller passing a timestamp: if each Gateway instance used its own local clock, clock drift between instances could let a client be double-counted or under-counted right at a window boundary. Computing time inside Redis, on one clock, removes an entire class of subtle multi-node bugs for free.

O. Deep Dive 3 — Scaling to 1M Requests/Sec

Requirement this addresses: Throughput (D, G) — a single Redis instance's ~100K–200K ops/sec ceiling is well below the 1M/sec target.

Sharding via consistent hashing on client_id: ~10 Redis shards, each handling ~100K ops/sec, clear the 1M/sec target with room to spare. Consistent hashing (not simple modulo) is chosen specifically because it's the standard, well-understood way to add/remove shards later while only remapping a small fraction of keys — the same rationale used anywhere shards need to grow without a full rehash. Critically, a given client's key must always land on the same shard — you cannot split one client's counter across two shards and still check it atomically, so this isn't just a load-balancing choice, it's a correctness requirement (same principle as the hash-tag braces in K).

flowchart TB GW["API Gateway"] -- "hash(client_id)" --> CH{"Consistent Hash Ring"} CH --> R1[("Redis Shard 1")] CH --> R2[("Redis Shard 2")] CH --> R3[("Redis Shard 3")] CH -.-> R10[("... Redis Shard 10")]

A client's key always resolves to the same shard — required for the atomic check in N to mean anything.

Connection pooling: a fresh TCP handshake per check can cost ~20–50ms — several times the entire <10ms per-check latency budget from D. Pooled, persistent connections between each Gateway instance and its Redis shards are not an optimization here, they're required to meet the stated latency NFR at all.

P. Deep Dive 4 — High Availability & the Fail-Open/Fail-Closed Decision

Requirement this addresses: Availability (D) — specifically, what happens when the rate-limiting store itself is unreachable.

Fail-Open (allow all traffic)Fail-Closed (reject all traffic) — chosen
Backend safetyBackend is fully exposed to unlimited traffic exactly when the limiter can't check itBackend stays protected regardless of the limiter's own health
API availabilityThe protected API stays up for legitimate users during the outageThe protected API becomes fully unavailable during the outage
When it's worstAn infra outage often correlates with a traffic spike (e.g. everyone hammering "refresh" during an incident) — the exact moment fail-open removes the one thing preventing backend collapseA rare Redis outage now takes down an otherwise-healthy backend too

Why fail-closed: the rate limiter's entire purpose is backend protection; choosing fail-open specifically fails at that purpose during the one event (infrastructure stress) most likely to coincide with the traffic spikes it exists to defend against. This is a defensible, opinionated choice — a candidate arguing fail-open with a clear justification (e.g., "this API's uptime SLA is more important than a rare overload") is equally strong, as long as the trade-off is named, not assumed away.

Making the fail-closed path rare in practice: Redis Cluster or Sentinel gives each shard a master-replica pair with automatic failover, so a single node failure — the common case — never actually reaches the fail-closed branch at all. Fail-closed is the safety net for the rare case where HA itself fails, not the primary defense.

Q. Deep Dive 5 — The Hot-Key / Single Abusive Client Problem

Requirement this addresses: a distinct scaling problem from O — O assumes well-distributed aggregate traffic; this addresses one concentrated source (an abusive client, or a DDoS from one IP range) overwhelming its single shard.

Why sharding (O) can't fix this on its own: consistent hashing on client_id is exactly what makes the atomic check correct (O) — but it also means one client's traffic can never be spread across multiple shards without breaking that same correctness guarantee. A hot key is hot by definition on exactly one shard.

R. Deep Dive 6 — Multi-Region / Global Rate Limiting

This is genuinely the most open-ended part of the problem — there is no single canonical answer across sources, and naming that honestly, then reasoning through the real options, is stronger than picking one and pretending it's obviously correct.

ApproachHowTrade-off
Static per-region quota splitGlobal limit ÷ number of regions, enforced locally per regionSimplest, but penalizes a client whose traffic happens to concentrate in one region
Home-region pinning (recommended default)Consistent-hash the client to one "home" region; that region's local counter is authoritative for that clientSame principle as shard pinning in O, just one level up — avoids cross-region coordination entirely for the common case
Local counters + async cross-region syncEach region counts locally, publishes deltas to others periodicallyEventual consistency (~1–2s convergence); a client hitting multiple regions simultaneously can briefly exceed the global limit by roughly the number of regions — acceptable under D's "approximate is fine" NFR
CRDT-based countersCoordination-free, mathematically mergeable counters across regionsElegant but adds real operational complexity most interviews don't need to go this deep on
flowchart LR C(["Client"]) --> Router{"Home-Region Router
consistent hash"} Router -- "pinned" --> RegionA["Region: us-east
local Redis shards"] Router -.-> RegionB["Region: eu-west
local Redis shards"] RegionA -. "periodic async sync (fallback path)" .-> RegionB

Recommendation: home-region pinning as the default (it's the same sharding idea already used within a region, just applied globally); fall back to async cross-region sync only for the genuinely hard case of a single client's traffic legitimately spanning regions (e.g. a mobile user roaming) where a single "home" doesn't make sense.

S. Deep Dive 7 — Where to Enforce the Limit

Requirement this addresses: an architectural placement decision, independent of the algorithm (M) or the store (K) — revisits J's rejected in-process approach with the fuller comparison.

PlacementLatencyCross-Instance ViewVerdict
In-app / in-processFastest — zero network hopNone (J's fatal flaw)Usable only as a cheap local pre-filter layered in front of the real check — never as the sole enforcement point
Dedicated sidecar (e.g. Envoy-style, one per service instance)Adds a local proxy hopShared, via a common backing storeDecouples limiting logic from application code (polyglot-friendly) at the cost of one more moving part to operate per instance
API Gateway (chosen)Zero extra hop — the Gateway is already on every request's pathShared, via sharded Redis (O)Centralizes rule configuration in one place; blocks bad traffic before it burns any backend compute at all

The trade-off worth naming for the Gateway choice: the Gateway itself must now scale to the full 1M req/sec target (it already had to handle this traffic anyway, so this is not new load, but it is new responsibility), and a Gateway-level bug or outage has a wider blast radius — it affects every downstream service uniformly, unlike a per-service sidecar failure which is contained to one service.

T. Full Solution at Scale — Final Architecture

flowchart TB C(["Client"]) --> Edge["CDN / Edge
coarse pre-filter (Q)"] Edge --> GW["API Gateway
rule config + fail-closed policy (S, P)"] GW -- "hash(client_id)" --> CH{"Consistent Hash Ring (O)"} subgraph REDIS [" Sharded Redis — atomic Lua check (M, N) "] direction LR R1[("Shard 1")] R2[("Shard 2")] R3[("... Shard 10")] end CH --> REDIS GW -- "allowed" --> BE["Backend Services"] GW -. "denied: 429 + Retry-After" .-> C BE -. "200 + X-RateLimit-* headers" .-> C GW -.-> Region2["Home-region pinning
for multi-region (R)"]

Everything above is the composition of M through S — no new component introduced here.

U. Design Evolution

What stays stable throughout: the isAllowed(client_id, rule_id) check contract and the 429+headers response shape from I — every version below the surface changes without callers noticing.

V. Consistency Model

Almost everything here is deliberately eventually/approximately consistent by design (D's NFR) — a client occasionally slipping through at 101/100 during a failover is an accepted, bounded inaccuracy, not a bug, because the system's job is coarse protection, not exact metering. The one place near-strong consistency is non-negotiable is within a single shard's Lua-script execution (N) — that step must be atomic or the entire limiter is pointless even at the smallest scale. This is the inverse emphasis of URL Shortener's model (strong uniqueness, eventual everything else) — here almost everything is eventual except one narrow, single-shard atomic operation.

W. Failure Scenarios

X. Bottlenecks & Scaling Boundary

StageActual BottleneckFix Applied
V1 in-processNo cross-instance view at all — broken with >1 serverShared Redis store (K)
Single shared Redis~100–200K ops/sec ceiling per instanceConsistent-hash sharding (O)
Sharded RedisOne client's traffic concentrated on one shardEdge pre-filter + blocklisting (Q)
Single region at full shard capacityCross-region coordination costHome-region pinning (R)

Y. Design Trade-Offs

DecisionOption AOption BTrade-off
AlgorithmFixed window (simplest)Token bucket / sliding-window counter (chosen)Fixed window is trivial but has a real 2× boundary exploit; the alternatives cost a bit more complexity to remove it
ExactnessSliding window log (exact)Sliding window counter (approximate, chosen)Log is exact but memory cost scales with request volume (120GB-class); counter is ~92% cheaper at negligible (0.003%-class) error
Infra failure policyFail-openFail-closed (chosen)Fail-open keeps the API up but exposes the backend during the exact event it needs protection most; fail-closed protects the backend at the cost of full unavailability during the (rare, HA-mitigated) outage
PlacementSidecar per serviceAPI Gateway (chosen)Sidecar contains blast radius per service; Gateway is zero extra hops but centralizes risk
Multi-regionStatic quota splitHome-region pinning (chosen)Static split is simpler but penalizes region-concentrated clients; pinning avoids cross-region coordination for the common case

Z. Common Bad Designs

AA. Interviewer Follow-Ups

Follow-UpDesign ChangeComponents ChangedComponents Unchanged
"Legitimate clients need to burst occasionally."Token bucket's capacity parameter directly models allowed burst size — no new mechanism neededRule configuration onlyEverything else
"Some API calls cost 10× more than others — weight them."Consume N tokens instead of 1 per request; token bucket generalizes to this triviallyLua script's decrement logicSharding, placement, HA policy
"A client swears they didn't exceed the limit but got a 429."Debug via the response headers — was this a failover-induced brief miscount, or the fail-closed path firing during a Redis blipNone — this is why I/N's observability existsEverything
"Now add per-tenant AND per-user limits simultaneously."Two rule checks per request; check whichever is cheaper/likelier to reject first, to fail fastRule evaluation logicStore, sharding, algorithm choice
"We need real-time analytics on who's being rate-limited."Same lesson as URL Shortener's analytics deep dive — async event pipeline, never on the hot check pathNew async pipeline (Kafka → aggregator → analytics store)The check path itself, entirely

AB. Interviewer Evaluation Rubric

AC. Interview Simulation

Interviewer: Design a rate limiter — 100 requests per minute per user.
Candidate: Before the architecture, I want to pick the counting algorithm, since that drives everything else. A naive fixed-window counter lets a client send 100 requests at the very end of one window and another 100 right at the start of the next — effectively 200 in a few seconds. I'd use a token bucket instead, or a sliding-window counter if burst tolerance isn't actually wanted.
Interviewer: Fine, token bucket. Where does the count live?
Candidate: Redis — it needs to be shared across every API server instance, or a client can just get counted separately per server and blow past the limit. And critically, the check-and-decrement has to be one atomic operation, not a separate read then write, or two concurrent requests can both read "99" and both get allowed.
Interviewer: How do you make that atomic?
Candidate: A Lua script executed server-side inside Redis. Redis's MULTI/EXEC can't branch on a value it just read in the same transaction, but a Lua script can — read the bucket, compute refill, conditionally decrement, write back, all as one atomic step.
Interviewer: Now scale it to a million requests a second.
Candidate: One Redis instance tops out around 100-200K ops/sec, so I'd shard across roughly 10 instances using consistent hashing on the client ID — and it has to be consistent hashing on client ID specifically, because a client's counter can't be split across two shards and still be checked atomically.
Interviewer: What if Redis itself goes down?
Candidate: That's a real product decision — fail open and risk the exact backend overload this component exists to prevent, or fail closed and take the API down until Redis recovers. I'd default to fail-closed, since an infra outage often coincides with a traffic spike — the worst possible time to remove protection — and then invest in Redis Cluster/Sentinel failover so that path is rarely actually hit.
Interviewer: Last one — global deployment, multiple regions?
Candidate: Honestly, there's no clean single answer here. My default would be pinning each client to one home region via the same consistent-hashing idea, so most clients never need cross-region coordination at all. For a client that genuinely spans regions — a roaming mobile user — I'd fall back to local counters with periodic async sync, and accept that they could briefly exceed the global limit by a small, bounded amount during the sync window.

AD. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
Hello InterviewAPI-Gateway placement, Redis-backed counter, explicit fail-open/closed framing, staff-level multi-region discussionBest framing of placement as its own decision, separate from algorithmDoesn't compare all five algorithms as exhaustively as ByteByteGoThe explicit fail-open vs. fail-closed decision framing used throughout this section
ByteByteGoFull five-algorithm comparison with named fatal flaws; real Cloudflare accuracy data pointThe most rigorous algorithm-trade-off treatment of any source reviewedLighter on the multi-region and hot-key anglesThe 0.003%-accuracy Cloudflare data point and the "fatal flaw per algorithm" framing adopted in M
Redis official docsActual, runnable Lua scripts for every algorithm (token bucket, fixed window, sliding log, sliding counter, leaky bucket), using server-side TIME for clock safetyThe only source with real, correct, atomic implementation code rather than just conceptual descriptionNo system-design framing — purely an implementation referenceThe exact Lua-script structure and the "why Lua not MULTI/EXEC" reasoning used in N
gitgik/distributed-system-design (GitHub)Concrete memory-sizing math for sliding-window-log vs. sliding-window-counter at a stated user/request scaleThe source of the ~120GB vs. ~16GB memory comparison used in GLess coverage of the availability/fail-open trade-offSeeing algorithm choice translated into an actual memory bill, not just Big-O

Additional cross-referenced repos (search-level only): Salah856/System-Design, sm2774us/System_Design, anshme/rate-limiter (a working implementation of Alex Xu's design), Kong's rate-limiting plugin docs (a real production API Gateway's own configuration model).

AE. Interview Cheat Sheet

ProblemRate Limiter
DifficultyBeginner → Advanced+Distributed
Core requirementsPer-client configurable limits; 429+metadata on rejection; 1M req/sec target, <10ms overhead, approximate correctness OK
Algorithm decisionToken bucket (burst-tolerant) or sliding-window counter (boundary-smoothing) over fixed-window (2× exploit) or sliding-log (memory-expensive)
Atomicity fixServer-side Lua script (not MULTI/EXEC) for atomic read-calculate-write; server-side TIME to avoid clock drift
Scale fixConsistent-hash sharding on client_id across ~10 Redis shards + connection pooling
Availability decisionFail-closed (protect backend) + Redis HA/failover to make that path rare — a defensible, opinionated call
Placement decisionAPI Gateway (zero extra hop, centralized config) over in-app (no cross-instance view) or sidecar (per-service hop)
Hardest open-ended follow-upMulti-region — no canonical answer; home-region pinning as default, async sync as fallback
Common mistakesIn-process-only counters; unnamed fixed-window boundary flaw; two-call check-then-increment race; unexamined fail-open
What NOT to over-engineerCRDT counters, full distributed consensus for the counter itself — the NFR explicitly allows approximate correctness