Design a Distributed Rate Limiter
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | Protect one public API from any one user hammering it | A multi-tenant gateway for every API in the company | Global limits, billing quotas and real attackers |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Traffic | ~10K requests/s peak | ~167K requests/s peak per region | ~500K requests/s peak across 3 regions |
| Tracked keys | ~1M users | ~50M buckets (IPs, users, tenants × routes) | ~50M per region |
| Survives | Losing the cache primary | Losing a cache node or a whole AZ | Losing a region |
| Targets | Limiter adds < 5 ms; its failure never takes the API down | Limiter P99 < 2 ms; 99.99% | 99.999% for the gateway; quotas exact enough to bill |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up.
Loop Opener: What Is a Rate Limiter?
You Already Know One: the Bouncer With a Clicker
A club has room for so many people. The bouncer at the door holds a clicker and lets in, say, 100 people an hour. Person 101 waits outside and is told when to come back. The bouncer isn't there to be unkind to person 101. He's there so that the people already inside have a good night, and so the club doesn't break its fire rules.
A rate limiter does the same job for an API: it counts the requests each client makes in a period of time, and it refuses the ones over the limit. APIs need this for three reasons:
| Without a limiter | What goes wrong |
|---|---|
| One client sends far more than everyone else | Everyone else gets slow answers or errors. This is the noisy neighbor problem. |
| A client runs a script that loops forever | Our servers, databases and cloud bill grow for no business value. |
| An attacker tries a million passwords | Our login endpoint becomes a guessing machine. |
The answer to a refused request is the HTTP status 429 Too Many Requests, usually with a Retry-After header that says how many seconds to wait.
What Changes When the Limiter Is Distributed
One bouncer at one door is easy. Now give the club 50 doors, each with its own bouncer, and a guest who can walk in through any of them. If each bouncer keeps his own clicker, the guest who uses all 50 doors gets in 50 times as often as the rule allows.
That's exactly our situation. An API runs on many gateway servers behind a load balancer, and a client's requests land on all of them. So two questions drive the whole loop:
- Who holds the clicker? The count must be shared, or it isn't a limit.
- What if the clicker breaks? The limiter sits in front of every request. If it is slow, the whole API is slow. If it is down and we wait for it, the whole API is down.
The Question the Whole Loop Answers
How do we count fairly across many machines, on every request, without adding latency or becoming the thing that causes the outage?
The answer gets sharper every round:
- Round 1: one shared counter in a fast store, updated atomically, and a plan for when it fails.
- Round 2: many counters per request, live rules, hot counters kept local, and layers from the edge to the service.
- Round 3: limits that span regions, quotas that bill, attackers who spread out, and backends that need protection from everyone at once.
Round 1 · Mid-level · "Protect One Public API"
~35 min · SDE II (L5) · 1 region, 3 AZs · ~10K requests/s peak · ~1M users · limiter adds < 5 ms
R1.1 Establish Design Scope
The interviewer says: "Our public API keeps getting hammered by a few users. Design a rate limiter for it." Before we draw anything, we ask questions, and we say out loud what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Limit by what: user, API key, IP address? | By user. Every request carries a logged-in user ID. IP limits can wait. | The key we count against is the user ID. We don't have to reason about shared IPs yet. |
| What's the limit? | 100 requests per minute per user on the API. The login endpoint: 5 attempts per minute per account. | Two limits with different purposes: one protects capacity, one protects security. That difference matters in step 1.4. |
| How exact must it be? | A small overshoot is fine. A user getting 105 in a minute is not an incident. 200 is. | We can pick an algorithm that is approximate but cheap. We need a shared count, but not a perfect one. |
| Are bursts allowed? | Yes, briefly. A page load fires 10 to 15 calls at once. | A fixed "one request every 0.6 seconds" pace would break real clients. We need an algorithm that allows short bursts. |
| What does a throttled client see? | A clear error that says when to try again. | 429 with Retry-After, plus headers that show the remaining budget (R1.4). |
| Where does the check run? | In our API gateway layer, before requests reach the backend. | The limiter runs inside the gateway servers, on every request. Its latency is added to every call. |
| How many users, how much traffic? | About 1M users; about 10,000 requests per second at peak. | Small numbers. One cache node will be plenty (R1.7); the hard part is correctness and failure. |
| What if the limiter itself is down? | The API must stay up. | The limiter can never be the reason the API fails. Step 1.4 decides what "stay up" means for each limit. |
Out of scope for this round:
- Tenants and pricing tiers (a free customer and a paying customer with different limits).
- Different costs per route (an export that costs more than a read).
- Changing limits while the system runs, without a deploy.
- More than one region.
The interviewer will widen this scope later. Write your out-of-scope list where you can see it: in a multi-round loop, all of it comes back.
R1.2 Functional Requirements, Derived Step by Step
We read the problem one phrase at a time and turn each phrase into a requirement:
| Phrase from the problem | Requirement |
|---|---|
| "Stop a user hammering us" | Every request is checked against the user's limit before it reaches the backend |
| "100 per minute, bursts are fine" | The check allows short bursts but holds the user to about 100 requests per minute over time |
| "The login endpoint, 5 per minute" | A second limit, on login attempts per account |
| "Tell them when to come back" | A refused request gets 429 Too Many Requests and a Retry-After header |
| "Show clients their budget" | Every response says how much budget is left |
Not yet: different limits per customer or plan, weighted routes, and limit changes without a deploy. The interviewer may bring these back.
R1.3 Non-Functional Requirements: the Questions
Numbers come in R1.7. For now, the questions and why each matters:
- Latency. The check runs on every request, before any real work. Whatever it costs is added to every call the API serves. Our budget is under 5 ms added at P99, and we want about 1 ms typically.
- Availability. The limiter failing must not fail the API. A limiter that turns a cache hiccup into a total outage has done more damage than any abusive user.
- Accuracy. A small overshoot is fine. Two times the limit is not.
- Memory. A constant amount per key, whatever the user's request rate. An algorithm whose memory grows with traffic gets more expensive exactly when we are under attack.
R1.4 The API
There is no public endpoint for the limiter. It is an internal check that the gateway calls on every request. Written as a call:
json{ "call": "check", "key": "user:u_1001", "rule": "api-per-user", "cost": 1 }
json{ "allowed": true, "remaining": 17, "retry_after_s": 0, "reset_after_s": 2 }
remaining is the budget left after this request. retry_after_s is how long to wait before a request of this cost can succeed (0 when allowed). reset_after_s is how long until the budget is completely full again.
What the client sees is the HTTP response. A refused request:
httpHTTP/1.1 429 Too Many Requests Content-Type: application/problem+json Retry-After: 1 X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 12 { "type": "https://api.example.com/errors/rate-limited", "title": "Too many requests", "detail": "User u_1001 is over 100 requests per minute. Retry after 1 second.", "retry_after_seconds": 1 }
Which of these headers are standards, and which are habits?
| Header or status | Status | Notes |
|---|---|---|
429 Too Many Requests | Standard (RFC 6585) | The right status for "you sent too many". Not 503, which means "we are overloaded" (Round 3 uses that one). |
Retry-After | Standard (RFC 9110) | Seconds to wait, or an HTTP date. Well-behaved clients and many HTTP libraries honor it. |
X-RateLimit-Limit, -Remaining, -Reset | A common convention, not a standard | Widely used, but APIs disagree on details. Some send -Reset as seconds from now, others (GitHub, for example) as a Unix timestamp. We send seconds from now, and we write that in our API docs. |
RateLimit-Policy and RateLimit | An IETF Internet-Draft, not yet an RFC | The HTTP working group's draft (version 11 at the time of writing) defines these two fields, for example RateLimit-Policy: "per-user";q=100;w=60 and RateLimit: "per-user";r=17;t=2. Fine to send alongside the X- headers; don't promise clients it is a finished standard. |
Retry-After on a 200 response means nothing, so we only send it on 429. The X-RateLimit-* headers go on every response, so a well-behaved client can slow down before it hits the limit.
Recap
- One internal check per request:
check(key, cost) → allowed, remaining, retry_after. - Limits: 100 per minute per user on the API, 5 per minute per account on login, short bursts allowed.
- About 1M users and 10,000 requests per second at peak; small overshoot fine.
- Under 5 ms added; the limiter failing must never take the API down.
- Refusals are
429withRetry-After.
Let's build it, starting with the simplest thing that works.
R1.5 Design Evolution: From a Local Counter to a Shared, Atomic Limiter
Every step below follows the same pattern: a problem, your turn to think, the answer, and what the answer costs us. The cost is always the next problem.
Step 1.0: The Baseline
Each gateway server counts in its own memory: a map from user ID to "requests this minute". No network, no dependency, a few nanoseconds per check.
Synthesizing vector architecture diagram...
What's good about it: it is as fast as a limiter can be, and it can't fail on its own. For one server, it is the right answer.
What it costs us: we have six gateway servers, and the load balancer spreads each user's requests across all of them. Each server lets the user have 100 per minute, so the user gets up to 6 × 100 = 600 per minute. With N servers, a user gets up to N times the limit, and N grows every time we scale out.
Step 1.1: A User Got Six Times Their Limit
The problem: a scraper made 580 requests in one minute against a limit of 100. Each of our six gateways counted about 97 and let every request through. What would you do? How do all the gateways agree on one count?
Primitive: Distributed Cache Patterns & Eviction
Step 1.2: Which Algorithm?
The problem: we need to store something per user that answers "is this user over 100 per minute?". It must allow page-load bursts of 10 to 15 requests, take constant memory per user, and not let anyone get double the limit. What would you do? What do we store, and how do we decide?
The refill, in pseudocode. We store the tokens left and the time we last updated them. We don't refill on a timer; we compute the refill when the next request arrives:
textcheck(key, cost, capacity, rate, ttl): now = current time, in seconds with microseconds state = read(key) # { tokens, ts } or nothing if state is nothing: tokens = capacity # a new bucket starts full else: elapsed = max(0, now - state.ts) # never negative tokens = min(capacity, state.tokens + elapsed * rate) if tokens >= cost: tokens = tokens - cost allowed = true retry_after = 0 else: allowed = false retry_after = ceil((cost - tokens) / rate) # seconds until enough tokens write(key, { tokens, ts: now }, expire after ttl) return allowed, floor(tokens), retry_after
The numbers, proved. With capacity and rate tokens per second:
- A page load of 15 calls passes at once: 15 ≤ 20.
- An empty bucket is full again after seconds.
- The most a user can send in any 60-second window is a full bucket plus a minute of refill: , which is 1.2× the per-minute figure. That ceiling is set by the capacity. Capacity 100 would allow 200 in a minute, the same 2× as the fixed window. The capacity is how much overshoot we allow, so we keep it small.
- A request that finds 0.4 tokens waits s, sent as
Retry-After: 1(whole seconds, rounded up).
The trade-offs in one table:
| Algorithm | Bursts | Memory per key | Accuracy | Complexity |
|---|---|---|---|---|
| Fixed window counter | Up to 2× at a window edge | One counter | Low at edges | Very low |
| Sliding window log | Exact | One entry per request in the window | Exact | High |
| Sliding window counter | Smoothed | Two counters | Approximate (assumes even spread) | Moderate |
| Token bucket | Up to the capacity, on purpose | Two numbers | Good; ceiling is capacity + rate × window | Low |
| Leaky bucket (queue) | None; output is smoothed | A queue, up to its length | Exact output rate | Moderate |
Go deeper: the GCRA (generic cell rate algorithm) is a token bucket written with a single number per key: the "theoretical arrival time" of the next allowed request. A request is allowed if it arrives no earlier than that time minus the burst allowance. It behaves like a token bucket and saves one field, at the cost of being harder to explain in an interview.
Primitive: Distributed Rate Limiting
Step 1.3: Ten Requests All Saw One Token Left
The problem: a user's bucket has 1 token left. Ten requests from that user arrive at ten different gateways in the same millisecond. Each gateway reads the bucket, sees 1 token, decides "allowed", and writes back 0. All ten get through. What would you do?
Is TIME inside a script safe? It used to be a problem, and the reason is worth knowing. Redis once replicated scripts verbatim: it sent the script text to replicas, which ran it again. A script that reads the clock would then compute a different answer on the replica. So Redis refused a write after TIME in a script unless the script switched to effects replication (replicating the resulting writes instead of the script), which Redis 3.2 made possible with redis.replicate_commands(). Redis 5.0 made effects replication the default, and Redis 7.0 removed verbatim replication entirely. On the engines we'd choose (Redis OSS 7.x or Valkey 7.2+; effects replication has been the default since Redis 5.0 anyway), a script can read TIME and write: the replica receives "set tokens to 17.35, ts to …", never the script.
One subtlety remains. After a failover (step 1.4), the new primary's clock may differ from the old one's by a small amount δ. If it is behind, elapsed is clamped to 0 and the bucket just refills slightly late. If it is ahead, each bucket gains at most δ × rate extra tokens, once. With node clocks within a few milliseconds of each other (an assumption: we don't manage ElastiCache's clocks), that's a small fraction of a token.
Go deeper: the atomic-command alternative. The fixed window needs no script: INCR the counter and set its expiry, and compare the returned count. INCR is itself atomic. That's why fixed windows are popular: they need only atomic commands. The token bucket reads and writes two fields with arithmetic in between, which is why it needs a script.
Step 1.4: The Cache Is Down. Is the Whole API Down?
The problem: the cache's primary node fails. For the next several seconds, every check times out. The gateways were written to wait for an answer, so every API request waits too, and then errors. What would you do? What should a gateway do when it can't reach the limiter's store?
The fallback's arithmetic. A local limit of per gateway (rounded up) keeps the fleet-wide total near but can refuse an honest user early if their requests happen to hit the same gateway. For login, with and , one real user who mistypes twice on the same gateway is refused on the second try until the minute passes. For a security limit, during a cache outage, we accept that.
Primitive: Circuit Breaker, Bulkhead & Fault Tolerance · Drill: Circuit breaker cascading thread stall
Step 1.5: Throttled Clients Retry Instantly
The problem: a client library gets a 429, retries immediately, gets another 429, and retries again, hundreds of times a second. Each retry is refused, but each refusal still costs us a check.
What would you do?
Step 1.6: Memory Keeps Growing
The problem: a week after launch, the cache holds a bucket for every user who has made a single request, including millions who called us once and never came back. Memory climbs every day. What would you do?
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | A count in each gateway's memory | A user gets N× the limit with N gateways |
| 1.1 | Counts not shared | A shared store: ElastiCache | A network hop per request; a new dependency |
| 1.2 | Which algorithm | Token bucket, capacity 20, 100 per minute | Two parameters per limit; a race |
| 1.3 | Check-then-act race | One atomic script in the store, using the store's clock | More work per call in the store |
| 1.4 | Store down | 3 ms timeout, circuit breaker; fail open for capacity limits, local fallback for login | Seconds of loose enforcement; alarms needed |
| 1.5 | Instant retries | Retry-After from the refill; backoff with full jitter | Relies on clients |
| 1.6 | Memory grows | TTL ≥ capacity ÷ rate (60 s) | A rule to check per limit |
R1.6 Architecture v1
Now the concepts get AWS names.
Synthesizing vector architecture diagram...
Follow a request down the left: the load balancer picks a gateway, the gateway makes one call to the cache primary, and only an allowed request continues to the backend. Every gateway in every AZ talks to the same primary, which is what makes the count shared. The dotted arrow is replication to the replica in another AZ, which takes over if the primary fails.
The pieces:
- Gateways: six servers, two per AZ, behind an Application Load Balancer. The limiter is a filter inside the gateway process (or a sidecar on the same host), so the only network hop it adds is to the cache.
- Cache: ElastiCache with cluster mode off: one primary and one replica in a different AZ, with Multi-AZ automatic failover. Private subnets; only the gateways' security group can reach it; clients authenticate to it (ElastiCache supports role-based access control users and IAM authentication) and talk TLS.
- Stored state per key, one small hash per bucket:
| Key | Field | Type | Example | Meaning |
|---|---|---|---|---|
rl:user:u_1001:api | tokens | float | 17.35 | Tokens left after the last update |
ts | float, seconds | 1790424000.123456 | When tokens was last computed, by the store's clock | |
| (expiry) | 60 s | Set on every write | ||
rl:acct:alice@example.com:login | tokens, ts | The login bucket: capacity 5, 5 per minute |
Trace: the three outcomes of a check.
Synthesizing vector architecture diagram...
The third branch is the one that matters most. A timeout adds at most 3 ms and then gets out of the way. Once the circuit breaker opens, later requests skip the call entirely until a probe succeeds. For the login limit, the third branch uses the local fallback instead of forwarding blindly.
R1.7 Numbers
Checks per second. One check per request: 10,000 per second at peak. We assume average traffic is about a third of peak, ~3,300 per second, the same ratio the other loops use.
Memory per key. Our estimate for one bucket, stored as a small hash:
| Part | Bytes (estimate) |
|---|---|
Key name, e.g. rl:user:u_1001:api (up to ~32 characters) | ~32 |
Two fields and their values (tokens, ts) | ~16 |
| Redis's own bookkeeping for the key, the hash and the expiry | ~48 |
| Total | ~96 |
That assumes all 1M users were active in the same minute, which is a very safe upper bound: with a 60 s TTL, only users active in the last minute have a key.
Treat the 96 bytes as an estimate, not a fact. Real per-key overhead depends on the engine version, the encoding Redis picks for small hashes, the memory allocator's size classes and the expiry bookkeeping, and it can easily be 1.5 to 2 times higher. Load a million test keys and measure with MEMORY USAGE on a sample and INFO memory in total. Here, even 200 bytes per key is only 200 MB.
Store operations against capacity. One script call per check: 10,000 calls per second at peak. We plan on about 60,000 script calls per second per Redis node for a script this small. That is an assumption to load-test, not a published figure: throughput depends on the script, the node type, TLS and the client. At our assumption, peak uses of one node.
Node choice. Memory isn't the constraint (96 MB), and neither is throughput. We want steady CPU (not a burstable t-class node that runs out of CPU credits under sustained load) at a low price: cache.m7g.large (2 vCPUs, 6.38 GiB). ElastiCache reserves 25% of memory by default (reserved-memory-percent) for background work like snapshots and replication, leaving about GiB for data: 50 times what we need. On a 2-vCPU node, the host's own background processes share the CPU with the engine, so we watch both EngineCPUUtilization and CPUUtilization.
One replica, for failover, not for reads. Every check writes, so every check goes to the primary; the replica exists only to take over if the primary fails.
Latency. A round trip inside one region is typically well under a millisecond in the same AZ and around a millisecond across AZs (an assumption to measure in our region). The script itself runs in microseconds. So a typical check costs about 1 ms, and the worst case before the breaker opens is the 3 ms timeout. Both are inside the 5 ms budget.
Monthly cost (us-east-1 on-demand prices, 730 hours a month; ElastiCache for Valkey nodes are priced 20% lower than these Redis OSS prices):
| Item | Math | Monthly |
|---|---|---|
2 × cache.m7g.large (primary + replica) | 2 × $0.158/h × 730 h | ≈ $231 |
| Cross-AZ traffic, gateways to the primary | ~200 B per call and reply × 3,300 calls/s ≈ 1.75 TB/month; 4 of 6 gateways are in other AZs, so ~1.17 TB × $0.02/GB ($0.01 each way) | ≈ $23 |
| CloudWatch metrics and alarms | a few custom metrics and alarms | ≈ $10 |
| Total | ≈ $265/month |
We don't count the gateways or the load balancer: the API needs them anyway, for routing and authentication. The limiter adds a cache and a filter.
R1.8 Trade-Offs
Local vs shared counting
| Choice | Accuracy | Latency | Failure behavior |
|---|---|---|---|
| Local only (step 1.0) | Up to N× the limit | None | Can't fail on its own |
| Shared store (our choice) | One count per user | One round trip | The store is a dependency; needs step 1.4 |
| Local with a shared store behind it | Bounded overshoot | Mostly none | Round 2, step 2.3 |
Fail open vs fail closed
| Choice | Good for | Dangerous for |
|---|---|---|
| Fail open | Capacity and fairness limits: loose limits for a few seconds hurt nobody | Security limits: an attacker gets unlimited tries while the store is down |
| Fail closed | Almost nothing on a public API | Everything: turns a cache problem into a full outage |
| Local fallback limit | Security limits during an outage | Adds a second limiter to keep correct |
The algorithm trade-offs are in the table in step 1.2.
R1.9 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| The cache primary fails | Timeouts from every gateway; breakers open; fail-open and fallback counters jump | ElastiCache promotes the replica. AWS documents that writes can resume after promotion within typically a few seconds; adding client reconnection and DNS updates, we plan for up to about 30 seconds of degraded enforcement and measure it with a test failover. During that time, API limits fail open and login limits use the local fallback. |
| The cache is slow, not down | Limiter latency rises toward 3 ms; some timeouts | The timeout caps the added latency at 3 ms. If more than 20% of calls fail in a second, the breaker opens and requests skip the cache until a probe succeeds. We alarm on the limiter's own latency before it gets there. |
| The gateway fleet scales from 6 to 12 | Nothing, from the limiter's point of view | The count lives in the cache, so adding gateways doesn't change anyone's limit. (With step 1.0's local counts, it would have doubled every limit.) The local fallback's share per gateway, , is recomputed from the fleet size. |
| A gateway's clock is wrong | Nothing | The script uses the cache's clock, not the gateway's (step 1.3). |
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | The limiter itself is throttling that protects the backend; 3 ms timeouts and a circuit breaker so the cache can't stall the API; a replica in another AZ with automatic failover; the fail-open or fallback decision made per limit REL 5 · REL 11 |
| Performance Efficiency | One round trip per request; the whole decision in one atomic script; latency budget stated and measured PERF 1 · PERF 3 |
| Security | The login limit stops password guessing and never fails fully open; the cache sits in private subnets, reachable only by the gateways, with authentication and TLS SEC 5 · SEC 9 |
| Cost Optimization | Memory math shows 96 MB, so the node is chosen for steady CPU, not memory: about $265 a month COST 6 |
| Operational Excellence | Light this round: alarms on the 429 rate, fail-open and fallback counts, and the limiter's own latency OPS 8 |
| Sustainability | Skipped this round: one small node; nothing to decide yet |
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Asks what to limit by, how exact, and whether bursts are allowed, and explains how each answer changes the design.
- Explains why local counts fail behind a load balancer (N× the limit).
- Compares the algorithms and chooses one with a reason; knows the fixed window's 2× edge burst.
- Proves the token bucket's numbers: 12 s to refill, at most capacity + rate × window in any window.
- Makes the check atomic in the store, and uses the store's clock.
- Treats "what if the limiter is down" as a first-class question, and decides per limit.
- Sets a TTL, and knows why TTL ≥ capacity ÷ rate makes expiry harmless.
Follow-up questions
-
"Why not use the sliding window log? It's exact." Answer: its memory grows with the request rate. At 100 requests per active user, and assuming around 64 bytes per stored timestamp, 1M active users is about GB, against 96 MB for token buckets. Worse, if the log records every attempt (some implementations do, so that refused requests still count against the client), an attacker sending 10,000 requests a minute makes us store 10,000 entries for them: the algorithm is most expensive exactly when we're under attack. We asked in R1.1 and "a small overshoot is fine", so exactness isn't worth that.
-
"Your capacity is 20 but your limit is 100 a minute. What does
X-RateLimit-Remainingsay?" Answer: the tokens in the bucket, at most 20. It's the true answer to "how many can I send right now?", but a client reading "limit 100, remaining 20" might be confused. We say in the docs thatRemainingcounts tokens available now, and we sendRateLimit-Policy: "per-user";q=100;w=60so the policy itself is explicit. -
"Could we skip the cache and use DynamoDB for the counters?" Answer: it would work, with a conditional write per check. But each DynamoDB write is a few milliseconds, against our 1 ms target, and a single item's partition supports about 1,000 writes per second, so one very busy key hits a ceiling. DynamoDB also has no server-side clock in conditions: we would have to pass the gateway's time, which brings back step 1.3's clock problem. We'd pay per request, too: at 3,300 writes a second on average, about $5,400 a month at $0.625 per million writes, against $231 for the cache.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Each gateway counts locally" | Behind a load balancer, a user gets N× the limit. |
| "Read the count, decide, then write" | Check-then-act race: concurrent requests all see the same old count. |
| "Fail closed, it's safer" | Turns a cache failure into a full API outage. Decide per limit instead. |
| "Pass the gateway's time into the script" | Gateway clocks differ; a fast clock mints tokens. Use the store's clock. |
| "Keys don't need a TTL" | Memory grows with every user who ever called; set TTL ≥ capacity ÷ rate. |
| "Fixed window is fine" | 2× the limit across a window boundary, legally. |
Round 2 · Senior · "A Multi-Tenant Gateway for the Whole Company"
~40 min · Senior SDE (L6) · 1 region, 3 AZs · ~167K requests/s peak · ~50M buckets · limiter P99 < 2 ms · 99.99%
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "We built a rate limiter for one public API: about 10,000 requests a second at peak, 1M users, one region, three AZs. Each user has a token bucket, capacity 20 and 100 tokens a minute, so bursts pass and nobody gets more than 120 in any minute. Login has its own bucket, 5 attempts a minute per account. The buckets live in ElastiCache, one primary and one replica, so all six gateways share one count. Each check is one atomic script in the cache that refills, decides and writes, using the cache's clock, so there is no check-then-act race and gateway clocks don't matter. Every key expires after 60 seconds, which is longer than a full refill, so expiry never grants extra tokens. Checks time out after 3 ms behind a circuit breaker. When the cache is unreachable, the API limit fails open with an alarm, and the login limit falls back to a local limit on each gateway. Refusals are 429 with Retry-After. About $265 a month. Three costs are still open: every request pays one round trip to one node, a very busy key would sit on one node, and the limits are in a config file."
Architecture v1, compact
textclients ──► ALB ──► 6 gateways (2 per AZ), limiter filter + local fallback │ one EVALSHA per request, 3 ms timeout, circuit breaker ▼ ElastiCache primary (AZ a) ──replicates──► replica (AZ b) key rl:user:<id>:api → { tokens, ts }, TTL 60 s
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Counts not shared | Shared ElastiCache store | A hop per request |
| 1.2 | Which algorithm | Token bucket (capacity 20, 100/min) | Parameters per limit |
| 1.3 | Check-then-act race | Atomic script, the store's clock | More work in the store |
| 1.4 | Store down | Timeout + breaker; fail open (API), local fallback (login) | Loose enforcement for seconds |
| 1.5 | Instant retries | Retry-After; full-jitter backoff | Relies on clients |
| 1.6 | Memory grows | TTL ≥ capacity ÷ rate | A rule per limit |
Open costs: one round trip per request to one node; a hot key lives on one node; rules in config files.
R2.1 The Scope Raise
Interviewer: "Your limiter worked, so now every API in the company goes through our gateway. Our customers are tenants on Free, Pro and Enterprise plans, each with different limits. Some routes are much more expensive than others: an export costs us about ten times a read. The security team must be able to change a rule during an attack and have it take effect in seconds. We also want limits per IP address at the edge. The company serves about 500,000 requests a second at peak across three regions, and each region runs its own gateway stack, so design one region's: about 167,000 a second. The limiter's decision must take under 2 ms at P99, and the gateway must be up 99.99% of the time."
A scope raise is not the end of scoping. Before we fix anything, we ask back, and we say what each answer changes, just as in R1.1.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Is every request authenticated? | Almost all carry a tenant API key or token. Login, sign-up and a few public pages don't. | Two kinds of identity: authenticated requests are limited by tenant, the rest by IP address (steps 2.1 and 2.5). |
| What are the plan limits? | Free: 10 requests/s, burst 20. Pro: 100/s, burst 200. Enterprise: per contract, typically 1,000/s, burst 2,000. | A rule model with tiers (R2.3), not one hard-coded limit. |
| How do the expensive routes work? | POST /v1/exports costs 10 units and also has its own cap of 5 exports per second per tenant. | Weighted costs, and more than one bucket per request (step 2.1). |
| How fast must a rule change land? | Within 5 seconds, across the whole fleet, without restarts and without resetting anyone's counters. | Rules move out of config files into a versioned store that gateways reload live (step 2.2). |
| Who's the biggest tenant? | One tenant has a 10,000/s contract. Its batch jobs sometimes misfire and send 100,000/s. | A single tenant's bucket can be hotter than one cache node can serve (step 2.3). |
| Can limits overshoot a little? | For plan limits, up to about a third of a second's worth now and then is fine. Security limits must stay tight. | We can serve hot plan limits from local memory with a bounded overshoot, but never security limits (step 2.3). |
| What should happen when the limiter's cache fails? | The API stays up. Plan limits may loosen for seconds; security limits must never disappear. | Round 1's per-limit rule stands, and failover must not cause its own storm (step 2.4). |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Callers | One API's users | Every API in the company; tenants on Free, Pro, Enterprise |
| Traffic | ~10K requests/s peak | ~167K requests/s peak in this region (500K across three independent regional stacks) |
| Tracked keys | ~1M user buckets | ~50M buckets: IPs, users, tenants, tenants × routes |
| Limits | One per user, one per login account | Per IP, per tenant tier, per route, with weighted costs |
| Rules | In a config file | Live changes in under 5 s, no restarts, no counter resets |
| Survive | The cache primary failing | A cache node or a whole AZ |
| Targets | < 5 ms added | P99 < 2 ms for the decision; 99.99% for the gateway (4.4 min/month) |
The "Not yet" list from R1.2 is now mandatory: limits per customer and plan, weighted routes, and limit changes without a deploy.
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | What breaks at the new scope |
|---|---|
| One limit per user | A request now has to pass an IP limit, a tenant limit and a route limit. One key can't express that. |
| Limits in a config file | Changing a limit during an attack means a deploy, which takes minutes and may restart processes. |
| One cache node | 167K checks a second is about three times our assumed 60K per node. We need shards, and a key lives on exactly one shard. |
| "A busy key is fine" | The 100K/s tenant's bucket is one key on one shard. Every one of its requests, allowed or refused, hits that one node. |
| Reconnect on failure | With 24 gateways and pooled connections, a failover means thousands of clients reconnecting to one new primary at once. |
| Per-IP limits in the gateway | Attackers forge the X-Forwarded-For header, and a flood of 1M requests a second shouldn't reach our gateways at all. |
R2.3 New Requirements and API Additions
The rule model. A rule says which bucket to use, which requests it applies to, how big the bucket is and how fast it refills, what each request costs, and what to do when it's empty. Rules live in a versioned rule set:
yamlrule_set: public-api version: 1842 rules: - id: ip-unauthenticated dimension: ip # bucket key: the client IP set by our edge match: { authenticated: false } limit: { rate_per_s: 20, burst: 40 } cost: 1 kind: security # never leased; local fallback if the cache is down action: reject - id: tenant-plan dimension: tenant match: { authenticated: true } limit_by_tier: free: { rate_per_s: 10, burst: 20 } pro: { rate_per_s: 100, burst: 200 } enterprise: { rate_per_s: from_contract, burst: 2x_rate } cost_by_route: "POST /v1/exports": 10 default: 1 kind: capacity # may be leased; fails open if the cache is down action: reject - id: route-exports dimension: tenant+route match: { route: "POST /v1/exports" } limit: { rate_per_s: 5, burst: 5 } cost: 1 kind: capacity action: reject - id: login-per-account dimension: account match: { route: "POST /v1/login" } limit: { rate_per_s: 0.0833, burst: 5 } # 5 per minute cost: 1 kind: security action: reject
How a request is evaluated.
- Resolve identity once: the tenant and tier from the verified token, or the client IP from our edge (step 2.5).
- Collect the matching rules. An export by a Pro tenant matches
tenant-plan(cost 10) androute-exports(cost 1). - Short-circuit locally first. If a matching bucket is in the gateway's "denied until" cache (step 2.3) or its leased tokens cover the request, no network call is needed.
- Check every remaining bucket in one atomic call (step 2.1). All must pass, or none are charged.
- Return the most restrictive answer: the smallest
remainingand the largestretry_afteramong the buckets.
Validation rules (checked when a rule set is saved, not at request time):
- Every
costis at most its bucket'sburst. A request costing 10 against a burst of 5 could never pass: a silent, permanent outage for that route. - Every bucket's TTL is at least
burst ÷ rate(Round 1's rule), computed by the system, not typed by a person. - A new version that tightens a limit for more than a set share of traffic needs a second approver (R2.8).
The rule-management API. Versions go up by one on every change, and a write must name the version it is based on:
httpPUT /v1/rule-sets/public-api HTTP/1.1 Host: ratelimit-admin.internal If-Match: "1841" Content-Type: application/yaml rule_set: public-api rules: [...]
httpHTTP/1.1 200 OK ETag: "1842" Content-Type: application/json { "rule_set": "public-api", "version": 1842, "rollout": "canary", "effective_fleet_wide_within_s": 5 }
If someone else saved version 1842 first, this request gets 412 Precondition Failed, and they must re-read and re-apply their change. Two security engineers editing during an incident can't silently overwrite each other.
Headers per tier. The response headers describe the bucket closest to empty, and the draft RateLimit-Policy field can list every policy that applied:
httpHTTP/1.1 200 OK X-RateLimit-Limit: 5 X-RateLimit-Remaining: 3 X-RateLimit-Reset: 1 RateLimit-Policy: "tenant-plan";q=100;w=1, "route-exports";q=5;w=1 RateLimit: "route-exports";r=3;t=1
R2.4 Design Evolution: Hot Keys, Live Rules and Layers
Step 2.1: One Request Must Pass IP, Tenant and Route Limits
The problem: a Pro tenant calls POST /v1/exports. It must pass the tenant's plan limit at cost 10 and the export cap at cost 1. The tenant has 12 tokens in its plan bucket but its export bucket is empty.
What would you do? How do we check several buckets for one request, correctly and fast?
Primitive: Distributed Rate Limiting
Step 2.2: Security Changed a Rule and Half the Fleet Still Uses the Old One
The problem: during a scraping attack, a security engineer tightens the unauthenticated IP limit from 20/s to 5/s. Ten minutes later, 11 of 24 gateways are still enforcing 20/s. Two of them briefly went back to an even older rule after a delayed message arrived. What would you do?
Step 2.3: A 100K-Requests/s Tenant Pinned One Cache Shard
The problem: the tenant with a 10,000/s contract misfires a batch job and sends 100,000 requests a second. Because of step 2.1's hash tag, every one of those checks is a script call on the same shard. Nine in ten are refused, but each refusal still costs a call. That shard's engine CPU hits 100%, and every other tenant whose keys live on that shard slows down with it. What would you do?
Synthesizing vector architecture diagram...
The gateway talks to the shard once per block, not once per request. When the tenant is over its limit, the gateway refuses locally until the time the shard gave it, so a runaway tenant's refusals cost the cache almost nothing.
Primitive: Distributed Cache Patterns & Eviction · Drill: Sharding tenant hotspot
Step 2.4: The Cache Failed Over and Every Gateway Reconnected at Once
The problem: a shard's primary fails. Replica promotion takes seconds. Meanwhile, 24 gateways, each with a pool of 64 connections per shard, see errors, drop their connections, and immediately open new ones, all to the newly promoted primary, all with TLS handshakes, all refreshing the cluster's slot map at the same moment. The new primary spends its first seconds on handshakes instead of checks. What would you do?
Drill: WebSocket reconnect thundering herd
Step 2.5: Attackers Rotate IPs and Forge X-Forwarded-For
The problem: a scraper sends requests with a different X-Forwarded-For header on each one: X-Forwarded-For: 10.0.0.1, then 10.0.0.2, and so on. Our per-IP limit reads that header, so every request looks like a new client with a full bucket. Separately, a botnet sends 2 million requests a second, and our 24 gateways are drowning in traffic they'll refuse anyway.
What would you do?
Primitive: API Gateway & Reverse Proxy · Drill: Rate limiting a partner API
Step 2.6: Limiter Latency Creeps Above 2 ms at Peak
The problem: at peak, the limiter's P99 is 2.8 ms. The cache's CPU is only at 40%. What would you do?
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | Several limits per request | One key per dimension, one hash tag per tenant, one all-or-nothing script, weighted costs | More keys; a tenant's keys on one shard |
| 2.2 | Rule changes are slow and can roll back | Versioned rule store, 1 s polling, monotonic versions, buckets keyed by rule ID | 1–2 s propagation; bad rules spread fast too |
| 2.3 | A hot tenant pins one shard | Local token leasing (block ≈ 0.25 s of the gateway's share), local "denied until", give-back | Overshoot ≤ gateways × 1.2 × block (~0.3 s); lease lifecycle |
| 2.4 | Reconnect storm on failover | Multiplexed connections, per-shard breaker, jittered backoff, local fallback | Weaker enforcement for seconds on one shard |
| 2.5 | Forged client IPs; floods | Trust only CloudFront's viewer address; Shield + WAF at the edge; gateway; service | Coarse edge control; WAF request fees |
| 2.6 | P99 above 2 ms | One round trip, pipelining, no extra hops, local hot keys, own latency metric | More logic in the check path |
R2.5 Architecture v2
Synthesizing vector architecture diagram...
Follow a request top to bottom: CloudFront and WAF drop floods, the load balancer admits only CloudFront's traffic, and the gateway's limiter filter decides with one call to the shard that holds the tenant's keys (or none, when a lease covers it). The dotted arrows are off the request path: gateways poll the rule store every second, and every version is also saved to S3 so a gateway can start without the rule store. Each shard's replica sits in a different AZ from its primary (not drawn).
Trace 1: a weighted, multi-bucket check.
Synthesizing vector architecture diagram...
Trace 2: a live rule change. A security engineer saves version 1843 (unauthenticated IP limit 20/s with burst 40 → 5/s with burst 10) with If-Match: "1842". The conditional write moves the current pointer to 1843 and writes the S3 snapshot. Within a second, each gateway's poll sees 1843, fetches it, validates it, and swaps it in. The first request from an attacking IP after the swap refills that IP's bucket at 5/s and clamps its tokens to the new burst of 10. No gateway restarts; no bucket resets; a delayed copy of version 1842 arriving later is ignored because 1842 < 1843.
Trace 3: a cache failover. Shard 3's primary fails. Checks for keys on shard 3 time out at 3 ms; after five in a row, each gateway's breaker for shard 3 opens. For about a sixth of keys, capacity rules fail open (counted, alarmed) and security rules use the local fallback; leased blocks keep serving. ElastiCache promotes shard 3's replica in another AZ within seconds. Gateways probe with jittered backoff, refresh the slot map once, reconnect their two connections, and close their breakers. The other five shards never noticed.
R2.6 Numbers and Cost
Traffic. The company's peak is 500,000 requests a second across three regional stacks, so each region plans for , which we round to ~167K requests/s at peak. Average is about a third of peak, ~55.6K/s, which is billion requests a month in this region.
Gateways. We plan on 12,000 requests a second per gateway node (an assumption to load-test). With 24 nodes, 8 per AZ, peak runs at per node (58%), and after losing an AZ, per node (87%), with no new nodes needed.
Memory, re-derived from the fact source's figures:
The 50M is an upper bound: it counts every bucket seen in a day as if all were alive at once, while TTLs of a minute or less keep far fewer alive. What decides the node type is memory per primary: GB of data per shard, or 1.2 GB with the 1.5× buffer. If our 96-byte estimate turns out to be 200 bytes (R1.7's warning), that's 10 GB of data, 1.67 GB per shard, 2.5 GB with the buffer.
Shards, from operations per shard. One script call per request before leasing, so 167K calls/s at peak. At our assumed 60,000 calls per second per shard (to load-test):
Three shards leave no headroom for bursts, uneven key spread or a slow shard. We choose 6 primary shards: calls/s each, 46% of the assumption at peak. We size this without counting on leasing, because leasing is an optimization we may switch off (for example, if a tenant disputes its limit and we want exact enforcement while we investigate). And each shard gets one replica in a different AZ, so losing an AZ promotes replicas but leaves all six shards with a primary: capacity doesn't drop.
Node type. Each primary needs about 1.2 GB (2.5 GB at worst) and CPU for one busy engine thread. We choose cache.m7g.xlarge (4 vCPUs, 12.93 GiB; about 9.7 GiB usable after the default 25% reservation). Four vCPUs keep the host's background processes and network handling off the engine thread's core, which AWS notes can matter on 2-vCPU nodes. We don't need a memory-optimized r-family node: the fact source's cache.r6g.xlarge (26.32 GiB) buys about twice the memory we could ever use.
How much leasing removes. Assume about 200 tenants run above 500 requests a second and together carry half the peak, ~83.5K requests/s. With block sizes of a quarter-second of each gateway's share, each such tenant costs at most about 100 lease calls a second:
| Calls/s at peak | Per shard | Share of the 60K assumption | |
|---|---|---|---|
| Without leasing | 167,000 | 27,800 | 46% |
| With leasing | 83,500 + 200 × ~100 ≈ 103,500 | 17,250 | 29% |
Leasing cuts total calls by about 38%, not 99%: the 99% is per hot key. What it really buys is the worst case: the runaway 100K/s tenant costs ~100 calls/s instead of 100,000 on one shard.
The per-request edge fee. AWS WAF charges $0.60 per million requests, plus $5 per web ACL and $1 per rule per month:
That is list price for this region's traffic. (AWS Shield Advanced, at $3,000 a month per organization, includes up to 50 billion WAF requests a month; Round 3 uses it.) Large customers negotiate private pricing, so treat the absolute number as illustrative. The ratio is the lesson: the edge's per-request fee is about 25 times the limiter's own infrastructure.
Monthly cost (us-east-1 on-demand; CloudFront's own fees aren't counted, because the company already serves its APIs through CloudFront for TLS and edge routing):
| Item | Math | Monthly |
|---|---|---|
12 × cache.m7g.xlarge | 12 × $0.315/h × 730 h | ≈ $2,760 |
| Cross-AZ traffic, gateways to shards | ~300 B per call and reply × 55.6K calls/s ≈ 16.7 MB/s × 2,629,800 s ≈ 43.8 TB; about 2/3 crosses an AZ ≈ 29.2 TB × $0.02/GB. An upper bound: leasing removes about 38% of calls | ≤ $585 |
| Rule store polling | 24 gateways × 1 strongly consistent read/s ≈ 63M reads × $0.125 per million | ≈ $8 |
| CloudWatch metrics, alarms, dashboards | ≈ $150 | |
| Limiter subtotal | ≈ $3,500 | |
| AWS WAF at the edge | $5 web ACL + ~$10 rules + 146,000 M × $0.60 | ≈ $87,600 |
| Total | ≈ $91,100/month |
R2.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Leasing overshoot vs cache load | Lease only capacity buckets above ~500/s, blocks of ~0.25 s | Up to ~0.3 s of a hot tenant's limit as overshoot (gateways × 1.2 blocks); slight undershoot when blocks are stranded on idle gateways |
| Push vs pull for rules | Pull: poll a tiny pointer every second | Up to ~1–2 s delay and one read per gateway per second. Push (a message per change to every gateway) is faster but needs a delivery system that itself must be reliable, ordered and monitored; polling can't miss a change. |
| Where each limit lives | Volume per IP at the edge (WAF), tenants and routes in the gateway, business rules in the service | WAF can't see tenants or costs; the gateway can't see business data; each layer must be kept consistent with the others |
| Fail open vs local fallback | Per rule kind: capacity fails open, security uses per gateway | Seconds of loose plan limits; possible early refusals for honest users on security routes during an outage |
| Redis vs DynamoDB for counters | ElastiCache (Redis/Valkey) | DynamoDB would be durable and serverless, but a conditional write per check costs milliseconds, a single hot item tops out around 1,000 writes a second, conditions have no server-side clock, and 146 billion writes a month would cost about $91,000 at $0.625 per million. Counters are short-lived and cheap to lose, so durability buys us little. |
R2.8 Failure Modes
| Failure | Trigger | What you'd see | How the design responds |
|---|---|---|---|
| Losing an AZ | An AZ-wide failure | A third of gateways gone; two shards' primaries gone | The load balancer stops sending to the lost gateways; 16 remain at ~87%. The two shards promote their replicas in other AZs within seconds; for those seconds, their keys fail open (capacity) or fall back (security). All six shards keep a primary. |
| Memory fragmentation and eviction | Tens of millions of short-lived keys created and expired all day | DatabaseMemoryUsagePercentage climbs although live data stays flat; Evictions rises | Fragmentation: the allocator holds freed memory in pieces it can't reuse well; Redis's active defragmentation (a parameter-group setting) moves data to reclaim it. Eviction policy: we choose volatile-ttl, which tends to evict keys that are nearly idle. It is not harmless: evicting a bucket that hasn't fully refilled hands its owner free tokens, and for a security bucket that is a small hole. So any eviction is an alarm, not a normal event. We also alarm at 75% memory. |
| The rule store is down | DynamoDB errors or throttling in the region | Rule polls fail; rule-staleness metric rises | Gateways keep the last good rule set; new gateways start from the S3 snapshot. Rule changes are blocked until it recovers, and we alarm, because in an attack that's a problem (R3.9 has the emergency path). |
| A bad rule throttles everyone | Someone sets the Pro tier to 1/s instead of 100/s | 429 rate jumps across many tenants within seconds | Prevention: validation on save (R2.3), a second approver for wide tightening, a shadow mode where a new rule logs what it would refuse without refusing, and a canary: new versions go to one gateway per AZ for 60 seconds, and roll back automatically if that canary's 429 rate jumps. An emergency change can skip the canary with two approvers. Recovery: saving the previous rules as a new version (1845 = 1843's content) takes one second to reach the fleet. |
| A hot-tenant spike | The 10K/s tenant sends 100K/s | That tenant's 429s jump; its shard's CPU stays flat | Step 2.3: leases and the local "denied until" cache keep the shard's load near 100 calls a second for that tenant; other tenants on the shard are unaffected. The gateway fleet itself absorbs the extra 90K requests a second, and scales out if it lasts. |
R2.9 Production Gotchas
| Gotcha | Symptom | Cause | Fix |
|---|---|---|---|
| Check-then-act | Tenants exceed limits under concurrency, never in tests | Read in the gateway, decide, then write | One atomic script per check (step 1.3) |
| Missing hash tags | CROSSSLOT Keys in request don't hash to the same slot errors in cluster mode | A multi-key script whose keys hash to different slots | Put the same {tag} in every key one script touches (step 2.1) |
| Fail-closed login gateway | A cache blip becomes a login outage during a product launch | A limiter timeout treated as "refuse" | Per-rule kind: security rules use the local fallback, not "refuse" or "allow everything" |
| No TTL | Memory grows until evictions or OOM command not allowed | Keys written without expiry | The script sets the expiry on every write, from burst ÷ rate |
| Trusting client IP headers | Per-IP limits never trigger during a scrape | Reading a client-supplied X-Forwarded-For | Read only the address our edge sets; lock the origin to CloudFront |
| Cost larger than burst | One route always returns 429 | A rule with cost 10 against a burst of 5 | Validation on save: cost ≤ burst |
R2.10 Pillar Check
| Pillar | What Round 2 adds |
|---|---|
| Reliability | Six shards, each with a replica in another AZ, so an AZ loss leaves every shard a primary; per-shard breakers with jittered reconnects; last-good rules on disk and in S3; canary and shadow mode for rules REL 5 · REL 10 · REL 8 |
| Performance Efficiency | One round trip per request even with several buckets; leases and a denied cache for hot keys; the limiter's own P99 measured separately; shard count from an assumption we load-test PERF 3 · PERF 5 |
| Security | Only CloudFront's viewer address is trusted; the origin admits only CloudFront; Shield and WAF at the edge; security rules never fail fully open; rule changes need versions and approvals SEC 5 · SEC 3 |
| Cost Optimization | Node type chosen from memory per primary, not total memory (half the fact source's node size); cross-AZ traffic to the shards bounded and costed (≤ $585); the edge's per-request fee found to be the largest line COST 6 · COST 8 |
| Operational Excellence | Rule changes versioned, validated, shadowed and canaried, with one-step rollback; alarms with first actions (below) OPS 6 · OPS 8 |
| Sustainability | Light this round: Graviton nodes; TTLs keep only live buckets in memory; leasing removes about 38% of cache calls SUS 4 · SUS 5 |
Alarms and first actions
| Signal | Alarm | First action |
|---|---|---|
| Limiter decision latency P99 (gateway histogram) | > 2 ms for 5 min | Check per-shard latency and EngineCPUUtilization; look for a code path making more than one call |
| Check timeouts | > 0.1% of checks for 5 min | Which shard? Check its CPU, connections and network allowance metrics |
| Fail-open and fallback decisions | any sustained count | Is a breaker open? For which shard? Is a failover in progress? |
429 rate by tenant and by rule | sudden jump across many tenants | Suspect a rule change first: which version went out in the last minutes? |
| Rule-set version spread across gateways | more than one version for > 10 s | Find the gateways that didn't update; check their polls and the rule store |
Evictions, DatabaseMemoryUsagePercentage | any evictions; > 75% | Check TTLs on new rules; check fragmentation |
R2.11 Round 2 Rubric and Follow-Ups
What a senior (L6) answer adds over L5
- Models limits as rules with dimensions, costs and kinds, and checks several buckets in one atomic, all-or-nothing call.
- Knows why cluster mode needs hash tags, and what they cost (a tenant's keys on one shard).
- Solves the hot key with leasing, and states and proves the overshoot bound.
- Makes rule changes live and safe: versioned, monotonic, validated, canaried, and without resetting counters.
- Plans failover as a load event: fewer connections, per-shard breakers, jittered reconnects.
- Never trusts client-supplied IP headers, and places each limit at the layer that can see what it needs.
- Sizes shards from operations, nodes from memory per primary, and notices where the money really goes.
Follow-up questions
-
"Why not lease for every key? It saves calls everywhere." Answer: for a key that sees 2 requests a minute, a lease is a call that saves nothing, and every lease strands tokens on a gateway that may never use them. A Free tenant at 10/s with 24 gateways would need blocks of 1 token to keep its overshoot small, which is just a call per request with extra steps. Leasing pays off only when a key's rate per gateway is high enough that one call serves many requests.
-
"Two gateways hold 105-token leases for the big tenant, and the tenant goes quiet. What happens to those tokens?" Answer: each lease expires 10 seconds after it was taken, by that gateway's monotonic clock, and the gateway gives the unused tokens back with the
give_backscript, which adds them to the bucket capped at its capacity. While they were stranded, the tenant's bucket had up to 210 fewer tokens than it should have: a small undershoot. Only the gateway's own clock is involved, so clock differences between gateways don't matter here. -
"Why is your IP limit keyed on the IP alone, and not per tenant?" Answer: it applies only to unauthenticated requests, where there is no tenant yet. For authenticated traffic, the tenant is the identity we trust; an enterprise tenant's requests may all come from a few NAT addresses, and a per-IP limit would throttle them wrongly. For those, per-IP volume is WAF's job at the edge, with known partner addresses excluded.
Interview gotchas from this round
| Gotcha | Why it's wrong |
|---|---|
| "Add shards to fix a hot key" | A key lives on one shard; more shards don't split it. |
| "Check each bucket with its own call" | Not atomic (charges buckets for refused requests) and blows the latency budget. |
| "Restart gateways to apply rules" | Minutes, during an attack; and it can't stop an older rule from arriving later. |
| "Leasing is free" | Overshoot of up to gateways × 1.2 × block (with early refill); state it and bound it. |
| "WAF rate rules are exact" | AWS documents them as approximate, with a detection delay of usually under 30 seconds. |
Round 3 · Architect · "Global Limits, Billing Quotas and Real Attackers"
~45 min · Principal (L7) · 3 regions · ~500K requests/s peak · 99.999% for the gateway · quotas exact enough to bill · no cross-region call on the request path
R3.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 3. If you're starting here, it's everything you need from Rounds 1 and 2.
Round 2 in 60 seconds. "Every API in the company goes through our gateway. Each of three regions runs its own stack at about 167,000 requests a second at peak, 99.99%, with the limiter's decision under 2 ms at P99. Limits are rules with a dimension, a tier, a cost per route and a kind: capacity or security. One request checks several token buckets, IP, tenant and route, in one atomic all-or-nothing script, because all of a tenant's keys share a hash tag and live on one shard. ElastiCache runs in cluster mode with six shards, each with a replica in another AZ, sized at 46% of an assumed 60,000 calls a second per shard. Hot tenants lease blocks of about a quarter-second of tokens into gateway memory, so a runaway tenant costs the cache about 100 calls a second, with an overshoot bounded by gateways times 1.2 blocks, about 0.3 s of the limit. Rules live in a versioned store; gateways poll every second, only accept higher versions, and never reset buckets. Failover uses per-shard breakers and jittered reconnects; capacity rules fail open and security rules fall back to local limits. We trust only CloudFront's viewer address, and WAF and Shield take the floods. About $3,500 a month for the limiter, and $87,600 for WAF request fees. Four costs are still open: limits are per region; billing quotas don't exist; per-IP limits miss distributed attacks; and tenant limits don't protect a struggling backend."
Architecture v2, compact
textclients ──► CloudFront + Shield + WAF (per-IP floods) ──► ALB (CloudFront only) ──► 24 gateways, 8 per AZ: rules v18xx, leases, denied cache, breakers, local fallback │ one script per request (or per lease), keys rl:{tenant}:... ▼ ElastiCache cluster: 6 shards × (primary + replica), cache.m7g.xlarge rule store (DynamoDB, versioned) ──polled 1 s──► gateways; snapshots in S3 × 3 regions, each on its own: a tenant's 1,000/s limit is 1,000/s in *each* region
Steps so far
| Step | Problem | Component |
|---|---|---|
| 1.1–1.3 | Shared, atomic counting | ElastiCache, token bucket script, the store's clock |
| 1.4 | Store down | Timeout, breaker, fail open vs local fallback per limit |
| 1.5–1.6 | Retries, memory | Retry-After, jittered backoff, TTL ≥ burst ÷ rate |
| 2.1 | Many limits per request | Multi-bucket all-or-nothing script, hash tags, weighted costs |
| 2.2 | Live rules | Versioned rule store, polling, monotonic versions |
| 2.3 | Hot tenant | Local leasing with bounded overshoot, denied cache, give-back |
| 2.4 | Failover storm | Multiplexed connections, per-shard breakers, jitter |
| 2.5 | Forged IPs, floods | CloudFront viewer address, Shield, WAF, layered limits |
| 2.6 | Latency | One round trip, no extra hops, own P99 metric |
Open costs: limits are per region; no billing quotas; per-IP limits miss distributed attacks; nothing protects a struggling backend.
R3.1 The Scope Raise
Interviewer: "We're consolidating into one global platform across three regions, and it must survive losing a whole region. Enterprise contracts now say 1,000 requests a second globally, not per region. Paid plans include monthly quotas that we bill against, so usage numbers go on invoices. Last month we had a credential-stuffing attack spread over 100,000 IP addresses, and not one of them crossed a per-IP limit. And last week our search service slowed down and fell over, even though no tenant was over its limit. The gateway must be up 99.999% of the time."
Again, we ask back before we design:
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How exact must a global limit be? | Averaged over a minute, a tenant should get its contract. Short overshoot or undershoot for up to about half a minute is fine; sustained overshoot isn't. | We can split the limit into regional shares and rebalance them in the background, as long as rebalancing converges within about 30 s (step 3.1). |
| Can a check call another region? | No. The 2 ms P99 still holds, and a region must work alone. | No cross-region call on the request path. Coordination happens off the path, every few seconds. |
| How are quotas billed? | Monthly. Free: 1M requests, hard stop. Pro: 10M included, overage billed per 1,000. Invoices must match usage records, and customers want to see usage within minutes. | Billing needs exact, auditable counts, which the limiter's approximate, expiring buckets can't give. A separate metering pipeline (step 3.2). |
| Must a tenant be cut off exactly at its quota? | Free tenants, within a minute or so. Paid tenants just start paying overage. | Enforcement can lag billing by tens of seconds, so the limiter only needs a "near or over quota" flag (step 3.2). |
| What did the attack look like? | 100,000 residential IPs, about one login attempt per IP per minute, each trying a different leaked email and password pair. | Per-IP limits and even per-account limits never trigger: each account is tried once. We need signals attackers can't rotate cheaply (step 3.3). |
| Why did search fall over? | Its database slowed down, latency went from 50 ms to 500 ms, and threads piled up. Every tenant was within its limit. | Protecting a backend is a different job from limiting clients: load shedding at the service (step 3.4). |
| How fast must we recover from a region loss? | Traffic moves within minutes; limits and quotas must keep working. | Shares, quotas and the limiter must all work with two regions (step 3.5). |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Footprint | 1 region (of 3 independent stacks) | 3 regions, one platform; survive losing a region |
| Traffic | ~167K requests/s peak per region | ~500K requests/s peak globally; ~250K per region after a region loss |
| Limits | Per region: 1,000/s means 1,000/s in each region | + global per-tenant limits (1,000/s total) |
| Usage | Approximate buckets that expire | + monthly quotas, billed, visible to customers within minutes |
| Abuse | Per-IP and per-account limits | + distributed attacks: 100K IPs, one attempt each |
| Backends | Protected only by tenant limits | + load shedding when a backend is slow, whatever the tenants do |
| Availability | 99.99% (4.4 min/month) | 99.999% for the gateway (about 26 s/month) |
R3.2 What Breaks in the Round 2 Design
| Round 2 choice | What breaks at the new scope |
|---|---|
| Each region counts on its own | A tenant with a 1,000/s global contract gets 1,000/s in each region: 3,000/s in total. |
| "Just check a central counter" | A cross-region round trip is tens to hundreds of milliseconds (on the order of 70–80 ms between us-east-1 and eu-west-1, and more to Asia; measure your own pairs), against a 2 ms budget, on every request. And the central region becomes everyone's single point of failure. |
| Buckets are approximate and expire | Fine for "slow down", useless for an invoice. Leases overshoot, failovers fail open, TTLs delete history. Billing must never count a request twice or lose it silently. |
| Per-IP and per-account limits | 100,000 IPs at one attempt each, one attempt per account: every limit we have sees a polite client. |
| Tenant limits | They limit clients. A backend that is slow can be overloaded by clients who are all within their limits. |
R3.3 New Requirements and API Additions
Global limit definitions. A rule can now have scope: global, with settings for how its budget is split across regions:
yaml- id: tenant-global dimension: tenant scope: global # one budget across all regions match: { tier: enterprise } limit: { rate_per_s: from_contract, burst: 2x_rate } shares: floor_fraction: 0.05 # each live region always keeps 5% of the limit rebalance_every_s: 5 max_step_fraction: 0.10 # a region's share moves at most 10% of the limit per rebalance kind: capacity
The metering model. Every gateway emits usage records; the billing system only ever reads totals derived from them.
A usage record (one per gateway per 5-second flush, carrying up to 1,000 tenants):
json{ "record_id": "gw-use1-a07-1790424000123/000918273", "region": "us-east-1", "interval_start": "2031-03-01T09:30:00Z", "interval_s": 5, "month": "2031-03", "u": { "t_acme": 4821, "t_bluebird": 37 } }
record_id is the gateway's identity (including its process start time, so a restarted process is a new identity) plus a sequence number that only goes up. Units are allowed requests weighted by cost, the same costs the limiter uses. The format is deliberately compact: the month and interval are stated once per record, and each tenant's count is one short map entry, about 24 bytes, so a record of 1,000 tenants is about 24 KB.
The quota usage table (a DynamoDB global table):
| Attribute | Type | Example | Meaning |
|---|---|---|---|
pk (partition key) | String | t_acme#2031-03 | Tenant and billing month |
sk (sort key) | String | us-east-1 | The region that wrote this item; only that region ever writes it |
units | Number | 6210455 | That region's total for the month so far, as an absolute value |
as_of | String | 2031-03-12T14:05:30Z | The end of the last interval included |
A tenant's usage for the month is the sum of its three items.
The usage API, for customers:
httpGET /v1/usage?month=2031-03 HTTP/1.1 Host: api.example.com Authorization: Bearer <tenant token>
httpHTTP/1.1 200 OK Content-Type: application/json { "tenant": "t_acme", "month": "2031-03", "plan": "pro", "included_units": 10000000, "used_units": 9412288, "overage_units": 0, "as_of": "2031-03-12T14:05:30Z", "note": "Usage is complete up to as_of, usually within 5 minutes of now." }
Abuse signals that rules can use, beyond IP address: the account being targeted, a client TLS fingerprint (JA4), a device or session token our login page issues, IP reputation (AWS publishes managed lists of known-bad and anonymizing addresses), and the ratio of failed to successful logins. A new rule kind: adaptive can tighten itself when a signal crosses a threshold (step 3.3).
Backend concurrency limits, declared per service and route, with priority classes:
yamlservice: search concurrency: mode: adaptive # the limit moves with measured latency target_latency_ms: 100 min_limit: 20 max_limit: 400 priority_classes: # shed from the bottom up - critical: ["POST /v1/checkout", "POST /v1/login"] - normal: ["GET /v1/search"] - best_effort: ["POST /v1/exports", "GET /v1/search/suggest"]
R3.4 Design Evolution: Global, Billable and Adaptive
Step 3.1: One Global Limit Across Three Regions
The problem: tenant t_acme's contract says 1,000 requests a second globally. Today it gets 1,000 in each of three regions. We can't call another region on the request path.
What would you do?
Primitive: Gossip Protocol & Failure Detection · Loop: Unique ID generator, step 3.1 (the same global-table trap, for leases)
Step 3.2: Monthly Quotas Must Bill Correctly
The problem: Pro tenants pay for usage above 10M requests a month. Finance wants the invoice for March to match exactly what each tenant used in March, and customers want to see their usage within minutes. What would you do? Where do the billing numbers come from?
Primitive: Change Data Capture & Outbox · Message Queues vs Event Streams
Step 3.3: An Attack From 100,000 IPs, Each Under Every Limit
The problem: 100,000 residential IP addresses each send about one login attempt a minute, about 1,670 attempts a second in total. Each attempt uses a different leaked email and password pair. The per-IP limit (20/s) never triggers. The per-account limit (5/min) never triggers either, because each account is tried once. About 0.5% of the pairs work. What would you do?
Primitive: Bot Defense, Sybil Resistance & Registration Abuse · Drill: Credential stuffing surge
Step 3.4: The Backend Is Slow, but Nobody Is Over Their Limit
The problem: the search service normally handles 2,000 requests a second at 50 ms each. Its database slows down and each request now takes 500 ms. Every tenant is within its limit, the same 2,000 requests a second keep arriving, and search falls over. What would you do?
Primitive: Circuit Breaker, Bulkhead & Fault Tolerance
Step 3.5: A Region Is Gone
The problem: ap-northeast-1 goes dark. Its gateways, cache, stream and aggregator are all unreachable. What would you do about limits, shares and quotas, during the outage and when it comes back?
Primitive: Cloud Disaster Recovery & Multi-Region Active-Active
Step 3.6: Should We Build This at All?
The problem: the CTO asks: "AWS has API Gateway usage plans and WAF rate-based rules. Envoy has a rate-limit service. Why does a team run shards, leases, share-sync and a metering pipeline?" What would you do?
Round 3 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 3.1 | One limit across 3 regions | Regional shares by recent traffic, 5% floor, ±10% per 5 s; one item per region in a global table | ≤ ~30 s to follow a shift; small over- or undershoot |
| 3.2 | Quotas must bill | Separate metering: gateways → Kinesis → Flink (dedupe, absolute totals) → global table; limiter reads a flag | A second pipeline; ~40 s enforcement lag; ≤ 5 s of counts lost per gateway crash (under-billing) |
| 3.3 | Distributed credential stuffing | Failure-ratio adaptive rules, device tokens, fingerprints, Bot Control and ATP on login, challenge instead of lock | False positives; managed-rule fees |
| 3.4 | Slow backend, polite tenants | Adaptive concurrency limits, priority classes, 503 + Retry-After, retry budgets | Refusals for within-limit tenants |
| 3.5 | Region loss | Route 53 moves traffic; shares move when staleness and health checks agree; 7-day stream retention; cells | Hotter survivors; lagging quota flags |
| 3.6 | Build or buy | Buy the edge, reuse Envoy, build leasing, shares and metering | A team on call either way |
R3.5 Global Architecture
Synthesizing vector architecture diagram...
Each region is complete on its own: its request path (CloudFront to gateway to cache to service) never leaves the region. Two small global tables carry everything that must cross regions, and each region writes only its own items in them: its usage rates for shares, and its quota totals. The Europe and Asia regions have the same shape as us-east-1, drawn compactly.
Trace 1: a global-limit check. t_acme (1,000/s globally) sends a request to eu-west-1. The gateway's rules say tenant-global, and the current share for eu-west-1 is 305/s (burst 610). The check is Round 2's ordinary script call against rl:{t_acme}:global at rate 305. Nothing crosses a region. In the background, every 5 seconds, eu-west-1 writes its own usage item (t_acme: 312.4/s), reads the other two from its local replica, recomputes, and moves its share up by at most 100/s.
Trace 2: a quota crossing.
Synthesizing vector architecture diagram...
Every write is an absolute total with a "never go down" condition, so replays are harmless. The flag reaches gateways through the same poll as rules. Tenant t_free9 was over its quota for about half a minute before the flag landed (330 units at its 10 a second); those ~330 requests are simply let go.
Trace 3: a region loss. ap-northeast-1 fails at 09:30:00. Its share-sync item stops changing; at 09:30:15 both other regions see it as stale. Route 53's health checks for it fail at about 09:30:30, so the survivors now have both signals and drop it from the share formula. A dead region's share is released at once (the 10% step limit applies to live regions): t_acme's shares go from 560 / 305 / 135 to and for us-east-1 and eu-west-1 (their floors plus their 2:1 traffic split), and then keep following traffic as ap-northeast-1's clients arrive. By about 09:32, most clients have moved. Each survivor's gateways run near 10,400 requests a second and its shards near 69%. ap-northeast-1's usage records wait in its stream; when it returns, its aggregator catches up from its checkpoint, and its quota items move forward to the right totals.
R3.6 Numbers and Cost
Per-region load.
| Case | Per region | Per shard (6) | Per gateway (24) |
|---|---|---|---|
| Normal peak | 500,000 / 3 ≈ 167K/s | 27.8K calls/s (46%) | ~7,000/s (58%) |
| After losing a region | 500,000 / 2 = 250K/s | 41.7K calls/s (69%) | ~10,400/s (87%) |
Shard percentages are of the assumed 60,000 calls a second and ignore leasing (which removes about 38%); gateway percentages are of the assumed 12,000 requests a second per node. Both are assumptions to load-test before launch.
Share-sync traffic. About 500 tenants have global limits (an assumption). One region's item holds ~30 bytes per tenant, about 15 KB, which is 15 write units per write:
Metering events. Each gateway flushes every 5 seconds. Assuming about 10,000 tenants are active on a gateway in any 5 seconds, a flush is 10 records of 1,000 tenant counts, ~24 KB each in the compact format from R3.3 (~24 B per count):
That's 48,000 tenant counts a second per region, from 167,000 requests: batching is what makes metering cheap. A Kinesis shard takes 1 MB/s or 1,000 records/s, so 1.15 MB/s needs 2 shards; we run 4 per region, for a region-loss load of 1.5× and headroom. Raw records are about TB a month per region, or about 0.6 TB compressed (assuming about 5× compression for repetitive JSON).
Quota table writes. Per region, 10,000 active tenants written every 5 minutes (33 writes/s) plus about 500 tenants above 80% of quota written every 30 seconds (17 writes/s) is 50 writes a second; each is replicated to two regions:
Edge volume. Average global traffic is about a third of peak, ~167K requests a second, which is billion requests a month through WAF. With Shield Advanced ($3,000 a month per organization, 1-year commitment), up to 50 billion WAF requests a month for protected resources are included, along with the web ACL and rule fees:
Without Shield Advanced, all 438 billion would cost about $263,000, so the $3,000 subscription saves about $30,000 in WAF fees, about $27,000 net of its own fee, before counting its DDoS response support. Its data-transfer-out usage fees depend on our egress volume, and we haven't included them.
Managed rules, scoped. Bot Control and ATP are billed for the requests they inspect, so we scope them to the login and sign-up routes with a scope-down statement. Assuming login and sign-up are about 20 million requests a month, 5 million of them login attempts, Bot Control (common) costs $10 plus $1 per million after the first 10 million: 10 + 10 \times \1 = $20. ATP is priced per 1,000 requests analyzed in tiers (\1.00 for the first 2 million a month, $0.70 for the next 3 million, then less); at an assumed 5 million login attempts a month, that's 2{,}000 + 2{,}100 + 10 = \4{,}1106{,}000 \times $0.40 = $2{,}400$ at the next tier, unless cheaper rules in front of ATP refuse the attack first. That's why rule order is a cost decision too.
Monthly cost (us-east-1 on-demand prices used for all regions; others cost somewhat more):
| Item | Math | Monthly |
|---|---|---|
ElastiCache, 3 × 12 × cache.m7g.xlarge | 36 × $0.315/h × 730 h | ≈ $8,280 |
| Cross-AZ traffic to caches | 3 × Round 2's upper bound | ≤ $1,755 |
| Control polling (rules, shares, quota flags in one pointer) | 3 × 24 gateways × 1 read/s | ≈ $24 |
| Share-sync global table | above | ≈ $44 |
| Kinesis, 3 × 4 shards, 7-day retention | 12 × $0.015/h × 730 h ≈ $131 + PUT units 3 × 126M × $0.014/M ≈ $5 + extended retention 12 × $0.020/h × 730 h ≈ $175 | ≈ $312 |
| Managed Service for Apache Flink | 3 × (2 KPUs + 1 orchestration KPU) × $0.11/h × 730 h ≈ $723 + 3 × 100 GB × $0.10 | ≈ $753 |
| Quota usage global table | above | ≈ $740 |
| S3 usage archive | 3 × 600 GB × $0.023/GB, per month of data kept | ≈ $41 (grows monthly) |
| Route 53 health checks | a few checks | ≈ $10 |
| CloudWatch | three regions of metrics, alarms, dashboards | ≈ $500 |
| Limiter and metering subtotal | ≈ $12,460 | |
| AWS WAF request fees beyond the 50B included | above | ≈ $233,000 |
| AWS Shield Advanced | $3,000 | |
| Bot Control and ATP, scoped to login and sign-up | $20 + $4,110 | ≈ $4,130 |
| Edge subtotal | ≈ $240,130 | |
| Total | ≈ $252,600/month |
The lesson in one line: the distributed limiter and metering cost about $12.5K a month; inspecting every request at the edge costs about 19 times that at list price. At this volume, the edge price is a negotiation, and which traffic needs which inspection is an architecture decision (R3.7).
R3.7 Trade-Offs
Rate limits, quotas and load shedding are three different tools.
| Rate limit | Quota | Load shedding | |
|---|---|---|---|
| Question it answers | "Is this client going too fast right now?" | "How much has this tenant used this month?" | "Can this service take one more request right now?" |
| Time scale | Seconds | Days to a month | Milliseconds |
| Protects | Fairness between clients; capacity | Revenue; plan boundaries | The service from collapse |
| Accuracy | Approximate is fine | Exact, auditable | Fast matters more than exact |
| Driven by | What the client does | What the client did | How the system is doing |
| Response | 429 + Retry-After | 429 (hard stop) or billed overage | 503 + Retry-After |
| Our component | Token buckets, leases, shares | Metering stream, aggregator, quota table | Adaptive concurrency at the service |
| Choice | We chose | What we give up |
|---|---|---|
| Share-splitting vs a central counter | Regional shares, rebalanced every 5 s | Up to ~30 s to follow a traffic shift and a small overshoot or undershoot; a central counter would be exact but put a cross-region round trip and a single point of failure on every request |
| Adaptive vs static limits | Static for plans and contracts; adaptive only for abuse and backend health | Adaptive rules can overreact or oscillate (R3.8) and are harder to explain to a customer; static limits can't react to an attack or a sick backend |
| Build vs buy | Buy the edge, reuse Envoy, build leasing, shares and metering | A team owns a critical system; managed options would be simpler but can't do global weighted limits or billing-grade quotas |
| WAF on every request | Yes, with Shield Advanced | About $233K a month at list price. The alternative, WAF only on unauthenticated routes, would save most of it but leave the authenticated API with only Shield's network-layer protection and our gateway against request floods. A decision to make with security and finance, with numbers. |
The opening question was: how do we count fairly across many machines, on every request, without adding latency or becoming the thing that causes the outage? The answer now: count locally and atomically (one script per request in a shared cache, the store's clock), keep hot counts in memory (leases with a proved bound), coordinate off the request path (versioned rules, regional shares, metering streams), and decide in advance what each limit does when its store fails (fail open for capacity, fall back for security). Counting exactly is a separate system, for billing, and protecting a sick backend is a third.
R3.8 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| A region is lost | Its health checks fail; its share-sync item stops | Step 3.5: traffic moves by DNS; shares move when staleness and health checks agree; survivors run at ~69% (cache) and ~87% (gateways); usage waits in the dead region's stream. |
| The share-sync is partitioned (regions up, sync broken) | Items from another region go stale, but its health checks pass | Keep the last shares. They add up to , so the contract's ceiling still holds; the risk is under-serving a tenant whose traffic moves during the partition. Fail toward the contract: after 60 s without sync, a region may grow its share toward its own measured demand, at most one step per rebalance. That can overshoot during a long partition, and we accept it for capacity limits: throttling a paying customer below its contract because our sync link broke is worse than serving it a little extra. Security and quota rules never do this. |
| Metering lag near a quota | Flink's millisBehindLatest rises; quota flags go stale | Enforcement lags further, billing doesn't: records wait in the stream (7-day retention) and totals catch up. If lag passes 5 minutes, we alarm; Free tenants may overshoot their quota by the lag times their rate, which we accept. |
| An attack while the cache is failing over | Breakers open on one shard during a credential-stuffing surge | Security rules on that shard's keys use the local fallback, never fail open. The edge (WAF rate rules, Bot Control, ATP) doesn't depend on our cache at all, which is one reason to keep defense in layers. |
| An adaptive limit oscillates | The login challenge switches on and off every minute; the failure ratio swings | Add hysteresis: switch on at a high threshold, and off only below a lower one, and only after it has stayed there for several minutes. Changes are rate-limited themselves (at most one tightening step per minute), and every change is logged and alarmed. |
| A bad rule, pushed globally | 429s jump in one cell | The canary gateway and the first cell see it first; automatic rollback on the 429-rate alarm; other cells and regions never receive it. |
R3.9 Runbook and Incident Response
Golden signals, per region OPS 8 · REL 6
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| Limiter decision latency P99 | > 2 ms for 5 min | P2 | Per-shard latency and EngineCPUUtilization; look for a new code path with more than one call |
| Fail-open and fallback rate | > 0.1% of checks for 2 min | P1 | Which shard's breaker is open? Is a failover in progress (describe-events)? |
429s per tenant and per rule | jump across many tenants | P1 | Suspect the latest rule version; roll back by saving the previous content as a new version |
| Share drift | sum of computed shares for any tenant > 1.2 × L, or a region stale > 60 s | P2 | Check share-sync writes and ReplicationLatency on the global table |
Metering lag (millisBehindLatest) | > 5 min | P2 | Check the Flink application's health and the stream's throughput metrics |
| Metering gaps (missing sequence numbers) | any | P3 | Find the gateway; check for crashes; estimate under-billed units from the gap |
| Login failure ratio | > 3× its normal level for 5 min | P1 | Start the attack playbook below |
| Backend shed rate | > 5% of a service's requests for 5 min | P2 | That service's own latency and dependencies, not the limiter |
Emergency rule override OPS 10
When an attack needs a new limit now, a security engineer saves a rule set with emergency: true, which skips the canary and needs a second approver. It still carries a version, so it can't be overwritten by a stale change, and it still passes validation:
httpPUT /v1/rule-sets/public-api HTTP/1.1 Host: ratelimit-admin.internal If-Match: "1907" X-Approved-By: sec-oncall-2 Content-Type: application/yaml rule_set: public-api emergency: true expires_after: 4h # reverts to version 1907's rules unless renewed rules: [...]
The fleet has it within about 2 seconds. It expires after 4 hours unless renewed, so an emergency limit can't quietly become permanent.
Attack playbook SEC 10
- Confirm. Is the login failure ratio up? Is the traffic spread over many IPs (step 3.3) or concentrated (per-IP rules should already be firing)? Check which WAF rules are matching.
- Contain at the edge first. Turn on the challenge action for logins without a device token. Add a new, stricter rate-based rule rather than editing the existing one: AWS documents that changing a rate-based rule's settings resets its counts and can pause its rate limiting for up to a minute.
- Tighten in the gateway. An emergency rule for the login route's adaptive thresholds.
- Protect accounts. Force a second factor or password reset for accounts with a successful login from a suspicious client during the window.
- Watch the cost. ATP is billed per login request it analyzes; make sure cheaper rules in front of it are refusing what they can.
- Learn. A Correction of Error (COE) review: which signal would have caught it sooner?
Go deeper: CLI playbook
Plain commands an on-call engineer runs, one at a time. Replace names and IDs with real ones.
text# 1. Is a cache failover in progress, or just finished? aws elasticache describe-events --source-type replication-group --source-identifier rl-use1 --duration 60 # 2. Shard layout and which nodes are primaries now aws elasticache describe-replication-groups --replication-group-id rl-use1 # 3. Rehearse a failover of one shard (in a game day, never during an incident) aws elasticache test-failover --replication-group-id rl-use1 --node-group-id 0003 # 4. Which IPs is a WAF rate-based rule limiting right now? (CloudFront web ACLs live in us-east-1) aws wafv2 get-rate-based-statement-managed-keys --scope CLOUDFRONT --region us-east-1 --web-acl-name api-edge --web-acl-id 11111111-2222-3333-4444-555555555555 --rule-name ip-flood # 5. The current rule-set pointer in a region aws dynamodb get-item --region eu-west-1 --table-name rl-rules --key '{"rule_set": {"S": "public-api#current"}}' --consistent-read # 6. A tenant's quota usage items for a month, all three regions (read from any replica) aws dynamodb query --region eu-west-1 --table-name rl-quota-usage --key-condition-expression "pk = :p" --expression-attribute-values '{":p": {"S": "t_acme#2031-03"}}' # 7. The metering stream's shard count and retention aws kinesis describe-stream-summary --region eu-west-1 --stream-name rl-usage # 8. Alarms currently firing for the limiter aws cloudwatch describe-alarms --region eu-west-1 --state-value ALARM --alarm-name-prefix ratelimit
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | Three regions that each work alone; global limits without cross-region calls; share and quota data that each region writes only for itself, so global-table conflicts can't happen; region loss absorbed at 69% cache and 87% gateway load; cells for rollout; load shedding so a sick backend degrades instead of collapsing REL 10 · REL 13 · REL 5 · REL 4 |
| Performance Efficiency | The request path unchanged from Round 2 (one local call, 2 ms P99); all coordination off the path; batching turns 167K requests a second into 48 metering records a second PERF 1 · PERF 4 |
| Security | Defense in layers against distributed attacks: failure-ratio adaptive rules, device tokens, fingerprints as signals, Bot Control and ATP on login, challenge instead of lockout; an attack playbook and time-limited emergency rules SEC 5 · SEC 10 · SEC 4 |
| Cost Optimization | Every line derived; the edge found to be about 19× the limiter; Shield Advanced justified by its included WAF requests; managed rules scoped to the routes that need them; build vs buy decided by capability COST 5 · COST 11 |
| Operational Excellence | Per-region golden signals with first actions; emergency overrides that are versioned, approved and expiring; metering gaps made visible; COEs after attacks OPS 8 · OPS 10 · OPS 11 |
| Sustainability | Batched metering (one record per gateway per 5 s, not one event per request); floods and bots refused at the edge before they consume compute in any region; Graviton cache nodes; raw usage compressed before storage SUS 3 · SUS 2 · SUS 5 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Separates rate limiting, quotas and load shedding, and gives each its own mechanism and accuracy target.
- Makes a global limit work without a cross-region call, with shares whose convergence time and error are stated.
- Knows how global tables resolve conflicts, and designs so that conflicts can't happen (one writer per item).
- Builds billing on at-least-once delivery plus idempotent absolute writes, and names the one loss it accepts.
- Treats distributed abuse as a signals problem, not a threshold problem, and places each signal at the layer that can see it.
- Decides what happens in a partition in terms of the customer's contract, per kind of limit.
- Does the build-vs-buy comparison against specific capabilities, and finds where the money really goes.
Follow-up questions
-
"Why not make the quota counter part of the limiter's script? It's already running per request." Answer: the script's state lives in a cache that expires keys, evicts under pressure, fails over with seconds of lost writes, and is skipped entirely when a breaker opens. Each of those is fine for a limit and wrong for an invoice. The metering pipeline is durable (the stream), replayable (checkpoints and the archive) and idempotent (absolute totals), and it's off the request path, so a problem there never slows a request.
-
"A tenant sends all its traffic to one region to get a bigger share. Can it game the global limit?" Answer: no. Shares split one limit ; wherever the traffic goes, the shares still add up to . Concentrating traffic just moves the share to that region, 10% of per rebalance. The only gain is the brief overshoot while shares move, at most one step for one interval.
-
"Your share-sync writes rates every 5 seconds. Why not every 100 ms?" Answer: it would converge faster, but replication takes around a second anyway, so shares couldn't be fresher than that; and writes would go up 50×. Five seconds with a 10% step converges within the 30 seconds the business accepted, for about $44 a month. The right interval comes from the business's tolerance, not from "as fast as possible".
Loop Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0–5 min | Scoping questions: limit by what? how exact? bursts? what if the limiter is down? | Restate the Round 1 design in 60 seconds | Restate the Round 2 design in 60 seconds |
| 5–15 min | Requirements + API (429, Retry-After, which headers are standard) | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Steps 1.0–1.6: local counts → shared store → token bucket → atomic script → fail open vs fallback → Retry-After → TTL | Steps 2.1–2.6: multi-bucket script, live rules, leasing, failover storms, trusted IPs and layers, latency | Steps 3.1–3.6: regional shares, metering vs limiting, distributed attacks, load shedding, region loss, build vs buy |
| 40–50 min | Numbers (96 MB, one node) + trade-offs | Numbers (shards from ops, nodes from memory, the WAF bill) + trade-offs | Numbers, cost (edge vs limiter) + the three-tools table |
| 50–60 min | Failures + pillar check | Failures + pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint.
The Two Sentences That Matter Most
- Opening a round: "Before I pick an algorithm: what do we limit by, how exact must it be, are bursts allowed, and what should happen to the API when the limiter itself fails?"
- When the scope is raised: "Here's what breaks in the current design, and here's the order I'll fix it in, starting with anything that could take the API down or let an attacker through."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What happens when the limiter's cache goes down?" (REL 5) | A 3 ms timeout and a circuit breaker get it off the path; capacity limits fail open with an alarm, security limits fall back to local limits. | 1–2 | Step 1.4, step 2.4 |
| "What happens when an AZ goes down?" (REL 10) | Every shard keeps a primary (replicas are in other AZs), and 16 gateways carry peak at 87%. | 2 | R2.8 | |
| "How do you enforce one limit across regions?" (REL 10) | Regional shares, rebalanced from each region's own usage item every 5 s, never a cross-region call. | 3 | Step 3.1 | |
| "What if a whole region goes down?" (REL 13) | Traffic moves by Route 53; its share moves to the survivors once staleness and health checks agree; its usage waits in its stream. | 3 | Step 3.5 | |
| "How do you protect a backend that's struggling?" (REL 5) | Adaptive concurrency limits and priority shedding at the service, with 503 and retry budgets. | 3 | Step 3.4 | |
| Performance | "How do you keep the limiter under 2 ms?" (PERF 1) | One round trip per request, all buckets in one script, hot keys leased into memory, and the limiter's own P99 measured. | 2 | Step 2.6 |
| "How did you size the cache?" (PERF 5) | Shards from operations at 46% of an assumption we load-test; nodes from memory per primary. | 2 | R2.6 | |
| Cost | "What does it cost?" (COST 5) | About $265, $3,500 and $12,460 a month for the limiter (Round 3 including metering); the edge's per-request fees are far larger. | 1–3 | R1.7, R2.6, R3.6 |
| "Should we just use API Gateway or WAF?" (COST 11) | Buy the edge; they can't do weighted global tenant limits or billing-grade quotas, so we build that part. | 3 | Step 3.6 | |
| Operations | "How do you change a limit during an attack?" (OPS 6) | A versioned rule set that every gateway picks up within seconds, canaried normally, emergency-approved and expiring when urgent. | 2–3 | Step 2.2, R3.9 |
| "How do you know it's healthy?" (OPS 8) | Decision latency, fail-open rate, 429s per tenant and rule, share drift, metering lag, each with a first action. | 2–3 | R2.10, R3.9 | |
| Security | "Can an attacker get around your IP limit?" (SEC 5) | Not by forging headers: we trust only CloudFront's viewer address and admit only CloudFront at the origin. | 2 | Step 2.5 |
| "What about an attack from 100,000 IPs?" (SEC 10) | Limit on signals attackers can't rotate cheaply (failure ratio, device tokens), with Bot Control and ATP on login, and challenge instead of lockout. | 3 | Step 3.3 | |
| Sustainability | "Where does this system waste resources?" (SUS 3) | Refused floods that reach compute, and per-request events; we refuse at the edge and batch metering per gateway. | 2–3 | Step 2.5, R3.6 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| Algorithm | Compares the five algorithms; chooses the token bucket; proves capacity + rate × window. | Several buckets per request, all or nothing, with weighted costs. | Knows which problems are rate limits, which are quotas and which are load shedding. |
| Shared state | One shared store; atomic script; the store's clock; TTL ≥ capacity ÷ rate. | Cluster mode with hash tags; leasing with a proved overshoot bound; shards from operations. | Regional shares with stated convergence; one writer per global-table item. |
| Failure | Timeout and breaker; decides fail open vs fallback per limit. | Failover as a load event: multiplexing, per-shard breakers, jitter. | Region loss and sync partitions decided in terms of the customer's contract. |
| Rules and change | Limits in config. | Versioned, monotonic, validated, canaried rule sets; no counter resets. | Emergency overrides with approval and expiry; adaptive rules with hysteresis. |
| Security | A login limit that never fails fully open. | Trusted client IPs; layers from edge to service. | Distributed attacks handled with signals, managed rules and challenges. |
| Well-Architected trade-offs | Sizes one node from the memory math. | Finds the edge fee is the biggest cost. | Build vs buy by capability; decides which traffic needs which inspection, with numbers. |
| Evolving under new scope | Builds from local counts, one problem at a time. | Opens with "what breaks"; fixes correctness and failure before speed. | Evolves the design and the company's rules: what bills, what's global, what's bought. |