R17 — Infrastructure design walkthroughs: rate limiter, API gateway, scheduler, key-value store

This is where the pack stops building mechanisms and starts using them. R0 to R16 built the general tools. R17 to R22 use those tools on general design questions, and R23 and R24 prepare the low-level design round. After that, R25 to R28 turn to the payments domain and the payments round.

A senior design interview rarely stays inside payments. At some point — often as a warm-up, or to see how deep you can go — the interviewer asks you to design a piece of infrastructure: a rate limiter, an API gateway, a job scheduler or a key-value store. None of these needs new theory. Each one reuses tools from R2 to R16 under tight constraints: idempotency, leases and fencing, consistent hashing, quorums, circuit breakers, the outbox. You are graded on whether you reach for the right tool and can explain why.

Like every walkthrough in the pack, including the payments round in R28, these are written as transcripts — conversations between an interviewer and you. That is because the real test is in the follow-up questions: the pushback on your algorithm, the dependency that fails, the moment you say "I would not build that, I would use this instead".

How to use these walkthroughs

Read each walkthrough once, for the flow of the conversation. Then re-read the lesson blocks that follow each exchange. Each block says what the interviewer was testing, explains the idea in a few sentences, and gives you one line to remember. Where an idea is already taught elsewhere in the pack, the block points there instead of teaching it again. All numbers are assumptions chosen to make the arithmetic easy to follow, not measurements of a real system.

Every walkthrough here, and the payments designs in R28, follows the same 45-minute shape: agree the scope, do the arithmetic, design the normal path, design the data model, and then spend most of the time on what happens when things fail.

Walkthrough What it really tests The line to say
Rate limiter Making a small decision on every single request without making requests slower or the system less available "Being exact is the expensive part; I'll trade a small, limited overshoot for speed and availability"
API gateway Separating the part that handles requests (data plane) from the part that manages configuration (control plane), and keeping anything slow off the request path "The control plane can fail; the data plane must not notice"
Distributed scheduler Time, ownership and failure — running each job once and never losing one, across machines that can die "A lease is a promise; the fencing check at the database is what enforces it"
Key-value store Splitting data, copying it, agreeing on reads and writes, resolving conflicts and repairing itself — all as one system "Quorums give overlap, consensus gives order — and anything that needs compare-and-set needs order"

Walkthrough 1 — Rate limiter, distributed across a gateway fleet

Interviewer: Design a distributed rate limiter.

You: Before drawing anything, I want to pin down the scope. "Rate limiter" can mean a small library inside one service. It can also mean a platform that protects hundreds of APIs. These are the questions that change the architecture:

# Question Why it matters
1 Is it a shared service for many APIs, or a limiter inside one service? Decides placement (gateway or library) and whether we need rules for many tenants
2 What do we limit on — user, API key, IP, endpoint, tenant tier, or combinations? Decides how we build keys and model rules
3 How strict? Is a brief 1–5% overshoot acceptable? Exact global limits cost latency; approximate limits allow local caching
4 How much traffic, and how many distinct callers? Sets the size of the counter store
5 One region or several? Global or per-region limits? Global limits need async sync or an accepted overshoot
6 If the limiter is down, do we allow or block? Fail-open (let traffic through) vs fail-closed (block it) is a business decision
7 Do we need to tolerate bursts? Points towards a token bucket — a counter that refills at a steady rate and lets bursts through up to a cap
8 How fast must a rule change take effect? Decides how rules are distributed and hot-reloaded

If you leave it to me, here is what I will assume, in plain terms:

Interviewer: You keep saying "approximate is fine". Why does exactness matter so much?

You: Because being exact is the most expensive thing you can ask of a rate limiter. To count perfectly, every gateway machine has to check one shared counter before letting a request through. There are three ways to arrange that counter, and each costs something different:

Option How it works Speed What can go wrong
Exact, one global counter Every request in every region updates the same counter Slow: 50–150 ms for a request to cross regions and back, on every API call If that counter is unreachable, every API in the world stalls
Exact per region Each region keeps its own counter Fast: under a millisecond, because the counter is nearby A caller who spreads traffic across three regions can get up to three times the limit
Approximate, with local caching Each gateway machine counts locally and syncs with the shared counter now and then Fastest A small, bounded overshoot while the machines catch up

My default is the middle option — a shared counter per region — and I switch only the very busiest keys to local counting, where each gateway takes a small batch of allowance and spends it locally. The reason this is acceptable: rate limits exist for fairness and protection, not accounting. Letting 102 requests through instead of 100 harms nobody. The exception is a quota that is also a bill — "you paid for 1 million calls this month" — which deserves stricter, slower counting.

What's being probed: whether you scope the problem before designing, and whether you can argue for a looser guarantee.

Concept — coordination costs latency. When many machines must agree on one number, they have to talk to each other. The tighter the agreement — exact, global, right now — the more round trips they need. A tighter agreement also means the system stalls more when a link fails. Rate limits are a natural place to relax. Relaxing lets each region, or each machine, decide alone most of the time. It is the same latency-versus-consistency dial as R0's CAP discussion, turned towards speed.

Takeaway: Buy speed and availability by accepting a small, bounded overshoot.

Interviewer: Requirements?

You: Functionally, the limiter must:

Non-functionally, I give each line a number, because each number becomes a decision later:

Out of scope:

Interviewer: Isn't this just load shedding with extra steps?

You: No. Rate limiting asks "is this caller over its contract?"; load shedding asks "is the system overloaded right now?" Rate limiting enforces per-caller fairness, even when the fleet is idle. Load shedding drops low-priority traffic, no matter who sent it. So one protects customers from each other, and the other protects the system from everyone. You run both. Load shedding is R14's territory.

Capacity

Interviewer: Size it.

You: My assumptions:

  1. Average: 10B / 86,400 ≈ 116K QPS (queries per second). Peak is ×4 ≈ 460K, so plan for 500K checks/s.
  2. The largest region takes half of that: about 250K script calls/s.
  3. A short Lua script on one Redis primary runs at roughly 50–80K calls/s, because Redis is single-threaded. At a conservative 50K, we need 5 primaries at minimum. So plan 8–10 per region, each with a replica.
  4. Memory: 50M keys × 120 B = 6 GB. This stays steady, because idle keys expire. That is tiny.
  5. Rules: 100K × 500 B ≈ 50 MB raw, about 150 MB once indexed. That fits in every gateway's heap, so rule lookup never leaves the node.
  6. Throttle events: if 2% are denied, that is 10K events/s × 300 B = 3 MB/s to Kafka. Easy.

Interviewer: Why not store every timestamp and be exact?

You: That is the sliding window log — it stores a timestamp for every request in the window. The arithmetic kills it. A 1,000/min limit holds up to 1,000 entries × ~60 B = 60 KB per key. For 10M heavy keys, that is 600 GB against 6 GB for token buckets. Therefore, token bucket.

The limiter is QPS-bound, not storage-bound. In other words, it runs out of operations per second long before it runs out of space. The hard parts are latency, hot keys and behaviour under failure.

What's being probed: whether you turn traffic into shard counts and find the real bottleneck.

Concept — throughput-bound vs storage-bound. The state here is just two numbers per caller. But it changes on every request. So the limit is operations per second per Redis shard, not bytes stored. Redis runs commands on one thread per shard. So throughput grows by adding shards, not by buying a bigger box. Every estimate should end in "therefore". For example: 600 GB versus 6 GB, therefore token bucket; 150 MB of rules, therefore cache them on every gateway.

Takeaway: Find the resource that runs out first, size for that one, and end every number with "therefore".

Sizing the Redis cluster from traffic — rule of thumb

  1. How many scripts run at once. Each primary runs one script at a time. So at any moment at most N token-bucket scripts are running, one per primary.
  2. How long one takes — two different numbers.

    • Execution inside Redis: about 10–20 µs. This is what keeps the primary busy.
    • What the caller sees: about 0.3–1 ms. Almost all of it is the network round trip. While one request is travelling, the primary is already running other callers' scripts.

    Do not size the cluster from the 1 ms the caller sees. That would give 1,000 calls/s per primary and a cluster about 50 times too big. Per-primary throughput is 1 ÷ execution time: about 50–80K script calls/s.

  3. Leave headroom. Run each primary at about 50–60% at peak, because waiting time shoots up as a single-threaded queue fills. Plan on about 30–40K calls/s per primary.

    Primaries ≈ peak checks per second in the busiest region ÷ ~40K, then add a replica for each.

  4. Memory — counters only. Rules are not in Redis: they live in the rules store and in every gateway's memory. Redis holds one small bucket per caller per rule: about 100–150 bytes including the key and Redis's overhead.

    Memory ≈ active callers × rules per caller × ~120 B. Count only keys active within the expiry window, since idle ones vanish. Double it for replicas and add some headroom.

  5. Which one wins. For a rate limiter, throughput almost always decides the cluster size, not memory. This design: 250K checks/s ÷ 40K ≈ 7, so 8–10 primaries — yet only about 6 GB of counters.

One key can never go faster than one primary. A single very busy caller is capped at that primary's 50–80K/s. Adding primaries does not help it — token leasing or splitting its bucket does.

API and data model

Interviewer: What does the API look like?

You: There are two surfaces.

A rule looks like this:

{
  "name": "pro-tier-payments-create",
  "match": { "tier": "pro", "method": "POST", "path": "/v1/payments" },
  "keyBy": ["apiKey"],
  "algorithm": "TOKEN_BUCKET",
  "limit": 100, "window": "1s", "burst": 200, "cost": 1,
  "mode": "ENFORCE",
  "failMode": "CLOSED"
}

We apply idempotency on create (R3) and optimistic concurrency on update, both to config. Idempotency means sending the same create twice has the same effect as sending it once. Optimistic concurrency means an update succeeds only if the version has not changed since you read it. The first stops duplicate creates. The second stops two operators silently overwriting each other.

A throttled client sees:

HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1790000001

Retry-After comes straight out of the token bucket: (cost − tokens) / rate. We round it up, because rounding down invites a retry that fails again. When several rules apply, the headers report the most restrictive one. SDKs add jitter (a small random delay), so retries do not all land on the same second.

Interviewer: Where does each piece of state live?

You: There are two very different kinds of data.

Entity Store Why
Rules, versions, audit PostgreSQL Precious data with few writes; needs transactions, history and rollback
Global rule version Postgres sequence + Redis key Lets gateways detect updates they missed
Token bucket state Redis Cluster hash, rl:{apiKey:ak_123}:post_payments → tokens, ts Atomic read-modify-write in under a millisecond via Lua; TTL cleans up
Sliding window counters Redis strings, one per window INCR + expire, two windows per key
Local bucket cache Gateway heap Hot-key leasing and fail-open fallback
Throttle events Kafka → ClickHouse High-volume appends for analytics and abuse detection

Counters go in Redis, not a database. We need 500K atomic updates a second at sub-millisecond latency. And losing a counter on failover only resets one caller's window. Not Memcached either, because it has no server-side scripting. Without that, there is no way to make a multi-step bucket update atomic.

Interviewer: Your key has curly braces in it.

You: Those are Redis Cluster hash tags. Redis Cluster spreads keys across 16,384 slots. Only the part inside {…} is hashed to pick the slot. So all of one caller's keys land on one shard. This matters because Redis only runs a multi-key Lua script when every key is in the same slot. As a result, one script call checks both of a caller's rules atomically.

The cost: a giant caller becomes a giant shard. I will come back to that. Every key gets a TTL (time to live — an expiry timer) of about twice its full-refill time. That is why 50M hourly keys need only 6 GB.

What's being probed: matching each store to its access pattern, and practical Redis Cluster knowledge.

Concept — durable config vs ephemeral counters. Rules are precious and change rarely. So they belong in a relational database with history. Counters are written on every request and are worthless after a minute. So they belong in memory with an expiry. Hash tags then let you choose which keys must live together, so they can change atomically. This is R10's partitioning trade-off in miniature: co-location buys atomicity but costs balance.

Takeaway: Precious data gets a database; hot disposable data gets memory with a TTL; co-locate what must change together.

High-level design

Interviewer: Draw it and walk me through an allowed request.

You: The design splits into two parts. The data plane runs on every request. The control plane is used rarely. It is easier to walk through than to describe. Follow an allowed request through the gateway filter to Redis and back. Then follow a throttled one. Then watch a rule change propagate.

The allowed request, step by step:

  1. The client sends GET /v1/orders with its API key. The edge applies coarse per-IP limits.
  2. The gateway authenticates the request and resolves tenant acme, tier pro.
  3. The limiter filter matches rules from its in-memory table, version 42. Two rules match: per-key 100 rps (requests per second) with burst 200, and per-endpoint 1,000 rps for the tenant.
  4. It builds both keys with the same hash tag and calls EVALSHA once.
  5. Redis runs the bucket script atomically for both keys. It returns allowed=1, remaining=41.
  6. The gateway sets headers from the most restrictive rule and proxies the request. Added latency: about 0.3–0.6 ms.

The throttled request: the script finds tokens < cost. It decrements nothing and returns a retry delay. The gateway returns 429, and the backend is never called. A throttle event goes to Kafka asynchronously.

Interviewer: Why the gateway? Wouldn't a sidecar or a library be cleaner? Before or after auth?

You: Each placement fits a different job, so I would layer them.

Placement Knows Use for
Client SDK Nothing trustworthy Courtesy backoff only — clients can be modified
Edge / CDN / WAF IP and ASN (the network the IP belongs to) — before auth Floods and coarse per-IP limits
API gateway filter API key and tier, after auth The default enforcement point for a public API
Sidecar (local + global rate limit service) Per-service policy Mesh environments and east-west (service-to-service) traffic
In-service library What the request means to the business "5 password resets per hour per user"

Enforce each limit at the earliest layer that knows enough to enforce it. On auth, I would do both. A cheap IP limit runs before auth, and it protects the auth service itself. The per-key limit runs after auth, because it needs the caller's identity.

What's being probed: the data plane / control plane split, and a defensible layered placement.

Concept — data plane vs control plane. The data plane handles every request, so it must be tiny: a local rule lookup, one Redis call, done. The control plane — rules, versions, audit — runs rarely and can be slow. So a slow admin database never slows an API call. And a rules-service outage only pauses edits. Walkthrough 2 builds an entire product on this split. R1 introduces the gateway's place in the architecture.

Takeaway: Keep the per-request path tiny; push everything else to the control plane.

Deep dives

Interviewer: Which algorithm?

You:

Algorithm How it works Memory per key Accuracy Bursts Use
Token bucket Every request has a cost in tokens. For each API key we store two values: the token balance after the last request and that request's timestamp. When a request arrives, we compute the current balance as the stored balance plus (now − last timestamp) × refill rate, capped at the bucket's capacity. We subtract the request's cost: if the result is zero or more, the request is allowed and the new balance is stored; if it would go negative, the request is denied and the caller is told to retry after (cost − balance) ÷ refill rate. Either way we store the current timestamp, and the whole calculation runs atomically in Redis, using Redis's clock. A key with no record starts with a full bucket O(1): tokens + timestamp Good Up to capacity Default — supports weighted cost and gives a natural Retry-After
Leaky bucket as queue Requests join a queue that drains at a fixed rate; when the queue is full, new requests are dropped O(queue) Exact output rate Smoothed out, but adds delay Shaping outbound traffic to a partner API
Fixed window counter One counter per caller per window ("100 per minute"), reset to zero when the next minute starts O(1) Poor at window boundaries Up to 2× across a boundary Internal tools only
Sliding window log Store a timestamp for every request, and count the ones inside the last N seconds O(limit) Exact None Tiny, high-stakes limits: "5 logins per 15 min"
Sliding window counter Keep this window's count and the previous window's, and blend them by how far into the current window you are — 25% in, with 80 last minute and 30 now, the estimate is 80 × 0.75 + 30 = 90 O(1): two counters Very good Smooth "N per hour/day" limits

O(1) means a small, fixed amount of memory per key, whatever the traffic. R14 teaches the mechanics of token and leaky buckets and of the window algorithms. Here, the decision is per use case:

Interviewer: A fixed window is one INCR. Why not ship that?

You: The problem is the boundary. Say the limit is 100 per minute. A client sends 100 requests at 12:00:59 and 100 at 12:01:00. That is 200 in two seconds. Also, every client's window resets at the same instant, so retries pile up on the minute.

The sliding window counter keeps O(1) memory and fixes this. It blends the previous window's count with the current one. For example: the previous window had 80, the current one has 30, and we are 25% into the current window. That gives 80 × 0.75 + 30 = 90, so we allow the request.

And if fixed windows are used anywhere, INCR and EXPIRE must run in one script. Otherwise a crash between them leaves a key that never expires, and the caller stays blocked for ever.

Interviewer: Fifty gateways hit the same bucket. What stops them all spending the last token?

You: A GET followed by a SET races: both gateways read one token, and both allow the request. WATCH/MULTI/EXEC is atomic, but retries explode on hot keys. So I move the decision to where the data lives and run it as one indivisible step. That step is a Lua script, which Redis executes as a single command on the shard.

-- KEYS[1] = bucket key; ARGV = capacity, refill rate (tokens/s), cost
local capacity, rate, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local t   = redis.call('TIME')                         -- server time: no gateway skew
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)

local s      = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(s[1]) or capacity
local ts     = tonumber(s[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - ts) * rate / 1000)  -- lazy refill

local allowed, retryMs = 0, 0
if tokens >= cost then tokens = tokens - cost; allowed = 1
else retryMs = math.ceil((cost - tokens) * 1000 / rate) end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate * 1000) * 2)       -- idle keys vanish
return { allowed, math.floor(tokens), retryMs }

Load it once with SCRIPT LOAD. Then call EVALSHA, and fall back to EVAL on NOSCRIPT. Keep the script short, because a slow script blocks the whole shard. Spring Cloud Gateway's RedisRateLimiter is publicly documented as using exactly this kind of Lua token bucket.

Interviewer: The script is atomic. What else can go wrong?

You:

Race Effect Fix
Multi-rule partial consume Rule A is decremented but rule B denies, so a denied request still eats budget Check all, then commit all, in one script on one slot
INCR succeeded, EXPIRE never ran The caller is blocked for ever on a fixed window Both in one script, or SET key 0 EX ttl NX then INCR
Clock skew between gateways Wrong refill Use Redis TIME as the single clock
Redis failover loses the last writes Some callers get extra tokens Accept it — bounded to one window
Rule changes mid-window Stored tokens exceed the new capacity Pass capacity and rate as arguments; min() clamps
Retry storm after 429 Retries arrive in sync Retry-After plus jitter; token bucket has no hard reset

What's being probed: the read-modify-write race, and the partial-failure and clock traps beyond it.

Concept — atomic read-modify-write. Any "read, decide, write back" sequence is unsafe when two actors run it at once. Redis runs one command at a time per shard, and a Lua script counts as one command. So nothing can slip in between its read and its write. When one request touches several counters, the same unit must check all of them before committing any. This is the all-or-nothing guarantee R2 discusses. Here it is cheap, because everything sits on one shard. Finally, let one authority — the Redis server — tell the time.

Takeaway: Move the decision to the data, check all then commit all, and let one clock decide.

Interviewer: How do you stay under 1 ms at p99? And one tenant just went to 300K requests a second.

You: Here is the budget:

That is about 0.3 ms at p50 (the median). To keep it there:

The limiter must never add 100 ms.

The hot key — one tenant on one shard

The 300K tenant is the real problem. All its keys share a hash tag, so they land on one shard. That shard tops out at 50–80K calls a second. So it melts, for every tenant on it. The fix is a hybrid of local and global counting:

What's being probed: latency awareness, and handling a hot key without giving up correctness entirely.

Concept — token leasing. Think of a bank. Instead of asking the central counter for every token, each gateway withdraws a roll and spends from its own till. The centre sees one withdrawal per roll. Tokens sitting in tills are your overshoot. That overshoot is bounded by roll size times the number of tills. This is R10's hot-key problem: hashing spreads keys, but not the load on a single key. Here it is solved by batching the coordination rather than splitting the data.

Takeaway: Lease tokens in batches to trade a bounded overshoot for a cooler shard.

Interviewer: An operator raises a tenant's limit. How does every gateway know within five seconds?

You:

  1. The operator sends PUT /v1/rules/r_9f2c with If-Match: "v41".
  2. The rules service validates it. It then writes version 42 plus an audit row in one Postgres transaction.
  3. After the commit, it publishes rules.version=42 via a transactional outbox — the event is saved in the same transaction and sent afterwards. So the publish cannot be lost. This is exactly R4's pattern.
  4. Each gateway fetches the changes since version 41 and builds a new immutable rule table. It then swaps an AtomicReference to point at the new table. There are no locks on the hot path.
  5. The next request uses the new limit. Bucket state in Redis is kept. The new capacity applies on the next refill, because capacity is a script argument.
  6. A gateway that missed the push catches up on its 30-second poll. Target: 99% within 2 s, all within 5 s.

Matching uses a trie or hash map on (tier, method, pathPattern). So it costs O(path segments), not O(rules). All applicable rules apply, and tenant overrides carry higher priority. The IP comes only from the hop our own edge wrote into X-Forwarded-For.

Safety measures:

Walkthrough 2 develops the immutable swap fully.

Interviewer: Three regions, a 1,000 rps limit. Per region or global?

You: Per region by default, with each region's limit sized to its share. GeoDNS pins most callers to one region anyway. Global synchronous coordination adds 50–150 ms and a single point of failure, so I would almost never use it.

For contractual daily or hourly quotas, I use local enforcement with async reconciliation. Each region broadcasts its counts every second and subtracts the other regions' usage. So the error is about one second of traffic. That is noise against 86,400.

If a contract demands an exact global limit, I route that tenant to a home region. That is better than putting the speed of light on every request.

Failures and scale

Interviewer: A Redis primary just died. What happens to API traffic?

You: This is the failure to get right, because a limiter outage must not become an API outage.

  1. EVALSHA times out at 5 ms, or the breaker for that shard is already open.
  2. Rules with failMode=OPEN fall back to the local bucket at globalLimit / gateways, with a small multiplier. The current gateway count comes from service discovery.
  3. Rules with failMode=CLOSED — login, OTP, transfers — return 429 or 503 rather than allow unlimited attempts.
  4. limiter_degraded metrics fire. We page someone if it lasts a minute.
  5. Redis promotes the replica within seconds. The breaker half-opens, probes, then closes. Lost counter writes only hand a few callers a fresher bucket.

If the whole cluster is down, we use local-only enforcement everywhere, and edge limits still protect the backends.

What's being probed: a designed degraded mode, with fail-open versus fail-closed as a per-endpoint business decision.

Concept — fail-open vs fail-closed. When a check cannot run, you have two choices. You let traffic through, which protects availability. Or you stop it, which protects security. A product listing fails open. Login and money movement fail closed, because unlimited password guesses or unlimited transfer attempts are worse than a brief outage. R14's circuit breaker makes either choice fast, instead of waiting on timeouts.

Takeaway: Decide per endpoint what "broken" means, and fail fast either way.

Interviewer: Other bottlenecks?

You:

The question for every box is "if this disappears, what still works?" That is R13's static stability.

Curveballs

Interviewer: An attacker sets X-Forwarded-For to a random IP on every request.

You: We key on the address our own edge appended — the last trusted hop. We never use the client-supplied value. And once a caller is authenticated, I would limit by API key. IP is for unauthenticated endpoints like login.

Interviewer: Simple floods are caught. Credential stuffing (trying stolen username and password pairs) across thousands of IPs?

You: The limiter sees every decision, so it becomes a sensor. Throttle and allow events stream to Kafka. An abuse detector aggregates them by key, IP, ASN and endpoint. It spots what no single rule sees. For example: many API keys from one IP, or a high login failure ratio across an ASN.

It then creates a temporary block rule through the rules service. That rule reaches every gateway in seconds, through the normal path. Observe, decide, enforce, observe again.

What's being probed: trust boundaries, and whether you see the limiter as part of a feedback loop.

Concept — trust boundaries and closed-loop protection. Anything a client sends can be forged. That includes headers that claim to describe the client. Only what your own edge wrote is trustworthy. Static limits get learned and evaded. So you aggregate decisions over time and feed the conclusions back as rules. That closes the loop, and it reuses the rule pipeline you already built.

Takeaway: Trust only what your edge wrote, and turn limiter decisions back into rules.

Quick reference

Decision Choice Why
Placement Gateway filter, plus edge and in-service Central, and knows key and tier; each layer enforces where its information appears
Algorithm Token bucket; sliding window counter for long windows O(1), allows bursts, weighted cost, natural Retry-After
State Redis Cluster + Lua, hash tags Sub-ms, atomic, TTL; one script per request
Consistency Approximate, with bounded overshoot Latency and availability matter more
Failure Fail-open by default, fail-closed on login and money movement The limiter must not cause outages
Hot keys Local leasing hybrid Removes the shard hotspot
Multi-region Per-region limits, async global quotas No cross-region hop
Rules Postgres + outbox, push + poll + snapshot, atomic swap Never lost, never half-applied

The 60-second version

Limits are enforced in layers. Coarse per-IP limits run at the edge. Per-key and per-tier limits run as a gateway filter after authentication. Business limits run in the service.

Rules live in Postgres, versioned and audited. They are published through an outbox and swapped atomically into every gateway's memory.

The default algorithm is a token bucket: two numbers per key, burst-friendly, weighted cost and a natural Retry-After. Buckets are held in a sharded Redis Cluster. Each check is one atomic Lua call of about 0.3 ms. Hash tags keep a caller's keys on one slot. At 500K checks a second, that is 8–10 primaries per region and a few gigabytes of memory.

Hot keys lease batches of tokens to the gateways. This trades a bounded overshoot for a cool shard. If Redis fails, most endpoints fail open to local buckets, while login and money movement fail closed. Regions enforce locally, with async reconciliation for global quotas.

Building it on AWS: API Gateway, Lambda and ElastiCache

The design above is cloud-neutral. If the platform runs on AWS, most of it maps onto managed services, and the first two layers need no code at all.

What the design needs On AWS Code needed
Per-IP flood protection at the edge AWS WAF rate-based rule None
A token bucket per API key and pricing tier API Gateway usage plans, plus method throttling None
Per-user, per-endpoint and combined rules, weighted costs, fail-closed for money A Lambda authorizer that runs the Lua token bucket in ElastiCache (managed Redis or Valkey) A small function and one script
Rules that are versioned, validated and changed without a redeploy AWS AppConfig, read through its Lambda extension and cached in the function's memory A JSON document and a few lines to read it
A 429 with Retry-After A gateway response that turns the authorizer's Deny into 429 Configuration only
One request through the AWS rate-limiting layers
flowchart LR
  C["Client"] --> W["AWS WAF<br/>per-IP rate rule"]
  W --> G["API Gateway (REST)<br/>usage plan + API key"]
  G --> A["Lambda authorizer<br/>our rules"]
  A <--> R[("ElastiCache<br/>Redis / Valkey<br/>counters")]
  P["AppConfig<br/>rules"] -.->|polled, cached in memory| A
  A -->|Allow| B["Backend"]
  A -->|Deny| X["429 + Retry-After"]

Use the built-in limits first, and add the Redis authorizer only for the rules they cannot express.

Turn off authorizer caching for the rate limiter. API Gateway normally reuses an authorizer's answer for a few minutes. A cached "Allow" lets requests through without being counted, so the rate-limiting authorizer must run with a cache time of zero — and that means its latency is added to every request.

Step through the build below. Each step shows the explanation on the left and the code or configuration on the right — highlighted lines are the ones the step is about. It goes from the plan, through the built-in throttling, the ElastiCache store and the Lua script, the rules in AppConfig, to the Lambda authorizer, wiring it to the API with a proper 429, and testing it.

Running this in production


Walkthrough 2 — API gateway, as a multi-tenant platform

Interviewer: Design an API gateway.

You: First, what kind of gateway? These are the questions whose answers change the diagram:

# Question Why it matters
1 Are we building the gateway product itself, or one gateway deployed for one company? A product needs tenants, usage plans and a self-service control plane
2 North-south traffic only, or east-west too? East-west traffic belongs to a service mesh
3 Which protocols — REST, gRPC, WebSocket, SSE? Long-lived connections change capacity planning and deploys
4 What is peak traffic, and what is the gateway's own latency budget? It decides whether we can afford a remote call per request
5 Do we only validate tokens, or issue them too? We validate; an identity provider issues
6 Do we aggregate calls, or only proxy them? Aggregation brings fan-out, partial failure and memory pressure
7 How fast must config change, and how many routes are there? It decides the push mechanism and the in-memory table size
8 Single region, or active-active across regions? It affects global limits and config replication

Interviewer: A platform used by many teams and external customers. Multi-region. Pick numbers.

You: Then here are my assumptions:

And it does not handle service-to-service traffic. East-west traffic wants a proxy next to each service. That is what a service mesh's sidecars give you — a sidecar is a small proxy running beside each service, handling mTLS (both sides prove their identity), retries and telemetry between services. A central gateway in that path adds an extra hop to every internal call. It also becomes everyone's bottleneck. Gateway for the edge, mesh for the inside, with clear ownership of each.

What's being probed: scoping an infrastructure component, and knowing where a gateway ends and a mesh begins.

Concept — north-south vs east-west. Traffic arriving from clients needs edge concerns. These are public TLS, API keys, quotas for paying customers, and WAF rules (a web application firewall that blocks known attack patterns). Traffic between services needs fast mutual trust instead, and it is far larger in volume. If you mix the two, the front door becomes a chokepoint for every trip down the hallway. R1.3 places the gateway among the building blocks; this walkthrough builds it.

Takeaway: Ask the questions that change the diagram, and keep the gateway at the edge.

Interviewer: Requirements.

You: Functionally, most of the work is plugins in a chain. Each request passes through them in order:

Non-functionally, the targets are:

Out of scope: issuing tokens, the mesh, CDN-scale caching, the developer portal and billing invoices. We only emit usage records.

Two lines drive the whole design: p99 overhead under 10 ms, and the data plane keeps serving if the control plane is down. The first rules out a database lookup per request. The second forces a split into two parts. The data plane is the part that handles each request. The control plane is the part that stores, manages and distributes config.

Capacity

Interviewer: Traffic, node count, bandwidth.

You: One node has 8 vCPUs and runs non-blocking code. It handles 15K RPS at 60% CPU, with TLS, JWT and rate limiting included. So 600K peak needs 40 nodes.

Each region has three AZs (availability zones — separate data centres). Losing one zone must not overload the other two, so I multiply by 1.5, which gives 60. Then I add 20% for bursts. That comes to about 72 nodes, 24 per region, 8 per AZ.

Average traffic is 200K RPS. 200K × 86,400 seconds = 17.3 billion requests a day. Each request is about 2 KB in and 8 KB out, so peak is 6 GB/s globally. That is 1.2 Gbps per node, which is comfortable on a 10 Gbps NIC (network card). So CPU, not network, is the limit. Most of that CPU goes on TLS handshakes and signature checks. Here are memory, logs and metrics:

Item Arithmetic Result
Route table 10K × 2 KB 20 MB
API key cache 1M × 200 B, or an LRU cache of the hot 100K 200 MB or 20 MB
Introspection cache 500K tokens × 300 B 150 MB, bounded by token expiry
Local rate-limit counters 1M × 64 B 64 MB
Connection buffers 50K connections × 32 KB 1.6 GB
Access logs 17.3B × 1 KB, 5:1 compression ~3.5 TB/day; sample successes, keep all errors
Route metrics 10K routes × 20 series 200K series per region — never label by API key
WebSockets 2M connections / 50K per node 40 dedicated stream nodes, in a separate pool

The conclusion that matters: all hot-path config fits in memory on every node. The hot path is the code every request runs through. Because it all fits, "no remote call per request" is realistic.

What's being probed: whether estimates include failure headroom and end in decisions.

Concept — zone headroom and metric cardinality. If one of three zones dies, two zones must carry the load of three. So you provision 1.5× up front. Then you add burst room, because spikes do not wait for autoscaling.

Cardinality means how many distinct series a metric has. A time-series store keeps one series per combination of labels. So labelling by route is fine, but labelling by API key multiplies the count by a million. R15 covers why "who and which one" belongs in logs, and "how much and how fast" belongs in metrics.

Takeaway: Size for peak, multiply for a lost zone, add burst room — and every estimate should end in a decision.

API and data model

Interviewer: What APIs does this expose?

You: The data plane carries the tenants' APIs, plus health endpoints.

The control plane has an admin API. This is where tenants configure things:

{
  "name": "orders-get",
  "match": { "hosts": ["api.example.com"], "pathPrefix": "/v1/orders", "methods": ["GET"] },
  "upstream": { "service": "orders-svc", "timeoutMs": 800,
                "retries": { "attempts": 2, "on": ["connect-failure", "503"], "budgetPercent": 20 } },
  "plugins": [
    { "name": "jwt", "config": { "issuer": "https://idp.example.com", "requiredScopes": ["orders:read"] } },
    { "name": "rate-limit", "config": { "by": "consumer", "plan": "gold" } },
    { "name": "circuit-breaker", "config": { "failureRatePct": 50, "minCalls": 50, "openSeconds": 30 } }
  ]
}

Every change produces a new config version. Versions are immutable (never edited after creation) and always increase. Creates take an Idempotency-Key, so a retried create does not make a duplicate. Updates send If-Match with the version. If an editor is working from an old version, they get a 412 error. That stops them from silently overwriting a colleague's change.

Interviewer: Where does each piece of data live?

You:

Entity Store Why
Routes, services, plugins PostgreSQL Relational integrity, transactional updates, low write rate
Config snapshots Postgres + S3, keyed by version Immutable, so rollback is trivial; nodes boot from S3 if the control plane is down
Consumers and API keys Postgres, keyed by SHA-256 of the key; replicated to every node Never store raw keys; no DB call per request
Rate-limit counters Redis per region Atomic, has TTL (automatic expiry), and losing it is tolerable
Usage and quotas Kafka → ClickHouse Append-only, aggregated for billing
Access logs Kafka → OpenSearch hot, S3 Parquet cold Search for recent logs, plus a cheap archive
Audit log Append-only Postgres + S3 Records who changed what, and when
Secrets Vault / KMS Never in the config DB in plain text

API keys are long random strings, so a fast hash is enough. bcrypt is for low-entropy human passwords, which are easy to guess. The key is shown once because the platform genuinely cannot show it again — it only keeps the hash.

Interviewer: Postgres is right there. A primary-key lookup per request is cheap.

You: One lookup is cheap. 600K a second is not. Once you include waiting for a connection from the pool, it eats the tail of a 10 ms budget.

Worse, it makes the database a hard dependency of every API on the platform. A Postgres failover would become a platform outage. Keys change rarely, so they are replicated to each node through the same push channel as config. A revoked key reaches every node within the 5-second target.

What's being probed: availability coupling, not just latency.

Concept — hot-path dependencies multiply risk. Every remote system on the request path adds its own slow tail and its own failure rate. Their availabilities multiply. For example, three dependencies at 99.95% each give a combined availability worse than any one of them.

Moving read-mostly data into local memory removes both the latency and the failure mode. The price is a few seconds of staleness. This is R12's "should I cache this?" answered yes, with push invalidation (the source tells nodes when data changes).

Takeaway: Read-mostly data on the hot path is replicated to the node, not fetched per request.

High-level design

Interviewer: Draw it.

You: There are two planes:

Walk a request through it: DNS to a region, an L4 balancer to a node, the filter chain, then the upstream. Then watch a config change travel from the admin API to the nodes.

The pieces:

Here is one authenticated request, end to end:

  1. GET /v1/orders/42 with a bearer JWT reaches a node via DNS and the L4 balancer.
  2. TLS terminates, using session resumption to skip a full handshake. The node assigns X-Request-Id and starts a trace span from traceparent.
  3. The router matches against a precompiled table: first a host map, then a path trie, then method and header checks. If the route is unknown, it returns 404 without touching an upstream.
  4. The JWT filter reads kid (the key ID). It finds the key in the in-memory JWKS cache. It verifies the signature, exp, nbf, iss and aud.
  5. Authorisation checks orders:read against the route policy.
  6. The rate-limit filter takes a token from the local bucket for (consumer, route). If the bucket is empty, it returns 429.
  7. The balancer picks a healthy endpoint by least-requests or peak EWMA (a moving average of latency). It sends the request over a pooled mTLS connection and adds X-Consumer-Id.
  8. Response filters run. Logs and metrics go to Kafka asynchronously. Overhead: 1–3 ms.

In the common case, nothing in steps 2–7 leaves the node. The only exception is an occasional token lease from Redis.

What's being probed: a short request path, and knowing where each millisecond goes.

Concept — L4 vs L7, and compiled lookups. An L4 balancer forwards connections without understanding HTTP. The gateway is the L7 layer that reads paths, headers and tokens. So the managed piece stays simple and fast, and the logic lives in software you control. R14 covers balancing algorithms.

A trie is a tree that walks the path one segment at a time. So matching cost depends on the length of the path, not on the 10K routes.

Takeaway: Dumb and fast at L4, smart at L7, and every per-request lookup precompiled into memory.

Deep dives

Interviewer: Why separate the planes so strictly?

You:

Aspect Data plane Control plane
Job Process every request Manage, validate and distribute config
Traffic 600K RPS A few writes a minute
State In-memory snapshot only Postgres, S3, audit
Latency need Microseconds per filter Seconds is fine
If it fails Outage Config frozen; traffic keeps flowing
Scale unit Many stateless nodes Three replicas with leader election

Nodes keep the last applied snapshot in memory and on disk. If the control plane dies, they serve config that is stale but valid. This is called static stability. It is publicly described for Envoy's xDS, Kong's hybrid mode and in AWS's own guidance.

How should config reach the nodes? There are three options:

I take push, with S3 as the boot source and fallback. Reconnects are jittered (spread out by a random delay), so a control-plane restart does not cause a reconnect storm.

What's being probed: the static stability principle, and push vs pull.

Concept — static stability. A system is statically stable if it keeps working, unchanged, when a dependency goes away that it uses only for changes. The gateway needs the control plane to learn new config, not to serve traffic. So losing the control plane costs only the ability to change things. R13 and R14 use the same principle; here it is the founding decision.

Takeaway: The control plane can fail; the data plane must not notice.

Interviewer: What runs on each request, in what order?

You: Two rules. Cheap rejections come before expensive work. And each filter runs after the filter that gives it what it needs.

Order Filter Note
0 TLS, ALPN Session tickets, OCSP stapling
1 Request ID + trace context W3C traceparent
2 WAF, IP allow/deny, size limits Before parsing the body
3 Route match Return 404 early
4 Authentication Key hash, JWT, introspection, mTLS
5 Authorisation Scopes, tenant entitlement, cached policy
6 Rate limit / quota After auth, so it can limit by consumer; a coarse IP limit already ran at 2
7 Request transformation Headers, path rewrite, body mapping
8 Load balance + resilience Timeout, retry, breaker, bulkhead
9 Response transformation Strip internal headers, CORS
10 Logging / metrics Async — never block on export

JWT details:

Interviewer: A customer's laptop is stolen. Their JWTs are valid for an hour.

You: That is the weak spot. A JWT cannot be revoked locally, because the gateway never asks anyone whether it is still valid. There are three levers:

For the stolen laptop: use the deny-list now, and short lifetimes as the policy.

What's being probed: filter ordering by cost, and the stateless-token trade-off.

Concept — self-contained vs reference tokens. A JWT is like a ticket with the date printed on it. It can be checked without calling the box office, but a stolen one works until it expires. An opaque token is like a ticket number that the usher must look up. It is slower, but it can be cancelled instantly. Real systems keep tokens short-lived, so "until it expires" is a small window. They also add a small revocation list for emergencies.

Takeaway: JWTs trade revocation for speed; short lifetimes and a deny-list buy most of it back.

Interviewer: Why an event loop rather than thread-per-request?

You: A gateway mostly waits. Thread-per-request blocking (Zuul 1, or a servlet plus RestTemplate) needs one thread for each request in flight. So 10K slow upstream calls mean 10K threads, and the gateway stalls. An event loop uses one thread per core and juggles tens of thousands of connections. Java 21 virtual threads are viable for moderate QPS, but you need care around pinning (when a virtual thread gets stuck to an OS thread).

Here is the budget for the 10 ms p99:

The rules:

A gateway mostly waits; an event loop waits for thousands of requests at once, as long as nothing blocks it.

Interviewer: A plan says 100 RPS. You have 24 nodes in a region.

You: Walkthrough 1 is the full answer; here it is as a gateway plugin. Use a token bucket per (consumer, route), because "100 RPS, burst 200" is a token bucket. There are three choices for where the state lives:

onRequest(key):
  b = local[key]
  if b.tokens > 0: b.tokens--, return ALLOW
  if b.leaseInFlight: return b.failOpen ? ALLOW : DENY
  lease = redis.eval(TAKE_BATCH, key, batch=min(50, plan.rate/nodes))
  if lease == 0: return DENY (429)
  b.tokens = lease - 1; return ALLOW

Monthly quotas are counted from usage events. They are enforced with a pushed "over quota" flag. A lag of a few seconds is fine for a monthly limit.

Multi-tenancy is a hierarchy: tenant → consumers → keys → plan. Each tenant has a concurrency cap. Premium or noisy tenants get dedicated node pools.

Interviewer: Redis goes down. Open or closed?

You: Open for most plans — requests are allowed through. But each node keeps a local hard cap, so a flood cannot reach upstreams unchecked. Over-admission is limited to the leased tokens not yet used.

For a contractual limit, such as a payments API, I would make Redis-per-request a per-plan option and accept the extra half-millisecond. Fail closed on security, fail open on fairness. Authentication must never fail open. Rate limiting is not a security boundary.

Interviewer: The inventory service has a bad deploy and starts timing out.

You: A slow upstream can use up gateway resources. That failure can then cascade to unrelated routes. Naive retries make it worse, because they multiply load. So I use the layers from R14, configured at the gateway:

Mechanism Setting
Timeouts Connect 200 ms, per-try 800 ms, overall deadline passed downstream
Retries Idempotent methods only, connect failure or 503 only, max 2, jittered backoff
Retry budget Retries capped at 20% of active requests per upstream
Circuit breaker Opens at 50% failures over 50 calls, half-open after 30 s
Outlier detection Ejects a host after 5 consecutive 5xx errors, for 30 s, at most 50% of the pool
Bulkhead Max 200 concurrent requests and a bounded pool per upstream
Fallback Cached response, or a fast 503 with Retry-After
Load shedding Adaptive concurrency; drop low-priority traffic first

The incident, step by step:

  1. Calls start hitting the 800 ms timeout.
  2. Budgeted retries fire, but only on idempotent methods (ones safe to repeat).
  3. Outlier detection ejects the worst pods.
  4. The route failure rate crosses 50%, so the breaker opens. Requests now fail in microseconds instead of waiting.
  5. The fallback serves cached stock or a 503.
  6. The bulkhead has already kept event-loop threads and pools free for every other route.
  7. After 30 s, the breaker goes half-open and sends probes. It closes again on success.

The arithmetic to say aloud: if three layers each retry three times, one user request becomes 27 calls at the bottom. So retry at one layer only.

What's being probed: stopping one failing service from taking down the platform, without retries making it worse.

Concept — the resilience stack at the edge. Each mechanism has one job:

R14 teaches each mechanism. The gateway is where they are configured per route, once, for every team.

Takeaway: Timeouts, bulkheads, breakers, budgets — and retries at exactly one layer.

Interviewer: How does a change reach 72 nodes safely, and how do canaries work?

You: Config compiles into an immutable snapshot. It contains listeners, routes, upstreams, consumers and plugins. Nodes apply it atomically. They build the new structures off the request path, then swap one reference.

Endpoint updates change far more often, because pods scale up and down. So they travel separately as small deltas (only the changes). That way a pod restart does not rebuild the route table.

Treat config like a code deploy

  1. An engineer moves the orders-svc canary weight from 0% to 5%, sending If-Match: "v1842".
  2. The control plane validates the change: schema, route conflicts, and whether the upstream exists. It writes the change and the audit entry in one transaction. It compiles snapshot v1843 to S3.
  3. The rollout controller pushes v1843 to the canary node group first.
  4. Each node swaps atomically. In-flight requests finish on the old table. The node sends an ACK or NACK.
  5. After a bake period watching errors and latency, the controller pushes to the rest in waves — or pushes v1842 back.
  6. Disconnected nodes reconnect, report their version, and receive the latest.

The canary options:

Automated analysis compares v2 against v1 and rolls back on a regression. R16 is the general version of this.

In Spring Cloud Gateway, which I know well, the canary and resilience take a few lines:

@Bean
public RouteLocator routes(RouteLocatorBuilder rlb) {
  return rlb.routes()
    .route("orders-canary", r -> r.path("/v1/orders/**").and().header("X-Canary", "true")
        .uri("lb://orders-v2"))
    .route("orders-v1", r -> r.path("/v1/orders/**").and().weight("orders", 95)
        .filters(f -> f
          .requestRateLimiter(c -> c.setRateLimiter(redisRateLimiter()).setKeyResolver(consumerKey()))
          .circuitBreaker(c -> c.setName("orders").setFallbackUri("forward:/fallback/orders"))
          .retry(rc -> rc.setRetries(2).setMethods(HttpMethod.GET)
                         .setStatuses(HttpStatus.SERVICE_UNAVAILABLE)))
        .uri("lb://orders-v1"))
    .route("orders-v2", r -> r.path("/v1/orders/**").and().weight("orders", 5)
        .uri("lb://orders-v2"))
    .build();
}

Routes refresh at runtime through RefreshRoutesEvent, or through a custom RouteDefinitionRepository backed by the config store. A GlobalFilter with Ordered handles JWT and tenant headers.

What's being probed: whether config changes get atomic apply, staged rollout and automatic rollback.

Concept — atomic snapshot swap. Imagine a node editing its live table one entry at a time. It could serve a request using a route that points at an upstream not yet defined. Instead, build the whole table on the side and flip one pointer. Then nobody ever sees a mix, and rollback is just pointing back. Most outages come from changes, so canary waves with automatic rollback remove a major source of outages.

Takeaway: Build new config on the side, swap one pointer, roll out in waves.

Interviewer: Mobile wants one call for the home screen instead of three.

You: Simple generic fan-out, yes. The edge filters run once. Then users, orders and recommendations are called in parallel. Each call has its own timeout and breaker. The results are combined under a total deadline of 500 ms.

But client-specific business logic belongs in a BFF service owned by the client team (BFF means backend for frontend). Otherwise the gateway becomes a monolith that every team must change. If many clients want flexible response shapes, that points to GraphQL federation. I cap fan-out concurrency and body sizes, because aggregation is the main source of memory pressure.

Observability in one breath:

WebSockets authenticate on the upgrade request and run on a separate pool. On deploy, they drain using close frames and jittered reconnects.

Security:

Failures and scale

Interviewer: Where does it break?

You:

Bottleneck / failure Mitigation
TLS handshake CPU Session resumption, ECDSA certs, HTTP/2 connection reuse, offload if needed
Redis rate-limit hot keys Local leasing, sharding by consumer, fail open with a local cap
Control plane down Last-known-good snapshot in memory, on disk and in S3; only changes pause
Bad config pushed Validation, canary waves, ACK/NACK, automatic rollback
IdP / JWKS down Serve on cached JWKS, stale-while-error; extend the introspection cache; alert
Slow upstream Timeouts, bulkheads, breaker, outlier ejection, shedding
One AZ fails Health checks remove nodes; 1.5× headroom carries the load
Region fails Anycast or GeoDNS shifts traffic; regions are provisioned to absorb a share
Reconnect storm Jittered reconnects, deltas, a cap on concurrent full-snapshot sends
Log pipeline backpressure Ring buffer; drop on full and count the drops — a lost log line beats a lost request

Multi-region works like this:

If you lose the primary region, you promote a replica while the data planes keep serving. Rate limits are per region. Config RPO is seconds (RPO is how much recent data you can lose). Traffic RTO is under a minute (RTO is how long recovery takes).

Almost every "what if this disappears?" answer is the same: traffic keeps flowing, and something gets slightly worse. Only the stateless nodes and the balancer are truly critical. Those are spread across zones and regions.

Curveballs

Interviewer: Why build any of this? Why not a managed gateway?

You: For one team that needs a gateway, I would recommend exactly that. You get TLS, keys, throttling and logging with no fleet to run. In exchange, you accept vendor limits and per-call pricing.

Here we are the product. We have many teams and external customers. We need control over plugins and latency, and we run multi-cloud. At 17 billion requests a day, per-call pricing gets very expensive. So we self-host — but built on a proven proxy core like Envoy or Netty, never an HTTP proxy written from scratch.

What's being probed: build-versus-buy judgement, and whether you can argue against your own design.

Concept — build vs buy. Building gives you control and lower unit cost at very high scale. But it costs engineering time, and on-call duty for ever. Buying gives you speed, and someone else carries the pager. But you live with their limits and pricing. The deciders are scale, whether the thing is your product, and constraints like multi-cloud or compliance. Even when building, you assemble proven parts.

Takeaway: Buy unless it is your product or your scale makes renting too expensive; even then, build on proven cores.

Quick reference

Decision Choice Why
Runtime Non-blocking event loop (Envoy, Netty, Spring Cloud Gateway) Slow upstreams do not use up threads
Planes Strict data / control split, static stability A control-plane outage freezes config, not traffic
Config distribution Push over gRPC + S3 fallback, atomic swap, canary waves Seconds-level, versioned, ACKed, reversible
Tokens JWT verified locally; opaque + introspection where revocation matters No extra hop per request
Rate limits Local buckets leasing from regional Redis Within a few percent, at a tenth of the traffic
Resilience Timeouts, budgeted retries on idempotent calls, breakers, outlier ejection, bulkheads One failing upstream cannot sink the platform
Aggregation Simple fan-out in the gateway; BFF logic with the client team Keeps the gateway generic
Scope North-south only; mesh for east-west Clear ownership

The 60-second version

A stateless data plane handles every request from memory. A control plane manages config and is never on the request path. So a control-plane outage freezes changes but not traffic.

Nodes run a non-blocking event loop and an ordered filter chain:

All of this adds one to three milliseconds.

Config compiles into immutable, versioned snapshots. They are pushed over gRPC streams in canary waves and applied with an atomic swap. Each node ACKs, and bad changes roll back automatically. S3 is the boot source.

About 24 nodes per region, across three AZs at 1.5× headroom, sit behind anycast and L4 balancers. Traffic to upstreams uses mTLS, and each region is isolated from the others.


Walkthrough 3 — Distributed job scheduler, cron to execution

What a distributed job scheduler is

A distributed job scheduler runs jobs at their scheduled times — cron expressions, one-off times or fixed intervals — using a cluster of scheduler nodes rather than one server. It guarantees that each scheduled run fires once, close to its due time, even when scheduler nodes fail: each job has exactly one owning node at a time, the database refuses duplicate runs, and a dead node's jobs pass to another. If the machine executing a run fails, the run is retried, so execution is at-least-once and every run carries an idempotency key to make the retry safe.

It decides when jobs run, and makes sure each run happens once — never missed, never doubled — even when machines die.

Do not confuse it with a cluster scheduler (the Kubernetes scheduler, YARN, Borg), which decides where a task runs by matching its CPU and memory needs to a machine with room. "Design a distributed scheduler" can mean either, so ask. This walkthrough designs the time-based kind.

Interviewer: Design a distributed job scheduler.

You: "Scheduler" can mean very different systems. So I ask some questions first:

# Question Why it matters
1 Is this a multi-tenant platform (many teams share it) or one application? Decides isolation, quotas and fairness
2 Cron, one-off "at time T", or fixed-rate? Cron needs time-zone and calendar logic; one-off needs precise delayed firing
3 What does "run" mean — call an HTTP endpoint, put a message on a queue, or run our code? Tells us whether we own the worker fleet
4 How precise — within a second or within a minute? Shapes the design of the due-job finder
5 Is at-least-once acceptable if jobs are idempotent (safe to run twice)? Exactly-once is only possible end to end
6 Do jobs last seconds or hours? Long jobs need leases and heartbeats
7 Runs missed during an outage — run all, run once, or skip? Needs a catch-up policy per job
8 How long do we keep history and logs? Decides storage and tiering
9 Single region or multi-region? Decides ownership scope and disaster recovery (DR)

Interviewer: Multi-tenant internal platform. You pick the rest.

You: I will support three schedule types: cron with a time zone, one-off delayed, and fixed-rate.

There are three kinds of target: an HTTP callback, a message on a tenant's queue, or a container on our workers. All three are the same thing underneath — "dispatch a payload to an executor".

My other choices:

Interviewer: Why not crontab on a couple of servers?

You: One box is a single point of failure. If it dies, jobs silently stop. With two boxes, every job fires twice. Crontab also gives no retries, no history, no quotas, no per-job time zones, and cannot hold 20 million definitions.

The real problem is one logical clock, many machines, exactly one firing per time slot, and nothing lost when machines die. Every answer to that comes from R8's toolbox: a single leader, partitioned ownership, or the database as coordinator. I will start with the database as coordinator — the simplest correct design — and add machinery only where the numbers force it.

Interviewer: Requirements.

You: The functional requirements are:

The non-functional requirements are:

There are also two correctness promises that pull against each other:

Interviewer: Which is it — no double-firing, or duplicates allowed? Can't you promise exactly-once?

You: There are two layers.

The scheduler creates exactly one run per (job, scheduled time). A unique key in the database enforces that.

Execution of an arbitrary side effect cannot be exactly-once. For example, a worker calls an endpoint and the call succeeds. Then the worker dies before recording it. Now nobody can tell whether the job ran. That is R0's unknown outcome.

So I promise at-least-once plus a stable key. That is effectively-once if the target honours the key, because exactly-once is at-least-once plus idempotency at the receiver, which is R3's whole argument.

What's being probed: why exactly-once delivery is impossible for external side effects, and whether you state both correctness promises.

Concept — liveness vs safety in a scheduler. "Never lose" pushes you to retry. "Never duplicate" pushes you to hold back. The honest answer is to never lose, and to make duplicates rare, bounded and harmless. You do that with an idempotency key that the target checks. Writing this down as a requirement shapes every later choice.

Takeaway: Promise "never lost" and "rarely duplicated, always de-dupable" — explicitly.

Capacity

Interviewer: Size it.

You: My assumptions:

  1. Average: 50M / 86,400 ≈ 580 runs/s.
  2. Top of the minute: the busiest boundary holds 100K runs, mostly due in the first 5 s → 20K runs/s. That is a 35× peak-to-average ratio. It happens because people write 0 0 * * * far more often than 17 3 * * *.
  3. Write amplification (one action causing many writes): mark the run queued, mark it running, mark it finished, insert the next run row, plus a heartbeat or two. That is about 5 writes per run. At peak, 5 × 20K = 100K writes/s. This number, not storage, is what forces sharding.
  4. Storage: definitions take ~20 GB. History grows by 41 GB/day, so ~3.7 TB stays hot over 90 days. Over five years that is ~15 TB in Parquet. Logs at 500 GB/day go to object storage, never to the database.
  5. Due index: only the next 10 minutes need to be in memory. At the top of the hour that is up to ~3M entries × 100 B ≈ 300 MB.

Interviewer: Midnight UTC. Lots of tenants schedule "daily at 00:00".

You: Say that is 5% of daily runs: 2.5M. Firing them all in 10 s means 250K/s. Paying for that capacity for something that happens once a day is waste.

So I provision about 50K dispatches/s and let the backlog drain in about 50 s. High-priority jobs go first through reserved capacity. Low-priority jobs get a default jitter window that spreads them out.

My planning numbers: steady at ~600/s, with spikes of 20–50K/s lasting seconds.

What's being probed: finding the number that drives the design, and shaping load rather than buying capacity for it.

Concept — spike times amplification. Averages hide the real load. Multiply the peak by the number of writes each run causes, and you get the figure that decides sharding. A short, bounded backlog is fine if you decide who waits. Priority classes protect critical jobs. Jitter spreads out "around midnight" work. Think of boarding groups at an airport instead of a wider door.

Takeaway: Size for the spike times the write amplification, then shape the spike with priorities and jitter.

API and the job specification

Interviewer: Show me the API.

You: The API has these features:

The endpoints:

A job definition has five parts, each answering one question: who owns it, when it fires, what it triggers, how the scheduler should behave, and who hears about failure.

{
  "name": "nightly-settlement-report",
  "description": "Builds the previous business day's settlement report",
  "owner": { "team": "payments-ops", "oncall": "slack:#payments-oncall" },
  "labels": { "domain": "settlement", "env": "prod" },

  "schedule": {
    "type": "CRON",
    "expression": "30 2 * * *",
    "timeZone": "Asia/Kolkata",
    "startAt": "2026-10-12T00:00:00+05:30",
    "endAt": null,
    "skipCalendar": "IN_BANK_HOLIDAYS"
  },

  "target": {
    "type": "HTTP",
    "http": {
      "url": "https://reports.internal/v1/settlement-report",
      "method": "POST",
      "auth": { "secretRef": "reports-api-token" }
    },
    "payload": {
      "businessDate": "{{scheduledDate}}",
      "region": "IN",
      "format": "CSV"
    }
  },

  "execution": {
    "timeoutSeconds": 1800,
    "retry": { "maxAttempts": 5, "initialBackoffMs": 2000, "maxBackoffMs": 300000, "jitter": "FULL" },
    "concurrencyPolicy": "FORBID",
    "misfirePolicy": "FIRE_ONCE",
    "priority": "NORMAL",
    "idempotencyKey": "{{jobId}}:{{scheduledAt}}"
  },

  "notifications": {
    "onFailure": ["slack:#payments-oncall"],
    "onDeadLetter": ["pagerduty:payments-ops"]
  },

  "enabled": true
}
Block Answers Notes
name, owner, labels Who is this, and who do we call? name is unique per tenant. labels drive search, dashboards and cost reports
schedule When? type decides which other fields apply. startAt/endAt bound the job's life. skipCalendar skips bank holidays — common for payments jobs
target What, and with what data? type (HTTP, QUEUE or CONTAINER), its own block, and the payload the target receives
execution How should the scheduler behave? Timeout, retries, overlap rule, missed-run rule, priority and the idempotency key
notifications Who hears about failure? "An attempt failed" and "gave up, sent to the DLQ" are separate alerts
enabled Should it fire? false creates the job paused

The other schedule and target shapes:

{ "type": "ONE_OFF",    "at": "2026-10-06T14:30:00+05:30" }
{ "type": "FIXED_RATE", "every": "PT15M", "anchor": "2026-10-06T00:00:00Z" }
{ "type": "QUEUE",
  "queue": { "system": "KAFKA", "topic": "settlement.commands", "key": "{{jobId}}" },
  "payload": { "businessDate": "{{scheduledDate}}" } }

{ "type": "CONTAINER",
  "container": { "image": "registry.internal/recon:1.4.2", "args": ["--date", "{{scheduledDate}}"],
                 "cpu": "1", "memory": "2Gi" } }

every is an ISO-8601 duration — PT15M means 15 minutes. anchor fixes the grid, so runs land on :00, :15, :30 and :45 instead of drifting from whenever the job was created.

Shaping the specification

Build the payload from the scheduled time, never from "now". {{scheduledDate}} and {{scheduledAt}} are filled from the run's slot. If an outage delays the 2 October run until 3 October, it must still build the 2 October report — and a retry must send the same idempotency key. With "now", a late or retried run quietly does the wrong day's work.

The response adds the server-assigned fields: jobId, version: 1, status: ACTIVE and nextRunAt: 2026-10-11T21:00:00Z. The first run is 02:30 on Monday 12 October in Kolkata, which is 21:00 UTC on the previous day — and the API always answers in UTC.

Suppose a client's create request times out and the client retries. It gets the same jobId back, so there is no second nightly report.

Interviewer: What can a job actually trigger?

You: The scheduler only knows when. It holds no business logic. So every job carries a target: what to trigger, and with what data. At fire time the scheduler creates a run and hands the target to an executor — "deliver this payload here". The real work, such as building the settlement report, lives in the target system.

There are three kinds of target:

Target type What happens at fire time Who does the work How the scheduler knows it finished
1. HTTP callback A worker calls a URL on the team's service, with the payload and an Idempotency-Key header The team's own service The HTTP response: 2xx is success; 5xx or a timeout means retry. A long job answers 202 Accepted and reports back later through a callback
2. Message on a queue A message is published to the tenant's Kafka topic or SQS queue The team's consumers, whenever they read it Usually "done" once the message is safely published. To track the job itself, the consumer reports completion back
3. Container on our workers Our worker fleet runs the team's container image with the given arguments and limits Our workers The worker reports through Lease / Heartbeat / Complete; a reaper retries if heartbeats stop

Underneath, all three are the same operation: dispatch a payload to an executor. So the scheduler core stays identical whatever the job does.

Getting targets right

We do not run tenant code inside the scheduler process. Quartz does this in Java — a job is a class that runs on the scheduler node. That suits one application, but on a shared platform one tenant's bug or memory leak would stop everyone's scheduling. Tenant code runs only behind one of the three targets. AWS EventBridge Scheduler follows the same shape: a schedule plus a target such as a Lambda function, an SQS queue, a Step Functions workflow or an ECS task.

The simple design

Interviewer: Those are the targets. Before you shard anything — what is the simplest design that is correct?

You: One PostgreSQL database coordinates everything. There is no etcd, no Kafka and no in-memory timer. The key idea is to keep what a job is apart from each time it runs:

The simple scheduler — one database, four kinds of process
flowchart LR
  C["Client / team"] -->|create, edit, pause| API["Job API"]
  subgraph PG["PostgreSQL"]
    SD[("schedule_details<br/>definitions")]
    EX[("scheduled_execution<br/>one row per run")]
  end
  API -->|one transaction| SD
  API -->|first run row| EX
  RN["Runners × 2–3<br/>poll every second"] -->|claim due rows,<br/>insert the next row| EX
  WK["Workers"] -->|claim queued rows,<br/>lease, heartbeat, result| EX
  WK -->|payload + Idempotency-Key| T["Target<br/>HTTP / queue / container"]
  RP["Reaper"] -->|expired leases →<br/>RETRY_WAIT| EX
  EX -.->|lag, failures, DEAD| AL["Alerts"]
Component Job
Job API Validates the specification. Writes the definition and its first run row in one transaction. Applies edits, pauses and deletes to both tables
schedule_details The definitions: schedule, target, execution policy, status and version
scheduled_execution The runs: what is due, what is running, and the history. Its unique key refuses a second run for the same slot
Runners (2–3 copies) Every second, claim due rows and insert each schedule's next row. They decide when
Workers Claim queued rows, call the target, send heartbeats and record the result. They decide how it gets done
Reaper Finds runs whose worker lease expired and schedules a retry
Alerts Schedule lag, failures and DEAD runs, sent to the job's owner

The definition says what the job is; the waiting row is what makes it fire.

Interviewer: Show me the tables.

You:

CREATE TABLE schedule_details (
  id         BIGINT PRIMARY KEY,          -- the jobId in the API
  tenant_id  TEXT   NOT NULL,
  name       TEXT   NOT NULL,
  schedule   JSONB  NOT NULL,             -- type, cron expression, time zone, start/end
  target     JSONB  NOT NULL,             -- HTTP / queue / container + payload
  execution  JSONB  NOT NULL,             -- timeout, retry, misfire, concurrency, priority
  status     TEXT   NOT NULL,             -- ACTIVE | PAUSED | DELETED
  version    INT    NOT NULL
);

CREATE TABLE scheduled_execution (
  id               BIGINT PRIMARY KEY,
  schedule_id      BIGINT NOT NULL REFERENCES schedule_details(id),
  scheduled_at     TIMESTAMPTZ NOT NULL,  -- this run's slot, always UTC
  status           TEXT NOT NULL,         -- see the lifecycle below
  attempt          INT  NOT NULL DEFAULT 1,
  next_attempt_at  TIMESTAMPTZ,           -- when a retry may start
  worker_id        TEXT,
  lease_token      BIGINT NOT NULL DEFAULT 0,
  lease_expires_at TIMESTAMPTZ,
  UNIQUE (schedule_id, scheduled_at)
);

-- only rows waiting to fire are indexed, so polling stays fast however big the history grows
CREATE INDEX due_idx ON scheduled_execution (scheduled_at) WHERE status = 'SCHEDULED';

UNIQUE (schedule_id, scheduled_at) means the database itself refuses a second run for the same slot, however confused the runners get. It is the last line of defence against double-firing — and it gives every run a natural idempotency key, schedule_id:scheduled_at.

A run's life:

SCHEDULED ──► QUEUED ──► RUNNING ──► SUCCEEDED
    │                       │ ├─► failed, retryable ──► RETRY_WAIT ──► RUNNING (next attempt)
    │                       │ ├─► failed, last attempt or fatal ──► DEAD
    │                       │ └─► lease expired (worker gone) ──► RETRY_WAIT
    ├─► SKIPPED   (previous run still going, concurrency policy FORBID)
    ├─► MISSED    (slot passed during an outage, misfire policy)
    └─► CANCELLED (schedule edited, paused or deleted)

Interviewer: Walk one run through it.

You:

1. Create. The API writes the definition and the first run row in one transaction. A schedule never exists without its next run — the guarantees below explain why this matters.

BEGIN;
INSERT INTO schedule_details (...) VALUES (...);                     -- id 7
INSERT INTO scheduled_execution (schedule_id, scheduled_at, status)
VALUES (7, '2026-10-11 21:00Z', 'SCHEDULED');                         -- 02:30 Kolkata
COMMIT;

2. Claim and chain. Every second, each runner runs the same transaction. The next row is inserted when a run fires, not when it finishes, so a slow or stuck run can never delay or block the next one.

BEGIN;
  -- 1. take up to 100 due rows that no other runner holds
  SELECT id, schedule_id, scheduled_at FROM scheduled_execution
  WHERE status = 'SCHEDULED' AND scheduled_at <= now()
  ORDER BY scheduled_at LIMIT 100
  FOR UPDATE SKIP LOCKED;

  -- 2. hand them to the workers
  UPDATE scheduled_execution SET status = 'QUEUED' WHERE id IN (...);

  -- 3. chain each recurring schedule's next run (computed in code from cron + time zone)
  INSERT INTO scheduled_execution (schedule_id, scheduled_at, status)
  VALUES (...)
  ON CONFLICT (schedule_id, scheduled_at) DO NOTHING;
COMMIT;

FOR UPDATE locks the rows a runner takes. SKIP LOCKED makes other runners skip those rows instead of waiting, so each due row goes to exactly one runner. "Due" is judged by the database's now(), so the runners' own clocks never matter.

3. Claim the run. Workers find QUEUED rows — and RETRY_WAIT rows whose next_attempt_at has passed — the same way, with SKIP LOCKED.

UPDATE scheduled_execution
   SET status = 'RUNNING', worker_id = 'w-1',
       lease_token = lease_token + 1,
       lease_expires_at = now() + interval '30 seconds'
 WHERE id = :id AND status IN ('QUEUED', 'RETRY_WAIT')
RETURNING lease_token;          -- zero rows: another worker got it first

The lease says how long the worker may hold the run. The lease token is a fencing token: a number that goes up on every claim, so a write from an older claim can be recognised and refused. R8 teaches fencing; this is where it earns its keep.

4. Execute. The worker calls the target with the payload and Idempotency-Key: 7:2026-10-11T21:00:00Z, built from scheduled_at. A long job sends a heartbeat every 10 seconds: UPDATE … SET lease_expires_at = now() + interval '30 seconds' WHERE id = :id AND lease_token = :mine.

5. Record. UPDATE … SET status = 'SUCCEEDED' WHERE id = :id AND lease_token = :mine. A failure writes RETRY_WAIT with a backoff, or DEAD after the last attempt.

6. Reap. The reaper looks for status = 'RUNNING' AND lease_expires_at < now(). Each such run gets RETRY_WAIT, attempt + 1, a jittered next_attempt_at and a bumped lease_token, which makes the vanished worker's claim worthless.

Step through it below. Each tab is a scenario: the happy path first, then two runners racing, crashes, failing targets, edits and an outage.

Interviewer: Why do the workers read from the table? Wouldn't a queue be cleaner?

You: A queue is the next step up, but it adds a gap. Suppose the runner commits QUEUED and then crashes before publishing to the queue. The run is stranded: the table says queued, but no message exists. You would then need a sweeper that re-publishes rows stuck in QUEUED — the outbox pattern from R4. Reading from the table has no such gap. Every hand-off is a committed row, so whichever process dies, the next one carries on from the table.

Interviewer: State precisely what this design guarantees.

You: Four guarantees, each enforced in one specific place:

The four guarantees, and where each is enforced

  1. Atomic creation. A schedule's definition and its first run row are written in the same transaction.

    If they were separate, a crash between the two writes leaves one of two broken states. A definition with no run row is a schedule that never fires — and nothing looks wrong, because nothing is ever due. A run row with no definition is a run nobody can execute. One transaction makes both states impossible.

    The same rule covers every later change. Claiming a run and inserting the next one is one transaction; editing, pausing or deleting a schedule updates its waiting row in the same transaction. Together they keep one invariant: every active recurring schedule has exactly one waiting row.

  2. Exactly one run per slot. UNIQUE (schedule_id, scheduled_at) makes it impossible to create a second run for the same schedule and time, whatever the runners do.

  3. At-least-once delivery of each run. A run leaves the active states only by being recorded as SUCCEEDED by the worker that holds the current lease — or by reaching DEAD. If a worker dies, its lease expires and the reaper retries the run.

    "At least once" is bounded by the retry limit. After the last attempt the run is DEAD, the owner is paged and a human redrives it. The honest wording is "delivered at least once, or explicitly marked failed and escalated" — never silently dropped.

  4. Exactly-once effect of execution. Delivery can repeat — a worker can finish the work and die before recording it — so the effect must be made exactly-once at the receiver. Every attempt carries the same Idempotency-Key, schedule_id:scheduled_at, and the target stores the keys it has processed in the same transaction as the effect.

The receiver's side of guarantee 4:

BEGIN;
INSERT INTO processed_requests (idempotency_key, result)
VALUES ('7:2026-10-11T21:00:00Z', NULL)
ON CONFLICT (idempotency_key) DO NOTHING;   -- 0 rows: already done, return the stored result
-- ... build the report, move the money, send the file ...
UPDATE processed_requests SET result = ... WHERE idempotency_key = ...;
COMMIT;

The scheduler cannot deliver exactly-once effect on its own. It provides at-least-once delivery and a stable key; the effect is exactly-once only if the target honours the key. When the target is our own database, the effect and the run's status can share one transaction — true exactly-once for that store. This is R3's inbox pattern.

Exactly one run per slot, delivered at least once, with an exactly-once effect — the last part is a contract the scheduler shares with the target.

What's being probed: whether the simplest design is still correct under concurrency, and whether every hand-off is durable.

Concept — the chain of next runs. A recurring schedule is a chain: each run, as it fires, creates the next. The chain is only safe if creating the next link is atomic with claiming the current one — one transaction, both or neither. A unique key on (schedule_id, scheduled_at) makes each link impossible to duplicate. Locks with SKIP LOCKED let any number of runners and workers share one table as a work queue. Leases and tokens make a vanished worker's run recoverable and a zombie worker harmless.

Takeaway: Claim and chain in one transaction, let a unique key refuse duplicates, and make every hand-off a committed row.

Scenarios — what can happen while it runs

Interviewer: Now break it. What can go wrong while it is working?

You: Here is each situation, what happens, and what protects us:

# Scenario What happens What protects us
1 Normal run Runner claims and chains; worker claims with a lease, calls the target, records SUCCEEDED —
2 Two runners poll the same instant Each gets different rows; nothing fires twice FOR UPDATE SKIP LOCKED; the unique key as backstop
3 Runner crashes before COMMIT Postgres rolls back: the row is SCHEDULED again, no next row exists. Another runner claims it about a second later Claim and chain in one transaction
4 Runner crashes after COMMIT The row is already QUEUED in the table; a worker claims it as normal Hand-off through the table, not a separate queue
5 Worker crashes mid-job Heartbeats stop and the lease expires. The reaper sets RETRY_WAIT and bumps the token; another worker retries Lease, heartbeat, reaper
6 Worker finishes the work but dies before recording it The run is retried, so the target receives it twice The same Idempotency-Key on every attempt; the target ignores the repeat
7 Worker pauses past its lease (a long GC pause), then wakes up Its completion carries an old token and matches zero rows Lease token (fencing)
8 Target is down or returns errors RETRY_WAIT with exponential backoff and full jitter; after the last attempt, DEAD and the owner is paged; an operator can redrive it Durable retries as rows; error classes (retry 5xx, 429 and timeouts; not validation errors)
9 A run lasts longer than its interval When the runner claims the next row, the concurrency policy decides: FORBID marks it SKIPPED, ALLOW runs both, REPLACE cancels the old one. The chain continues Policy checked at claim time
10 Schedule edited while its next row waits One transaction bumps the version (with If-Match), cancels the waiting row and inserts one at the new next time. A run already in flight finishes with the definition it started with Waiting row updated atomically with the definition
11 Schedule paused or deleted Status changes and the waiting row is cancelled; with no SCHEDULED row, runners never see it. Resume inserts the next future slot No waiting row, nothing fires
12 All runners down for an hour Each schedule has exactly one overdue row, because next rows are created only at fire time. On recovery the misfire policy decides: FIRE_ONCE runs it once and records the gap as MISSED, FIRE_ALL catches up slot by slot up to a cap, SKIP jumps to the next future slot Misfire policy; payload and key still use scheduled_at
13 Manual "run now" The API inserts a run row for now() as QUEUED; workers pick it up. The concurrency policy still applies Same row, same lifecycle
14 The database fails over to its replica Open transactions roll back, as in scenario 3. With asynchronous replication the newest commits can be lost: a run that was claimed may come back as SCHEDULED and fire again Claim and next row were in one transaction, so the chain stays consistent; the idempotency key absorbs the repeat; a synchronous replica removes the loss
15 Runner machines' clocks disagree Nothing changes "Due" and lease expiry both use the database's now() — one clock judges
16 DST change day for a local-time schedule 02:30 may not exist, or may happen twice The next slot is computed in the job's time zone with explicit rules — see the deep dive below

Every failure ends one of three ways: a little late, retried with the same key, or DEAD with someone paged — never silently lost, never knowingly doubled.

What's being probed: whether you can walk failures through the design instead of asserting it is "highly available".

Concept — failure walk-throughs. For each component, ask what happens if it dies before its commit, after its commit, or comes back after you thought it was dead. Transactions cover "before", committed rows cover "after", and fencing tokens cover "came back". Whatever is left — an action that happened but was never recorded — is handled by idempotency at the receiver.

Takeaway: Walk every component through "dies before commit, dies after commit, comes back late", and name the mechanism for each.

Shortcomings, and how to fix them

Interviewer: It is correct. Where does it fall short?

You: It is right for a single company's jobs or an internal platform up to roughly a thousand fires a second. Against our targets — 20K runs a second at the top of the minute — these break first:

# Shortcoming Why it hurts Fix, briefly
1 One database is the ceiling Each run costs about 5 writes. At the 20K/s spike that is 100K writes/s, far beyond one Postgres primary Shard both tables by schedule_id over several Postgres clusters, as 1,024 logical shards
2 Polling: up to a second late, and a hot index Every runner scans the same "oldest due" end of due_idx every second, and the top-of-minute burst lands on it all at once Each shard owner loads the next ~10 minutes into an in-memory timing wheel; a slow SQL sweep stays as the safety net
3 Runners compete for the same rows Adding runners adds lock contention, not throughput Partitioned ownership: each runner owns a set of shards through an etcd lease, and every transaction checks the shard's epoch (fencing)
4 Workers poll the database too The run table doubles as a busy queue under heavy writes Dispatch through Kafka or SQS; the QUEUED row stays the outbox, so a broker outage loses nothing
5 No fairness Rows are claimed oldest-first, so one tenant's two million midnight jobs delay another tenant's critical one Priority classes, per-tenant token buckets and concurrency caps, reserved capacity for high priority, default jitter for low priority
6 The chain is only as good as the code A bug that skips inserting the next row ends a schedule silently — nothing is due, so nothing looks wrong A reconciler: every ACTIVE recurring schedule must have exactly one future SCHEDULED row; repair and alert. Plus a per-job dead man's switch
7 History bloats the hot table Run rows pile up — 50M a day at our scale Partition scheduled_execution by day, drop old partitions after archiving, send run events to ClickHouse
8 Very frequent schedules A job every 5 seconds creates 17,000 rows a day A minimum interval (say one minute), or sub-minute timers kept in memory by the owning shard
9 One database, one region Asynchronous failover can re-fire recent runs; losing the region stops all scheduling A synchronous replica in-region; a warm standby region with controlled failover, or home regions per tenant

Start with the simple design, and add each fix only when a metric says you need it — schedule lag for the timing wheel, write load for sharding, lock waits for partitioned ownership, tenant complaints for fairness.

What's being probed: whether you can see your own design's limits and grow it step by step, rather than starting with the most complex version.

Concept — evolve, don't front-load. Each fix trades simplicity for capacity: shards bring routing and rebalancing, ownership brings a coordination service and fencing, a broker brings a second source of truth to keep in step. Interviewers reward a design that is correct first and then scaled by named bottlenecks, because that is how real systems are built and kept running.

Takeaway: Be correct with the fewest moving parts, then add machinery one measured bottleneck at a time.

Scaling up — the full design

Interviewer: We do need the 20K-a-second spike. Show me the scaled-up version.

You: It keeps the same two tables, the same lifecycle and the same unique key. What changes is who finds due rows, how they reach workers, and how much one database has to carry. These are the fixes from the table above, in detail.

In the scaled-up design, workers no longer write to the database directly; they report through a small Worker.Lease / Heartbeat / Complete RPC.

Interviewer: Why is there a leaseToken in the worker completion?

{ "executionId": 1042, "attempt": 2, "leaseToken": 4711,
  "outcome": "FAILED", "error": { "code": "UPSTREAM_503", "retryable": true } }

You: It is the same fencing token as in the simple design — a number that goes up each time someone new takes ownership, so old owners can be detected and rejected.

Every claim increments it. The server applies a heartbeat or completion only if the token still matches. So a worker that paused past its lease and then wakes up cannot overwrite the result of the worker that took over. R8 teaches fencing; this is where it earns its keep.

Interviewer: The data model.

You:

Entity Store Key Notes
schedule_details Sharded PostgreSQL, shard_id = hash(schedule_id) % 1024 id The definition and its version
scheduled_execution Same shard as its schedule, partitioned by day id; unique (schedule_id, scheduled_at) Status, attempt, worker, lease_token, lease_expires_at, next_attempt_at; index (shard_id, scheduled_at) WHERE status='SCHEDULED'
Attempts / events Kafka → ClickHouse execution_id, attempt Append-only history, kept off the hot path
Due index Redis sorted set per shard, score = fire time due:{shard} A cache of the next ~10 minutes; can be rebuilt
Shard ownership etcd /scheduler/shards/{id} Owner, lease, epoch as fencing token, TTL ~10 s
Dispatch Kafka topics per priority Keyed by tenant or schedule 3-day retention so messages can be replayed
Tenant quotas Config + Redis counters quota:{tenant} Token buckets (a rate limiter that refills at a fixed speed)
DLQ Kafka topic + status = DEAD execution_id Inspect and redrive (send again)

Both tables for one schedule live on the same shard, so claiming a run and inserting the next one is still a single local transaction. The unique key on (schedule_id, scheduled_at) is still the last line of defence against double-firing.

Interviewer: 100K writes a second. Why not Cassandra or DynamoDB from day one?

You: The hot path does three things, and they must happen atomically (all together or not at all):

SQL does that with a transaction, a unique key and SKIP LOCKED. A wide-column store needs a lightweight transaction or a conditional write for each step. That is slower and harder to combine.

The data is only 20 GB. So I shard for write throughput, not size. 1,024 logical shards over several Postgres clusters can handle 100K writes/s with batching. I would move to time-bucketed Cassandra or DynamoDB only if write volume or multi-region needs outgrew that.

What's being probed: whether keys and indexes encode the correctness rules, and whether you choose a store by the operations it must do.

Concept — natural keys and choosing by operation. A run is identified by which job and which instant. Making that the primary key makes run creation idempotent at the one place every writer meets. This is R3's idempotency boundary pushed into the schema. The store question is: which operations must be atomic, and which store makes them cheap? Size then decides how many shards you need, not which technology (R10).

Takeaway: Let a unique constraint be the last line against duplicates, and pick the store that makes your critical atomic step simple.

The scaled-up architecture

Interviewer: Draw it and walk a job firing.

You: The split is "deciding when" versus "executing". Every arrow between components is a durable hand-off, so nothing is held only in memory.

Walk a job from definition through the shard owner, the dispatcher and Kafka to a worker. Then kill a scheduler and watch its shards move. (This animation uses the older names: "jobs" is schedule_details and "runs" is scheduled_execution.)

The pieces:

A job firing:

  1. Scheduler S1 owns shard 37 at epoch 12. Every few seconds, it loads SCHEDULED rows with scheduled_at <= now() + 10 min into its wheel.
  2. At fire time, it pops due entries, up to 500 at once.
  3. It runs one transaction per batch, exactly like the simple runner plus one check: UPDATE scheduled_execution SET status = 'QUEUED' WHERE id IN (…) AND status = 'SCHEDULED', which is a compare-and-set; insert each schedule's next row with ON CONFLICT DO NOTHING; and check that the shard epoch is still 12.
  4. The dispatcher checks the tenant's bucket and concurrency count. It publishes to tasks.normal; the QUEUED row is the outbox, so if Kafka is down the publish is simply retried. If the tenant is over quota, it sets next_attempt_at a few seconds out instead of dropping the run.
  5. A worker consumes the message and claims the run:
UPDATE scheduled_execution
   SET status = 'RUNNING', worker_id = 'w-88',
       lease_token = lease_token + 1, lease_expires_at = now() + interval '30 seconds'
 WHERE id = :id AND status IN ('QUEUED', 'RETRY_WAIT')
RETURNING lease_token;
-- zero rows → another worker has it; ack and skip
  1. It calls the target with idempotency key schedule_id:scheduled_at and a per-attempt timeout. For long jobs, it sends a heartbeat every 10 s.
  2. On success, it runs SET status = 'SUCCEEDED' WHERE id = :id AND lease_token = :mine, emits RunSucceeded, and commits the Kafka offset.

One subtlety on create. A job due 30 seconds from now would sit invisible until the next refill. So when a new job's first run lands inside the lookahead window, the Job API also writes it straight into the due index. The periodic refill and a slow SQL sweep catch anything that slips through.

What's being probed: separating "deciding when" from "executing", with every hand-off durable.

Concept — the run row as the hand-off. The scheduler only decides that a run exists. Workers do the heavy, variable work. A durable queue sits between them, so a slow job never delays firing. Every step records its state before moving on. So whichever component dies, the next one resumes from the row. A QUEUED row is R4's outbox — the intent to publish, committed together with the state change.

Takeaway: The scheduler creates runs, queues and workers execute them, and the database records every hand-off.

Deep dives

Interviewer: How do you find which of 20M jobs are due each second, at p99 under 2 s, through the top-of-minute spike?

You:

Option How Strength Weakness
DB polling, FOR UPDATE SKIP LOCKED Pollers grab separate batches of due rows Simple and transactional; Quartz JDBC and db-scheduler work this way Hot spot on the index, lock churn, latency equals the poll interval
Time-bucketed partitions Rows keyed (minute_bucket, shard) Spreads writes; scans one partition Hot bucket at top of hour; late inserts
Redis sorted set ZRANGEBYSCORE due:{shard} -inf now O(log N), precise Limited by memory; not the source of truth
Hierarchical timing wheel Slots of seconds, minutes, hours; O(1) insert and tick Precise, cheap In memory only; one owner per shard

My choice has two levels:

while (ownsShard(shard, epoch)) {
    if (now().isAfter(nextRefill)) {
        executionRepo.findDue(shard, now().plus(LOOKAHEAD))
               .forEach(e -> wheel.addIfAbsent(e.id(), e.scheduledAt()));
        nextRefill = now().plusSeconds(5);
    }
    for (Entry e : wheel.tickAndCollectDue(now())) {
        fireInTx(shard, epoch, e.executionId(), e.fireAt());   // claim + insert next row, batched
    }
    sleep(Duration.ofMillis(100));
}

With batches of 500 per transaction, 50K fires a second becomes about 100 transactions a second across all shards.

Interviewer: That is a lot of moving parts. Why not keep the simple design's polling?

You: Under about 1K jobs a second, I would — that is exactly the simple design above. At 50K fires a second, three things start to hurt: the polling query, the hot end of the scheduled_at index, and the lock churn. Start with SKIP LOCKED, and add the wheel when the metrics say polling is the bottleneck.

What's being probed: standard timer structures, combining a fast path with a durable source of truth, and admitting the simple option is right at smaller scale.

Concept — timing wheels and SKIP LOCKED. A timing wheel is like a clock face made of buckets. Inserting a task drops it into slot (fireTime / tick) mod size in O(1). Each tick fires one slot. Stacked wheels move far-future tasks down to finer wheels as their time gets closer. Because the wheel forgets everything on restart, it is only a short-horizon cache over the database. SKIP LOCKED lets many pollers use a plain table as a safe work queue. That works until physics gets in the way, because every poll scans the same hot end of the index.

Takeaway: Database for truth, timing wheel for the next few minutes, sweep for safety — and SKIP LOCKED until load says otherwise.

Interviewer: Multiple scheduler nodes. How does each due job fire exactly once?

You: There are three options:

I take the third, with defence in depth (several layers of protection):

The failover, and the zombie

  1. S1 owns shards 0–127. It renews its lease every 3 s against a 10 s TTL (time to live).
  2. S1 crashes, or gets cut off from etcd by a network partition. Its renewals stop.
  3. After the TTL, etcd deletes its keys. Watchers see this.
  4. S2 acquires shard 37 at epoch 13.
  5. S2 rebuilds the due index from SQL. It does not blindly trust anything in Redis.
  6. Rows that fell due during the 10–15 s gap are simply overdue SCHEDULED rows. S2 fires them. For longer gaps, it applies the misfire policy.
  7. If S1 was alive but partitioned, its writes carry epoch 12. The DB rejects them. The unique key stops duplicate runs anyway.

Interviewer: If etcd already guarantees one lease holder, why the DB checks?

You: Because a lease only tells the holder what to believe. A process that paused past its TTL does not know its lease has expired.

A lease is a promise; the fencing check at the database is the enforcement. This is the argument in Martin Kleppmann's well-known analysis of distributed locks, and it is the core of R8. The unique key and the claim CAS are seatbelts, in case fencing itself has a bug.

What's being probed: that distributed locks are only advisory and must be enforced at the resource.

Concept — partitioned ownership plus fencing. Cut the work into many small shards. Give each shard one owner at a time, recorded in a consensus store. Many small shards make rebalancing smooth. The danger is the zombie: a node that froze, lost its lease, and wakes up still acting as owner. So every write carries its epoch, and the database refuses older ones.

Takeaway: Leases decide who should own work; fencing tokens enforce it where the data lives.

Interviewer: A worker takes a job and crashes halfway.

You: The rules first.

The crash:

  1. Worker w-88 holds run R at lease_token=5 and stops sending heartbeats.
  2. The reaper for R's shard selects status='RUNNING' AND lease_expires_at < now() FOR UPDATE SKIP LOCKED.
  3. R is on attempt 1 of 5. The reaper sets RETRY_WAIT, attempt=2, and next_attempt_at = now + backoff with full jitter. It also bumps lease_token to 6, which invalidates the old lease.
  4. The retry timer re-dispatches the run. w-91 claims it at lease_token=7.
  5. If w-88 wakes up and reports with token 5, its update matches zero rows. The idempotency key protects the target.
  6. At maxAttempts, the run goes DEAD and moves to the DLQ, and the tenant is alerted.

The states are the same lifecycle as in the simple design; only the hand-off from QUEUED to a worker now travels through Kafka.

Over a network, you cannot tell a dead worker from a slow one. So the lease sidesteps the question:

Interviewer: A downstream is down for ten minutes and 200K jobs target it.

You: Backoff is min(maxBackoff, initial × multiplier^(attempt−1)) with full jitter. Full jitter means a uniformly random wait between zero and that value. So when the downstream recovers, retries do not arrive in synchronised waves.

Retries are not sleeps inside workers. A failed run is written as RETRY_WAIT, and the same due-index machinery re-dispatches it. So workers stay free, and retries survive restarts.

Errors are classified:

A circuit breaker per target host pauses dispatch when that host fails more than half its calls in a minute. A circuit breaker is a switch that stops calls to a failing service for a while. This way we stop burning attempts. When the breaker half-opens (lets a few test calls through) and they succeed, the retries arrive spread out, not as a 200K wall. R14 covers backoff, jitter and breakers in general.

Interviewer: Tenant A schedules 2M jobs at midnight. Tenant B's critical payment job is due the same second.

You: I use several tools together:

So A's runs defer a few seconds at a time, while B's job takes reserved high-priority capacity.

What's being probed: retries that help recovery instead of causing a second outage, and stopping one tenant from starving another.

Concept — durable retries and fairness. A retry stored as state with a due time is just another scheduled run. So it gets the scheduler's durability for free. Fairness uses three different tools. Buckets cap each tenant's rate. Priorities order the work that is allowed through. Bulkheads (separate, walled-off capacity) give critical tenants their own resources. Deferring over-quota work keeps the no-loss promise.

Takeaway: Back off with jitter, stop calling a target that is clearly down, and rate-limit, prioritise and isolate per tenant — deferring, never dropping.

Interviewer: "Every day at 02:30 New York time." What happens on DST change days?

You: Store the cron and the IANA zone. Compute the next fire in local time with a library such as Spring's CronExpression over ZonedDateTime. Convert that to a UTC instant and store it as the next row's scheduled_at.

Compute only the next instance. Compute it from scheduled_at, not from now(), so drift never builds up.

Case (America/New_York) Example Policy
Spring forward — the time does not exist 02:30 on the day DST starts Fire once at the next valid instant, 03:00
Fall back — the time happens twice 01:30 occurs twice Fire once, on the first occurrence
Interval jobs "every 1 h" Fixed UTC duration; not affected

Governments change DST rules. So keep tzdata (the time-zone rules database) current, and recompute the affected waiting rows when it changes.

On clock skew (machines' clocks disagreeing slightly):

Missed runs: a run counts as misfired if it is more than a threshold late, 60 s by default. The policies:

One-off jobs always fire on recovery, unless they carry an expireAfter.

What's being probed: treating time as a domain with sharp edges, and keeping correctness out of clocks.

Concept — wall-clock time vs instants. An instant is a point on one global timeline. "02:30 New York" maps to an instant through rules that change twice a year, and sometimes by government decree. So store the user's intent, schedule in UTC instants, and define a policy for the odd days. Physical clocks disagree slightly. So one authority judges leases, and monotonic clocks measure durations. This is the same discipline as R26's business dates, which are never your server's midnight.

Takeaway: Store intent in local time, schedule in UTC instants, and keep correctness out of clocks.

Interviewer: Quickly — how do users know their jobs are healthy?

You: We measure:

Alerts fire on lag p99 above 5 s, on any shard unowned for 30 s, and on DLQ spikes. There is also a per-job dead man's switch: it fires when an expected success does not arrive by a deadline. This matters because the worst scheduling failure is silence. R15 covers alerting on absence.

Failures and scale

Interviewer: Where does the scaled-up design break?

You:

Bottleneck / failure Mitigation
Top-of-hour herd Preload the lookahead, batch 500 per transaction, jitter, priority topics, deferral
Hot shard Hash on job_id, not tenant; 1,024 logical shards; split and move hot ones
DB writes, 5 per run Shard across clusters, batch, send history to Kafka/ClickHouse, daily partitions
Scheduler crash Lease expiry reassigns shards; the new owner rebuilds from SQL and fires missed items
Zombie scheduler Epoch fencing in the transaction, unique run key, CAS
Worker crash or hang Lease, heartbeat, reaper, completions fenced by token
Kafka down Rows stay QUEUED — the row is the outbox; publish retries; nothing lost
Redis due index lost It is only a cache; rebuild from SQL; the sweep catches stragglers
etcd down Owners keep their shards until TTL, then stop; no reassignment; alert
Downstream outage Breaker per target, jittered backoff, DLQ, owner alerted

When etcd is down, the scheduler stops firing rather than risk firing twice, because late is recoverable and a duplicate payment is not.

Interviewer: The primary region goes down.

You: The standby region has async DB replicas, mirrored Kafka and idle schedulers. We promote the DB. The standby schedulers take the shards in their own etcd. The misfire policy handles the gap.

RPO (how much data we can lose) equals the replication lag. RTO (how long recovery takes) is minutes. But there is a trap.

Fencing only works inside one consensus domain

Each of the two regions has its own etcd. Their epochs cannot be compared, so a split brain (both regions thinking they are in charge) can double-fire. The options:

Meanwhile, idempotency keys contain the damage.

What's being probed: failing safe over failing dangerous, and seeing that single-region fencing does not extend across regions.

Concept — failing safe and consensus domains. Choose the harmless failure. A scheduler that cannot prove it owns a shard stops. That produces lateness instead of duplicates. A fencing token only means something inside the consensus system that issued it. Across regions, you have three choices: a human decides, a global store issues the tokens, or partitioning removes the conflict — R13's home-region idea.

Takeaway: Fail toward "late" rather than "twice", and across regions use controlled failover or home regions.

Curveballs

Overlapping runs, edits with a run in flight, and missed slots are covered in the scenarios table above.

Interviewer: On AWS with managed services? And how do you test correctness?

You: On AWS:

The cost is less control over fairness and cost at very high volume.

Testing:

Distributed systems fail in the gaps between components. So you write the invariants down, and break the system on purpose while checking them.

Quick reference

Decision Simple design Scaled-up design Why
Source of truth One Postgres: schedule_details + scheduled_execution Same tables, 1,024 logical shards over several clusters Transactions, unique keys, CAS, SKIP LOCKED
Next run Inserted when the current run fires, in the same transaction Same The chain can never break or duplicate
Duplicate guard UNIQUE (schedule_id, scheduled_at) Same The database refuses a second run per slot
Finding due runs Runners poll every second with SKIP LOCKED Timing wheel over the next 10 minutes + SQL sweep Precise and cheap at peak; the DB stays the truth
Runner coordination Row locks in Postgres Shard ownership via etcd leases, epoch fencing Scales horizontally; zombies rejected
Hand-off to workers Workers claim QUEUED rows from the table Kafka priority topics; the row is the outbox Nothing lost if a process or broker dies
Worker failure Lease, heartbeat, reaper, lease token Same A vanished worker's run is retried; a zombie is harmless
Delivery At-least-once + stable idempotency key Same The only honest guarantee for external side effects
Retries Durable RETRY_WAIT, full jitter, then DEAD Plus a circuit breaker per target Workers stay free; no retry storms
Missed runs Misfire policy per job, default FIRE_ONCE Same Different jobs need different behaviour
Fairness Oldest first Priorities, tenant buckets, reserved capacity, jitter One tenant cannot starve another

The 60-second version

Start simple. A Job API writes each definition to schedule_details together with its first run row in scheduled_execution. Two or three runners poll every second with FOR UPDATE SKIP LOCKED. In one transaction each claims due rows, marks them QUEUED, and inserts each schedule's next run. A unique key on (schedule_id, scheduled_at) refuses duplicates.

Workers claim queued rows with a lease and a fencing token, call the target with an idempotency key built from scheduled_at, and send heartbeats for long jobs. A reaper retries runs whose lease expired. Failures retry with jittered backoff, then go DEAD and page the owner. Missed slots follow a per-job misfire policy.

That is correct up to roughly a thousand fires a second. Past that, shard both tables by schedule_id, give each shard one owner through etcd leases with epoch fencing, load the next ten minutes into a timing wheel, dispatch through Kafka with the row as the outbox, and add per-tenant priorities and quotas. When coordination is lost, the system fails toward late, never twice.


Walkthrough 4 — Distributed key-value store, Dynamo-style at petabyte scale

Interviewer: Design a distributed key-value store.

You: A few answers change the whole shape of the design, so I'll ask those first:

# Question Why it matters
1 Only get, put and delete — or also range scans and secondary indexes? Range scans push us towards range partitioning (keys split by sorted ranges)
2 How big are values, and how much do sizes vary? Affects engine tuning, bandwidth, and whether to split large values
3 Read-heavy, write-heavy or mixed? An LSM engine (log-structured, built for fast writes) favours writes; reads then need Bloom filters and caches
4 Is eventual consistency fine, or must keys be linearizable (every read sees the latest write, as if there were one copy)? Decides between a leaderless quorum and consensus per shard
5 During a network partition, do we keep availability or consistency? A shopping cart picks AP; a lock service picks CP
6 One datacentre (DC) or multi-region active-active? Decides cross-DC replication and conflict resolution
7 What are the p99 targets (the latency 99 in 100 requests beat)? Sets the default R and W, and the timeouts
8 Do we need TTLs, compare-and-set or transactions? CAS (compare-and-set) and transactions need consensus

Interviewer: Assume the simplest reasonable answers.

You: Then the API is get, put and delete, with a consistency level chosen per request. Keys are up to 256 bytes. Values are up to 1 MB, typically 10 KB.

We hold about 1 PB today and a few PB in five years. The load leans towards reads, 70/30. The store is AP by default with tunable consistency. AP means it stays available during a partition, even if copies briefly disagree. Each request picks ONE, QUORUM or ALL.

It runs in several datacentres, with async cross-region replication, so remote copies update in the background. The target is p99 under 10 ms inside one DC at QUORUM. There are no cross-partition range scans and no multi-key transactions. AP, point lookups and petabyte scale point to the Dynamo family. Naming the family gives me a known checklist for the rest of the hour.

What's being probed: finding the questions that change the design, and recognising the system family.

Concept — design-forking questions and system families. Some questions fork the design into different paths: - "Range scans?" decides hash versus range partitioning. - "AP or CP?" decides leaderless quorums versus a leader per shard. - "CAS?" decides whether you need consensus at all.

Storage designs fall into a few families: - Leaderless replicated: Dynamo, Cassandra, Riak. - Leader-per-shard consensus: etcd, TiKV, CockroachDB. - Ordered range-partitioned: Bigtable, HBase.

When you name one, you inherit its building blocks and its trade-offs.

Takeaway: Ask the forking questions, then name the family you are building.

Interviewer: Requirements.

You: The functional side is tiny:

The real spec is non-functional:

Out of scope: SQL, joins, secondary indexes, multi-key ACID, and objects over 1 MB. Large objects belong in a blob store, with a pointer kept here.

Interviewer: A lot of teams want strong consistency. Why not make it CP?

You: It depends on the data, which is why I asked. During a partition you have two choices. You accept writes on both sides, or you refuse some writes to stay consistent.

A cart or a session store should never refuse a write. A stale read is cheap; a lost sale is not. A lock or a balance must never show two truths.

So I don't pick once for everything. The main store is AP with tunable consistency per request, and QUORUM with N = 3 is strong enough for most callers. Keyspaces that truly need linearizability or compare-and-set go to a separate Raft-based tier. Raft is a consensus protocol: the replicas agree on one leader and one order of writes. Sending only those keyspaces there is better than slowing every write in the main store.

What's being probed: CAP as a choice per use case, not a global switch.

Concept — CAP and PACELC. CAP says that during a partition you choose consistency or availability. PACELC adds that even without a partition you trade latency against consistency, because waiting for more replicas is slower. Tunable consistency lets each request choose its own point on that curve. R0 and R10 carry the theory.

Takeaway: Make consistency a per-request dial, and send true CP needs to a consensus system.

Capacity

Interviewer: Keys, traffic, nodes.

You: My assumptions:

  1. Keys: 10^15 / 10^4 = ~100 billion.
  2. Peak client ops: 1.5M reads + 600K writes = 2.1M/s.
  3. Replica ops: writes × N = 1.8M; QUORUM reads touch R = 2 replicas (one returns data, one returns a digest) = 3.0M; ~4.8M replica ops/s.
  4. Write bandwidth at peak: 6 GB/s logical, 18 GB/s replicated.
  5. Growth: 10K new keys/s × 10 KB = 8.6 TB/day logical; assume TTL and deletes remove 80%, so ~0.6 PB/year net — about 4 PB in five years.
  6. Nodes today: 1 PB × 3 × 1.3 / 8 TB ≈ 500; in five years, 15.6 PB / 8 TB ≈ 2,000.
  7. Per node: 4.8M / 500 ≈ 9.6K ops/s. Comfortable.

The cluster is storage-bound, not CPU-bound. Disk sets the node count, and the QPS per node ends up modest. So we optimise for density.

The memory budget works out:

Network: about 36 MB/s in and 30 MB/s out per node. That leaves a 10–25 Gbps NIC plenty of room for streaming and repair. This matters, because rebuilding a node moves terabytes.

What's being probed: the hidden multipliers in estimation, and headroom for background work.

Concept — amplification factors. The clients' numbers are not what the cluster sees: - Writes multiply by N. - Quorum reads multiply by R. - Bytes on disk multiply by compaction and tombstone overhead.

Then ask which resource runs out first — here, disk. Leave headroom on network and disk. For example, a design that uses 90% of the NIC for clients falls over the first time a node is rebuilt. R10 covers the Bloom filter itself.

Takeaway: Multiply by replication and overhead, find the resource that sets the node count, and keep headroom for repair.

API and data model

Interviewer: The API?

You: In reality it is a binary protocol. For readability I'll show an HTTP facade:

PUT /v1/kv/cart:user42?consistency=QUORUM&ttl=86400
X-KV-Context: a8Fz...        (opaque vector clock from the previous read)
Idempotency-Key: 6f1c-...

HTTP/1.1 200 OK
X-KV-Acks: 2/3
X-KV-Context: b91Q...

In vector-clock mode, a read can return several values plus a context. A vector clock is version metadata that records which writes each version has seen.

If the store can hold concurrent versions, the API has to say so honestly. So before writing, the client tells the server which version it read, the way If-Match carries an ETag. Otherwise the server cannot tell "updating what I saw" from "writing blind".

Interviewer: A put times out. Did it happen?

You: Unknown. There are two different errors:

A timeout is not a failure; it is an unknown outcome. This is R0's central problem.

So the client retries idempotently or reads the value back. Idempotent means repeating the request has the same effect as doing it once. A same-value put is naturally idempotent under last-write-wins. The Idempotency-Key also de-duplicates retries at the coordinator. This is R3 applied to storage.

Interviewer: What does each node persist?

You:

Entity Where Why
Record An LSM tree per replica: commit log, memtable, SSTables Writes are sequential; Bloom filters let reads skip files
Commit log An append-only file on fast disk Keeps writes durable before the flush; replayed after a crash
SSTable Immutable sorted files, each with an index, summary and Bloom filter Because files never change, compaction, streaming and snapshots stay simple
Ring / token map In memory on every node, also saved locally Any node can route any request; spread by gossip
Hints Local files on the coordinator or a fallback node Replayed when the target replica comes back
Merkle trees Built during repair, held in memory Find ranges that differ, cheaply
[token 8B][key_len][key][timestamp 8B][flags: tombstone|ttl][ttl][vclock_len][vclock][value_len][value]

Every record carries its own timestamp and tombstone flag. This is because replicas merge versions cell by cell.

Interviewer: Why LSM rather than a B-tree?

You: With an LSM tree, a write is just a commit-log append plus a memtable insert. A memtable is a sorted in-memory table that is later flushed to disk as an SSTable. So writes get high throughput on any disk. Immutable SSTables are also easy to stream and snapshot, which matters for rebalancing and repair.

The price is read amplification (a read may check several files) and background compaction (merging files). Bloom filters and the compaction strategy keep both bounded.

A B-tree wins for read-heavy, update-in-place workloads, where low space amplification matters more than write throughput. R10 and R11 compare the engines in general.

What's being probed: the metadata a replicated store needs beyond the data itself.

Concept — metadata for convergence. A single-node database needs only the value. A replicated one also needs: - a timestamp or vector clock, to order versions; - a tombstone, because an absent row cannot replicate; - hints, for missed writes; - a token map, so every node knows who owns what.

Each one exists to solve one failure mode.

Takeaway: In a replicated store, every record carries what it needs to be merged later.

High-level design

Interviewer: Draw it.

You: There is no master: every node holds the full ring, and whichever node receives a request coordinates it. Walk a put fanning out to three replicas, then a quorum read with a digest mismatch, then a replica dying and a hint replaying when it comes back.

The pieces:

Interviewer: A put and a get, end to end.

You: First the put, with N = 3 and W = 2:

  1. The driver computes murmur3("cart:42") and sends the request to owner B, which coordinates.
  2. B builds the rack-aware preference list [A, B, C] and assigns a timestamp.
  3. B sends the write to all three in parallel.
  4. Each replica appends to its commit log (group commit every ~10 ms), inserts into the memtable and acks.
  5. At two acks the client gets success.
  6. The third ack arrives later, or the coordinator writes a hint.

Now the get, with R = 2:

  1. The coordinator picks the two fastest live replicas by recent latency.
  2. It asks B for the data and asks C only for a digest (a hash of the value).
  3. Each replica checks the memtable, then SSTables from newest to oldest. It skips any SSTable whose Bloom filter says "not here".
  4. If B's data hashes to C's digest, the coordinator returns it.
  5. If not, it fetches C's full value and picks the newest (or keeps siblings — the conflicting versions). It returns that and writes the winner back to the stale replica. This is read repair.

Blocking read repair waits for that write-back before replying. So a later QUORUM read cannot go back in time. R6 teaches read repair, and its demo shows the digest mismatch.

What's being probed: a symmetric, masterless architecture, and tracing the data path when replicas disagree.

Concept — symmetric nodes and digest reads. Every node runs the same code and plays every role, so there is no master to fail over. The price is that every node must know the layout. That is why the token map is small and gossiped.

Reading one value plus one hash halves read bandwidth. Also, every read of a key whose replicas disagree heals that key as a side effect.

Takeaway: "Coordinator" is a role, not a node; read one value plus a digest, and repair on mismatch.

Deep dives

Interviewer: How do you spread 100 billion keys over 500 nodes? Why not hash(key) % n?

You: Because changing n from 500 to 501 remaps almost every key. At a petabyte, that is catastrophic.

Option Strength Weakness
hash % n Trivial Changing n moves nearly everything
Consistent hashing, one token per node Adding a node moves only ~1/n of keys Arcs are uneven; a new node relieves only one neighbour
Consistent hashing with vnodes Even spread; a new node takes small slices from many nodes Larger map; repair touches more peers
Fixed partitions (Riak) The partition is the unit of transfer and repair Partition count is fixed up front
Range partitioning Supports range scans Hot spots on sequential keys; needs split and merge

Consistent hashing places keys and nodes on a ring, and each key belongs to the next node clockwise. Vnodes (virtual nodes) give each physical node many small positions on the ring.

I'd use Murmur3 over a 64-bit token space, with about 16 vnodes per node. Public facts: the 2007 Dynamo paper introduced virtual nodes. Cassandra's default num_tokens moved from 256 to 16 in 4.0, with a token allocation algorithm, to cut repair overhead.

preferenceList(key, N):
  t = murmur3(key); list = []
  for vnode in ring.walkClockwiseFrom(t):
      node = vnode.physical
      if node not in list and rackOK(node, list): list.append(node)
      if len(list) == N: break
  return list

R10 teaches the ring itself — the modulo problem, the clockwise walk, a node joining and why vnodes even it out.

Interviewer: N, R and W. Why does QUORUM give consistency?

You: If R + W > N, every read set overlaps every write set in at least one replica. So a read always sees the latest acknowledged write.

N = 3 Behaviour Use
W=1, R=1 Fastest; eventual consistency Metrics, caches, sessions
W=2, R=2 (QUORUM) Sets overlap; tolerates one replica down Default
W=3, R=1 Fast reads; writes fail if any replica is down Config that is rarely written
W=1, R=3 Fast writes, slow reads Write-heavy logs that are rarely read
LOCAL_QUORUM A quorum inside the local DC only Multi-DC at low latency

Interviewer: So QUORUM is linearizable, and you don't need Raft.

You: No — this is the common trap. Overlap is necessary, but not sufficient. Here is why:

Leaderless (Dynamo, Cassandra, Riak) Leader per shard, Raft (etcd, TiKV, CockroachDB)
Writes Any replica accepts them Only the shard leader accepts them
On failure Keeps going while W replicas are reachable Pauses for leader election, for seconds
Consistency Tunable; eventual by default Linearizable
Conflicts Possible; resolved by LWW or vector clocks None; the log is totally ordered
Best for Always-writable, high-throughput, multi-DC data Metadata, locks, money, anything needing CAS

Quorums give overlap, consensus gives order — and compare-and-set and locks need order. That is exactly why Cassandra's lightweight transactions run Paxos (another consensus protocol) per key. R8 teaches Raft.

What's being probed: why consistent hashing and vnodes exist, the pigeonhole argument behind quorums, and its limits.

Concept — quorum intersection vs consensus. Picture three seats. Any two groups of two seats must share a seat. So the read always sees one copy of the latest write. Raising W slows writes, and raising R slows reads.

But overlap says nothing about the order of concurrent writes, and linearizability needs a single order. A consensus protocol puts every write into one log through one leader. That is what makes CAS safe. The price is pauses during elections.

Takeaway: R + W > N forces overlap; anything needing a single agreed order needs consensus.

Interviewer: Two clients write the same key at once on different replicas. Which wins?

You:

Approach Strength Weakness
Last-write-wins (LWW) by timestamp Simple, one value per key; Cassandra's per-cell model Silently drops concurrent writes; depends on clocks
Vector clocks / version vectors Detect true conflicts; nothing is lost The client must merge siblings; clocks must be pruned
CRDTs (data types that merge automatically) Automatic, correct merge Only some data types; extra metadata
compare(a, b):
  if all(a[n] <= b[n]) and any(a[n] < b[n]): return a_before_b   # b wins
  if all(b[n] <= a[n]) and any(b[n] < a[n]): return b_before_a   # a wins
  if a == b: return equal
  return concurrent                                               # keep both siblings

Public facts: the Dynamo paper used vector clocks and returned siblings to the application. The cart merged them by union, so deleted items could reappear. Riak later moved to dotted version vectors and added CRDTs.

My choice:

For example, in a cart, concurrent adds from two devices must both survive, so it uses vector clocks or a set CRDT. For a profile field, the latest edit should win, so it uses LWW.

What's being probed: telling "later" from "concurrent", and choosing a resolution per data type.

Concept — happened-before. Wall clocks drift, so "highest timestamp wins" can pick the wrong write. A vector clock keeps a counter per node: - If every counter in A is at most B's, then A happened before B. - If each has a counter the other lacks, they were written without knowledge of each other. The store keeps both, and the application merges them.

Takeaway: LWW is simple but lossy; vector clocks detect true concurrency so nothing is silently dropped.

Interviewer: Replica C dies. What happens to writes, and how does C catch up?

You:

  1. C's heartbeats stop. The phi-accrual score crosses its threshold, and nodes mark C down locally.
  2. A put for cart:42 arrives. Under strict quorum, we write to A and B, so W = 2 is met, and store a hint for C. A hint is a saved copy of the write, to hand over later.
  3. Under sloppy quorum, Dynamo-style: if two of three are down, we write to D and count it towards W. D holds the data tagged "belongs to C".
  4. C returns, and gossip spreads the news. Hint holders replay their hints, throttled, and then delete them.
  5. Hints expire after about 3 hours. A longer outage needs Merkle-tree repair.
Layer Fixes Runs
Hinted handoff Short outages On write; replayed on recovery
Read repair Keys that get read On read, when replicas disagree
Anti-entropy with Merkle trees Everything else — cold keys, long outages On a schedule, within the tombstone grace period

Sloppy quorum keeps writes available, but it breaks R + W > N. This is because a read can hit home replicas that never got the write. Cassandra does not count hints towards the consistency level, except at ANY. I default to strict quorum plus hints, so the overlap guarantee holds.

A Merkle tree is a tree of hashes over key ranges. Two replicas compare roots first, then descend only into branches that differ. They stream just the leaf ranges that diverge. Incremental repair marks SSTables that are already repaired. R6 teaches read repair, anti-entropy and Merkle trees in full.

Interviewer: How does every node know who is alive? Why not remove a dead node at once?

You: Every second, each node gossips its view — generation, heartbeat, state, tokens, load — with one to three random peers. State reaches all 2,000 nodes in O(log n) rounds, which is seconds. Seeds are entry points for joining, not masters.

Phi accrual turns gaps in heartbeats into a suspicion score. It compares each gap with that node's usual rhythm, so a slow network does not cause false alarms.

Down is local and temporary; removal is an explicit admin action. This is because most failures are restarts or GC pauses (stalls while memory is cleaned up), and removing a node moves terabytes. Dynamo made the same choice.

What's being probed: the layered self-healing toolkit, the cost of sloppy quorum, and conservative failure detection.

Concept — healing in layers, suspicion in degrees. The healing layers: - Hints are fast and cheap for short outages. - Read repair heals hot keys as a side effect. - Merkle trees catch everything else, by comparing summaries before data.

Gossip spreads state like office rumours. An adaptive suspicion score replaces a fixed timeout. Marking a node down is cheap and reversible, so it is automatic. Removal is expensive, so a human does it.

Takeaway: Hints for short outages, read repair for hot keys, Merkle trees for the rest — suspect quickly, remove deliberately.

Interviewer: One level deeper. How do reads stay fast and how do deletes work?

You: The write path is: commit log, memtable, ack. When a memtable is full, it flushes to an immutable SSTable.

The read path is: memtable, key cache, then each SSTable from newest first. For each SSTable it checks the Bloom filter, index summary, partition index and data block. It merges cells by timestamp.

Compaction Read amp Write amp Space amp For
Size-tiered Higher Low High; needs ~50% free Write-heavy
Leveled Low Higher Low Read-heavy, updates
Time-window Low Low Low Time series with TTLs

"Amp" means amplification: how much extra work or space the engine uses beyond the raw data.

A delete writes a tombstone with a timestamp. It replicates like any write. It is kept for a grace period, such as 10 days.

The tombstone trap: zombie data

Suppose a replica is down longer than the grace period and misses the delete. Meanwhile, the tombstone is compacted away on the other replicas. When the replica returns, its old value comes back and "repairs" the others.

Rule: run full repair more often than the grace period, and never return a node that was down longer than that without replacing it.

For large values, key-value separation (WiscKey-style) keeps only pointers in the LSM, to cut write amplification. Beyond 1 MB, values go to a blob store.

Interviewer: Traffic doubles. Add capacity without downtime.

You:

  1. Start node E with the seed list. It learns the ring by gossip.
  2. E picks ~16 tokens to balance load and enters JOINING. The pending ranges spread by gossip.
  3. Coordinators dual-write those ranges to the current owners and to E, so E misses nothing.
  4. Current owners stream SSTable sections to E. This is bulk file transfer, not row by row.
  5. E goes NORMAL. The token map updates, and drivers refresh.
  6. Old owners run cleanup for ranges they no longer own.

Because of vnodes, many peers each send a small slice in parallel. Throttle streaming, and add one node at a time. Bulk-copy the old data, dual-write the new, then flip ownership.

Failures and scale

Interviewer: A flash sale sends 500K reads a second to one key.

You: Consistent hashing spreads keys, not the load on a single key. In order, I would:

  1. Spread reads across all N replicas, if the caller accepts ONE.
  2. Add a short-TTL cache in the driver or a front tier (R12).
  3. Keep k copies, key#0..k, and read one at random.
  4. For write-hot keys, split the value into sub-keys and merge on read, or use a counter CRDT.
  5. Rate-limit abusive tenants — Walkthrough 1 again.
Bottleneck / failure Mitigation
Node crash RF 3 across racks; QUORUM still succeeds; hints; replace the node once it is past the hint window
Disk failure, corrupt SSTable Block checksums; rebuild from replicas by streaming
Network partition The AP default accepts writes on both sides, then reconciles via LWW or vector clocks and repair; CP callers use QUORUM and accept errors
Compaction falling behind Backpressure on writes, more threads, the right strategy, disk headroom
Tombstone build-up Avoid queue-like delete patterns; use TTL with time-window compaction; alert on tombstones per read
Coordinator overload Partition-aware drivers; speculative retry
Slow replica Latency-aware routing away from it; hedged requests past p99
Rebalancing hurts latency Throttle streaming; one node at a time

Interviewer: Multi-region, and losing a whole region.

You: Each DC holds three replicas. Writes at LOCAL_QUORUM ack after two local replicas confirm. The coordinator then forwards one copy to each remote DC, which fans it out locally. So the client never waits on the WAN.

Strong cross-region reads need EACH_QUORUM (a quorum in every DC), and they pay WAN latency. Cross-DC conflicts resolve the same way as local ones.

If we lose a DC, we fail over via DNS or the global balancer, then rebuild by streaming from the surviving DCs. Data that needs zero RPO (no data loss) across region loss uses synchronous EACH_QUORUM.

And replication is not backup. A bad deploy that corrupts values, or a truncate, replicates everywhere in seconds. So periodic SSTable snapshots go to object storage.

What's being probed: that hashing balances keys but not hotness, keeping latency local while replicating globally, and knowing replication is not backup.

Concept — hot keys, local quorums and snapshots. A wildly popular key must stop being one key. Cache it near clients, or copy it under different names so the hash spreads it.

Cross-region round trips would blow a 10 ms budget. So LOCAL_QUORUM acks locally and ships the write in the background. It accepts a small window of possible loss if a region fails (R13).

Only point-in-time snapshots protect you against your own mistakes.

Takeaway: Cache, copy or split the hot key; quorum locally, replicate asynchronously, and snapshot because replication copies mistakes too.

Curveballs

Interviewer: How do you stop a slow replica ruining p99?

You: At high fan-out, the slowest replica sets the response time. So rare stalls — GC, a noisy neighbour, a compaction burst — dominate the tail.

Route reads by recent latency. When a request passes the p99 threshold, send a speculative retry to another replica and take whichever answer comes first. This costs a few percent extra load for a much shorter tail. R14 covers hedging; the scatter-gather demo there shows why the slowest shard wins.

Interviewer: When would you tell the team not to build this, and use a Raft-based store?

You: When the requirement includes linearizable reads and writes, compare-and-set, leases or locks, or multi-key transactions. Per-key Paxos approximates some of this, but slowly and awkwardly.

So I would split it:

Many platforms run both side by side. In a payments system, the ledger is firmly on the second list.

What's being probed: tail-latency instincts, and judgement about the limits of your own design.

Concept — choosing between AP and CP systems. Leaderless AP stores shine when write availability and throughput matter more than a single global order. Consensus stores shine when correctness depends on everyone agreeing on one order — for example, "only one holder of this lock". A senior engineer picks per data set. They are also comfortable saying that part of the product should not live in the system they just designed.

Takeaway: Leaderless for always-writable data, Raft for anything that needs a single agreed order.

Quick reference

Decision Choice Why
Replication Leaderless quorum, N = 3 across racks Always writable, no failover pause, works well across DCs
Partitioning Consistent hashing, ~16 vnodes per node Even spread; small, parallel data movement
Consistency Per request; QUORUM by default (R = W = 2) R + W > N gives overlap; use ONE when latency matters more
Conflicts LWW by default; vector clocks or CRDTs per keyspace Simple API; no silent loss where it matters
Quorum type Strict quorum + hints that do not count towards W Keeps the overlap guarantee
Healing Hints, read repair, Merkle anti-entropy Fast for short outages, complete for the rest
Engine LSM: commit log, memtable, SSTables, Bloom filters Cheap writes; immutable files make streaming easy
Membership Gossip + phi accrual; removal is manual No central dependency; no storms on brief failures
Cross-DC LOCAL_QUORUM, async replication, snapshots Local latency; replication is not backup

The 60-second version

This is a leaderless, Dynamo-style store. Any node coordinates any request, and there is no master in the data path.

Keys hash onto a ring with virtual nodes. Each key lives on three distinct physical nodes clockwise, spread across racks.

Consistency is a per-request dial. QUORUM's R = W = 2 overlaps because R + W > N. But overlap is not order, so anything needing compare-and-set goes to a Raft-based store.

Conflicts resolve by last-write-wins by default. Vector clocks or CRDTs are used where concurrent updates must survive.

Failures heal in three layers: hints for short outages, read repair on reads, and Merkle-tree anti-entropy for everything else. Repair runs inside the tombstone grace period.

Gossip with phi accrual tracks membership. A new node takes small slices from many peers, under dual writes. Each node runs an LSM engine.

About 500 nodes hold a petabyte at RF 3 today. Regions replicate asynchronously at LOCAL_QUORUM, backed by snapshots.


What to say in the interview

"Infrastructure questions are tests of which mechanism you reach for, so I'd scope until I find the requirement that forces the architecture — the latency budget, the guarantee, what happens when a dependency dies — and do the arithmetic aloud to find the resource that runs out first. Then I keep the per-request path tiny and everything slow on a control plane that can fail without traffic noticing. The guarantees go where the data lives: an atomic script on the counter, a unique key and a fencing token in the database, a quorum or a consensus log depending on whether I need overlap or order. And for every box I say what still works when it disappears, and which way it fails — open or closed, late rather than twice."