The System Design Bible

A complete, staff-engineer-level refresher: how to attack the interview, every core concept from TCP handshakes to sagas, database internals to Bloom filters — with real-world analogies, TypeScript implementations, SVG architecture diagrams, decision flowcharts, and AWS mappings.

28 chapters · TypeScript examples throughout · Built July 2026

Start Here

0. The Cheat Sheet — Last-Minute Review

Everything in this book compressed to one scannable page. Read this in the 30 minutes before your interview: the attack plan, the numbers, the trigger-word mappings, and the one-line verdicts on every big decision. Each row links conceptually to a full chapter — if anything here surprises you, that's the chapter to reread.

The 6-step attack plan (45-min slot)

  1. Requirements — 5–10 min. Functional, non-functional (scale, latency, consistency, availability), explicit scope cuts. Close with: "So we're building X for Y users under Z constraints — agreed?"
  2. API & data model — 5 min. 3–6 endpoints, 2–4 core entities. Writing them down forces precision.
  3. Capacity estimation — 5 min. QPS, storage, bandwidth. Round aggressively; the goal is justifying choices, not decimals.
  4. High-level design — 10–15 min. Full request path on the board: client → edge → services → stores → async. Breadth before depth.
  5. Deep dives — 15–20 min. 1–2 hardest components: sharding, consistency, failure modes, hot keys.
  6. Wrap-up — 5 min. Bottlenecks, SPOFs, monitoring, "with more time I'd…". Self-critique is a staff signal.

Latency numbers (order of magnitude)

OperationLatencyOperationLatency
L1 cache ref~1 nsRead 1 MB from RAM~100 µs
Mutex lock/unlock~25 nsSSD random read~100 µs
Main memory ref~100 nsRead 1 MB from SSD~1 ms
Compress 1 KB~2 µsHDD seek~10 ms
Same-DC round trip~0.5 msRead 1 MB from HDD~20 ms
Redis GET (same DC)~0.5–1 msCross-region round trip (US–EU)~80–150 ms
Postgres indexed read~1–5 msCross-continent (US–APAC)~150–250 ms
TLS handshake~1–3 RTTsHuman "instant" threshold~100–200 ms

QPS & storage quick formulas

  • Seconds/day ≈ 86,400 → round to 100k. 10M requests/day ≈ 100 QPS average.
  • Peak ≈ 2–5× average (spiky consumer traffic: use 10×).
  • DAU → QPS: DAU × actions/user/day ÷ 100k. 100M DAU × 10 actions = 1B/day ≈ 10k QPS avg, ~30–50k peak.
  • Storage: items/day × bytes/item × retention days × replication factor (usually 3). 100M msgs/day × 1 KB × 5 yr ≈ 100 GB/day ≈ ~180 TB raw, ~0.5 PB replicated.
  • Bandwidth: QPS × payload size. 10k QPS × 10 KB = 100 MB/s ≈ 0.8 Gbps.
  • Cache sizing (80/20): hot set ≈ 20% of daily working set covers ~80% of reads.
  • Connections: concurrent users × 1 socket each; 10M concurrent WebSockets ÷ ~200k conns/box ≈ 50–100 gateway nodes.

"If you hear X → reach for Y"

Interviewer says…You reach for…
Millions of concurrent connections / real-time updatesWebSocket (or SSE) gateway + connection registry (Redis) + pub/sub fan-out
"Exactly once" processingAt-least-once delivery + idempotent consumer (dedupe key) — true exactly-once doesn't exist end-to-end
Unique username / "already taken?" at scaleBloom filter in front + DB unique constraint as source of truth
Top-K / trending / heavy hittersCount-min sketch + min-heap; periodic merge from stream
Count distinct (unique visitors)HyperLogLog (~1.5 KB for ~2% error at billions)
Global users, low latencyCDN/edge for static + multi-region active-active for dynamic, geo-DNS routing
Money movement / paymentsStrong consistency + idempotency keys + double-entry ledger (append-only) + saga for multi-step flows
Feed / timelineFan-out on write for normal users, fan-out on read for celebrities (hybrid)
Search / autocompleteInverted index (OpenSearch/Elasticsearch); trie or prefix table for typeahead
Hot key / celebrity problemKey salting, local cache, request coalescing, dedicated replicas
Rate limitingToken bucket in Redis (Lua for atomicity), sliding window for strictness
Long-running / multi-step workflowOrchestrated saga: Step Functions / Temporal; compensating transactions
Ordering matters within an entityPartition by entity ID (Kafka/Kinesis partition key = per-key ordering)
Huge files / mediaS3 + presigned URLs + multipart upload; CDN in front; never through your API servers
Contention on one row (flash sale, seat booking)Reservation + TTL, optimistic locking / version column, or queue-serialize writes per item
Audit / "who changed what"Event sourcing or append-only change log + CDC (Debezium pattern)
Proximity / "nearby drivers"Geohash or H3 cells in Redis; quadtree for static data

Big decisions — one-liners

DecisionThe one-liner
SQL vs NoSQLDefault SQL for relational/transactional data; NoSQL when you know access patterns, need horizontal write scale, or the model is genuinely key-value/document/wide-column.
SQS vs Kinesis vs EventBridge vs KafkaSQS = work queue (competing consumers, no replay); Kinesis/Kafka = ordered, replayable stream, multiple readers; EventBridge = low-volume routed bus with content filtering; Kafka over Kinesis when you need ecosystem, long retention, or multi-cloud.
ALB vs NLB vs API GatewayALB = L7 HTTP routing to your fleet; NLB = L4, extreme throughput/static IPs/non-HTTP; API GW = managed auth/throttling/keys for serverless or public APIs (adds cost + latency).
Cache strategyCache-aside is the default; write-through for read-after-write freshness; write-behind only when you can tolerate loss. Always set TTLs; always plan invalidation.
Consistency levelStrong within a partition/entity (money, inventory); eventual across aggregates (feeds, counts). Say "strong where the business needs it, eventual everywhere else — per data class, not per system."
REST vs gRPC vs GraphQLREST for public/simple APIs; gRPC for internal service-to-service (typed, fast, streaming); GraphQL when diverse clients aggregate many resources and over-fetch is real.
Push vs pullPush (WebSocket/SSE) for latency-sensitive updates; pull/poll for simplicity and low-frequency; hybrid long-poll when infra can't hold connections.
Sync vs asyncAnything not needed for the response goes async behind a queue — decouple, absorb spikes, retry safely.

Numbers to memorize (order-of-magnitude, not gospel)

  • Single Postgres: low tens of thousands of read QPS with tuning/indexes; ~5–10k write TPS; hundreds of GB to low TB comfortable per node.
  • Redis: ~100k+ ops/s per node (simple GET/SET), sub-ms latency; cluster for more.
  • Kafka/Kinesis partition (shard): single-digit MB/s to tens of MB/s each; Kinesis shard = 1 MB/s in, 2 MB/s out, 1k records/s — scale by partitions.
  • One server: ~65k ephemeral ports per (src IP, dst IP, dst port) tuple; ~10k–200k concurrent WebSockets per node depending on tuning.
  • DynamoDB partition: 3k RCU / 1k WCU hard cap per partition — hot partitions throttle regardless of table capacity.
  • SQS: effectively unlimited throughput (standard); FIFO ~300 msg/s per message group (3k batched).
  • S3: 3,500 PUT / 5,500 GET per second per prefix; 5 TB max object.
  • Lambda: cold start ~100 ms–1 s+ (worse in VPC/large bundles); 15-min max runtime.
  • Availability math: 99.9% = ~8.7 h down/yr; 99.99% = ~52 min; serial dependencies multiply (0.999 × 0.999 ≈ 0.998).
  • Payload sanity: tweet ~1 KB, photo ~500 KB–2 MB, 1 min video ~5–50 MB.

The universal skeleton

Nearly every answer starts as a variation of this diagram. Draw it, then mutate it for the question.

Client DNS / CDN static + edge cache LB / Gateway TLS, auth, rate limit   Stateless services scale horizontally Cache Redis, TTL'd Database replicated, sharded Queue / bus SQS / Kafka 1. read cache 2. miss → DB 3. async events Workers retry + DLQ write results

Classic pitfalls — top 10

  1. Jumping to architecture before requirements — designing the wrong system perfectly.
  2. No numbers — choosing Kafka-and-shards for 50 QPS, or one Postgres for 500k QPS.
  3. Saying "exactly once" without idempotency — interviewers pounce on this.
  4. Cache with no invalidation story, no TTL, and no stampede protection.
  5. Single point of failure left on the board (one LB, one DB, one region) and never mentioned.
  6. Sharding by a low-cardinality or hot key (date, country, celebrity user ID).
  7. Ignoring the write path / async path — only designing the happy read flow.
  8. Distributed transactions across services instead of sagas + idempotency.
  9. Silence — designing in your head instead of narrating trade-offs out loud.
  10. Never self-critiquing: no bottleneck list, no monitoring, no "what breaks first at 10×".

Say this, not that

Instead of…Say…
"We'll use Kafka.""We need ordered, replayable events with multiple consumers, so a log-based stream — Kafka or Kinesis; on AWS I'd default to Kinesis unless we need the Kafka ecosystem."
"NoSQL scales better.""Our access pattern is key-based lookups at high write volume with no cross-entity joins, so a partitioned KV store fits; if we needed ad-hoc queries I'd stay relational."
"We'll make it exactly-once.""Delivery is at-least-once; we get effective exactly-once with idempotency keys at the consumer."
"Add a cache to make it fast.""Reads are 100:1 over writes and tolerate 30 s staleness, so cache-aside with a 30 s TTL and event-driven invalidation on the hot paths."
"It should be highly available.""Target 99.95%: multi-AZ everywhere, multi-region for the read path; the ledger stays single-writer per region because correctness beats availability for money."
"That's an edge case.""Failure mode: if the worker dies mid-batch we re-deliver; the dedupe key makes the retry safe."
"We could do X or Y.""X gives us A at the cost of B; given our constraint C, I'd pick X. Happy to revisit if C changes."
What interviewers probe — the 60-second wrap-up script
  • Q: "Anything you'd like to add?" (the final 60 seconds) A: Run this script: (1) "Recapping: we built X handling Y QPS with Z storage, strong consistency on the money path, eventual elsewhere." (2) "Biggest bottleneck is [the DB / the fan-out]; first scaling move is [read replicas / partition split]." (3) "Remaining SPOF: [component]; I'd fix it with [multi-AZ / failover] next." (4) "I'd monitor p99 latency, queue depth, and error rate, alerting on burn rate." (5) "With more time I'd deep-dive [the trickiest component]." Delivered calmly, this closes the loop and is often the last thing they write down.
  • Q: Any question that stumps you. A: "I haven't built that exact thing — here's how I'd reason about it from first principles…" then map it to the nearest pattern in the trigger table above. Pattern-matching out loud beats silence every time.
References — where the depth lives
Part I · The Interview

1. The Interview Playbook — How to Attack Any System Design Question

Every system design interview is the same game with a different skin: an under-specified prompt, 45–60 minutes, and an interviewer scoring how you think, not what you memorize. This chapter is the attack plan — a repeatable 6-step framework with time-boxes, the exact clarifying questions to ask, the scripts that signal seniority, and the pattern-matching table that maps any new question onto a system you already know.

The 6-step framework (with time-boxes)

Treat the interview like an incident bridge: you own the clock, you narrate state, and you never go silent. The framework below fits a 45-minute slot; stretch each phase ~20% for a 60-minute one. The single biggest failure mode is spending 25 minutes on requirements and never designing anything — the time-boxes exist to prevent exactly that.

  • 1. Requirements (5–10 min). Functional + non-functional + explicit scope cuts. End with a spoken summary: "So we're building X for Y users with Z constraints — agreed?"
  • 2. API & data model (5 min). 3–6 endpoints and 2–4 core entities. This forces precision: you can't hand-wave a system whose inputs and outputs you've written down.
  • 3. Capacity estimation (5 min). QPS, storage, bandwidth — rounded aggressively (Chapter 2). The point is to justify architecture choices, not to be right to two decimals.
  • 4. High-level design (10–15 min). Boxes and arrows: clients → edge → services → data stores → async pipelines. Get the whole request path on the board before drilling anywhere.
  • 5. Deep dives (15–20 min). Pick the 1–2 hardest components (or let the interviewer pick) and go deep: data model, sharding, consistency, failure modes, hot spots.
  • 6. Wrap-up (5 min). Bottlenecks, single points of failure, monitoring, what you'd do with more time. Ending with self-critique is a staff signal in itself.
45–60 minute interview timeline Requirements 5–10 min API + data 5 min Estimation 5 min High-level 10–15 min Deep dives 15–20 min Wrap-up 5 min · bottlenecks, SPOFs loop back if time remains 1 2 3 4 5 6
Real-world analogy

The framework is a pilot's pre-flight checklist. Pilots with 10,000 hours still run it, not because they'd forget the wings, but because under pressure everyone drops steps. The checklist frees your working memory for the actual hard part — the design — instead of "wait, did I ever ask about scale?" Interviewers can tell within five minutes whether you fly by checklist or by vibes.

Exactly which clarifying questions to ask

Don't ask twenty questions; ask the eight that change the architecture. Group them, fire them in one burst, and write the answers down where the interviewer can see them.

Functional

  • Who are the users? Consumers, businesses, internal services? A B2B invoicing API and a consumer feed are different planets.
  • What are the 2–3 core flows? "Post a tweet, read a timeline, follow a user" — name them and refuse everything else politely.
  • Read-heavy or write-heavy? Ask for the read:write ratio. 100:1 (feed) vs 1:1 (chat) vs 1:100 (metrics ingest) selects completely different toolkits.

Non-functional

  • Scale: DAU/MAU, items created per day, peak vs average. "100M DAU" is the number the whole design hangs off.
  • Latency SLO: p99 target for the hot path — 100 ms feed read vs 1 s report generation changes caching strategy entirely.
  • Consistency vs availability: "If the network partitions, do we show stale data or an error?" For payments the answer is usually consistency; for likes, availability.
  • Durability: Can we ever lose a write? (Payments: never. Analytics events: 0.01% loss may be fine.)
  • Cost/constraints: Greenfield or existing stack? Team size? Any compliance regime (PCI, GDPR data residency)?

Scope cutting

  • Explicitly park: auth, billing, admin tooling, ML ranking, abuse — "I'll assume an existing auth service and skip moderation unless you want it."
  • Cutting scope is a positive signal: staff engineers descope for a living. Just cut out loud and get a nod.
// Requirements checklist as a type — literally what your whiteboard
// notes column should contain after minute 10.
interface RequirementsNotes {
  functional: {
    actors: string[];              // ["end user", "creator", "internal cron"]
    coreFlows: [string, string, string?];  // force yourself to max 3
    readWriteRatio: `${number}:${number}`; // "100:1" — ask, don't guess
  };
  nonFunctional: {
    dau: number;                   // 100_000_000
    writesPerDay: number;          // 500_000_000
    latencySloMs: { p50: number; p99: number }; // hot path only
    // Discriminated union: forces you to PICK a side of CAP per flow.
    consistency:
      | { mode: 'strong'; why: string }        // "money movement"
      | { mode: 'eventual'; staleness: string } // "feed, <5s stale ok"
      | { mode: 'mixed'; strongFlows: string[] };
    durability: 'zero-loss' | 'best-effort';
    multiRegion: boolean;
    compliance?: ('PCI' | 'GDPR' | 'HIPAA')[];
  };
  outOfScope: string[];            // say these OUT LOUD and get a nod
  assumptions: string[];           // "auth exists", "images via CDN"
}

const tinyUrl: RequirementsNotes = {
  functional: {
    actors: ['anonymous reader', 'registered creator'],
    coreFlows: ['create short link', 'redirect on click', 'view stats'],
    readWriteRatio: '100:1',
  },
  nonFunctional: {
    dau: 10_000_000,
    writesPerDay: 1_000_000,
    latencySloMs: { p50: 20, p99: 100 },   // redirects must be fast
    consistency: { mode: 'eventual', staleness: 'new link live <1s' },
    durability: 'zero-loss',               // losing a link breaks the web
    multiRegion: true,
  },
  outOfScope: ['custom domains', 'abuse scanning', 'billing'],
  assumptions: ['CDN available', 'analytics can lag minutes'],
};

A concrete API sketch beats ten minutes of talk

Two minutes of typed endpoints locks down semantics — idempotency, pagination, and payload shape — that vague talk never does. Fintech habit that transfers everywhere: put the idempotency key in the write path from the first line.

// API sketch: enough to anchor the whole design, small enough for 5 min.
type Cursor = string; // opaque, encodes (shardKey, timestamp, id)

interface CreateLinkRequest {
  longUrl: string;
  customAlias?: string;
  expiresAt?: string;              // ISO 8601
  idempotencyKey: string;          // client-generated UUID — retries are
}                                  // free, duplicates are impossible

interface CreateLinkResponse {
  shortCode: string;               // "x7Kp2Q"
  shortUrl: string;                // "https://sho.rt/x7Kp2Q"
}

interface LinkStatsResponse {
  shortCode: string;
  clicks: number;                  // eventually consistent — say so!
  uniqueVisitors: number;
  window: '24h' | '7d' | 'all';
}

// The contract, as the interviewer sees it on the board:
//   POST /v1/links            -> 201 CreateLinkResponse   (write path)
//   GET  /:shortCode          -> 302 Location: longUrl    (hot path, p99 < 100ms)
//   GET  /v1/links/:code/stats?window=7d -> LinkStatsResponse
//   GET  /v1/links?cursor=... -> paginated list (cursor, not offset —
//                                offset pagination dies at scale)
interface Paginated<T> {
  items: T[];
  nextCursor: Cursor | null;       // null => end of list
}

Drive the conversation — don't be driven

  • Narrate transitions: "Requirements are locked; I'll sketch the API next, then estimate." You set the agenda; the interviewer redirects only if they need to.
  • Offer forks instead of waiting: "I can deep-dive the fan-out service or the storage layer — which is more interesting to you?" You look collaborative and in control.
  • Timestamp yourself: glance at the clock at each phase boundary. Saying "we're 20 minutes in, let me get the full picture up before drilling" is a maturity signal.
  • When interrupted, treat it as data: an interviewer's question is a hint about what they score on. Answer it fully, then explicitly return: "…and that closes the cache question; back to the write path."
  • Never go silent for more than ~20 seconds. Thinking out loud badly beats thinking silently well — silence is unscoreable.
Real-world analogy

Being driven vs driving is the difference between a taxi passenger and a tour guide. A passenger answers "where to?" one turn at a time and the interviewer does the navigating. A tour guide announces the route, points out what matters as you pass it, and adjusts when a guest asks a question — then returns to the route. Interviewers hire tour guides.

Senior vs staff: what actually differs

DimensionSolid senior answerStaff-level answer
Trade-offsNames correct technology ("use Kafka here")Articulates the axis: "Kafka buys us replay and ordering per key; the cost is operational burden and end-to-end latency. At 5k msg/s SQS is cheaper to run — I'd only take Kafka if replay is a requirement."
Failure thinkingAdds replicas and retries when promptedVolunteers failure modes unprompted: "What happens when the cache cluster dies? We'd see a thundering herd on the DB — so request coalescing plus a load-shed threshold."
Operational maturityMentions monitoring at the endDesigns for day-2: dashboards per SLO, deploy/rollback story, capacity headroom (~2× peak), on-call ergonomics, backpressure end to end.
RequirementsAccepts the prompt as givenPushes back: "Do we truly need strong consistency on read? Relaxing that to 1s staleness removes the cross-region write bottleneck entirely."
NumbersEstimates when askedEvery choice is quantified: "40k QPS peak means one beefy Postgres won't hold the write path past year one — shard now or plan the migration."
ScopeTries to cover everythingRuthlessly cuts, then nails the 1–2 genuinely hard sub-problems.

Phrases that signal seniority (use them verbatim)

  • "Given ~X QPS and a p99 of Y ms, I'd start with…" — numbers before nouns.
  • "The trade-off here is A vs B; because of requirement R, I'm taking A."
  • "This is the part I'd prototype first, because it's the riskiest assumption."
  • "If this were 10× the scale, I'd change X — but at the stated scale that's over-engineering."
  • "The failure mode I'm worried about is… and the mitigation is…"
  • "I've built something similar; the thing that bit us in production was…" — scar tissue is gold, if it's true.
Pitfalls — red flags interviewers watch for
  • Jumping to tech: saying "Kafka, Redis, Cassandra" in minute two, before any requirement exists. The #1 rejection reason.
  • Buzzword dropping: "we'll use CQRS and event sourcing" with no ability to explain what the event log stores or how reads are rebuilt.
  • No numbers: a design with zero QPS/storage math reads as pattern-matching, not engineering.
  • Ignoring failure modes: a happy-path-only design caps you at mid-level regardless of how clean it is.
  • One-size hammer: shoehorning every question into "microservices + Kafka" (or, equally, "just Postgres") no matter the requirements.
  • Not listening: interviewer hints "what about hot keys?" and you keep drawing boxes. Hints are the exam.
  • Over-designing: multi-region active-active for a 1k-DAU internal tool. Right-sizing is scored.

Map new questions onto known systems: the archetypes

You cannot prepare every question, but ~90% of prompts are re-skins of a handful of archetypes. Identify the archetype in the first ten minutes and you inherit an entire known-good reference architecture — then spend your effort on what makes this prompt different.

ArchetypeTell-tale signsReference system you knowCore components
Read-heavy feed100:1 reads, personalized lists, "timeline"Twitter / Instagram feedFan-out on write vs read, feed cache, celebrity problem, cursor pagination
Write-heavy ingestMillions of events/s, append-only, analytics laterMetrics / logging pipeline (Datadog)Log/queue (Kafka), batch+stream consumers, columnar store, downsampling
Fan-out notification"Notify N users when X happens"Notification service / pub-subTopic queues, per-channel workers (push/email/SMS), dedupe, rate limiting, preferences
Coordination / locking"Exactly one wins": bookings, flash sale, auctionTicketmaster / inventory reservationDB transactions or distributed lock, reservation TTL + expiry, idempotency, queue as fairness gate
Geo / proximity"Nearby X": drivers, restaurants, datingUber / YelpGeohash or quadtree/S2 index, moving-object updates, region sharding
URL/KV lookup at scaleTiny payload, huge read QPS, globalTinyURL / DNSID generation, KV store, aggressive cache + CDN, 301 vs 302
Chat / realtimeBi-directional, presence, ordering per conversationWhatsApp / SlackWebSocket gateways, connection registry, per-conversation ordering, inbox model, delivery receipts
Media pipelineLarge binaries, transcode, global deliveryYouTube / NetflixChunked upload, object store, async transcode DAG, CDN, adaptive bitrate
Money movementBalances, transfers, "never lose or double-count"Payment system / ledgerDouble-entry ledger, idempotency keys, sagas/outbox, reconciliation, strong consistency core
Money or "exactly one wins"? Coordination / ledger archetype yes no Realtime, bi-directional, presence? Chat / realtime (WebSockets) yes no "Nearby X" / location queries? Geo / proximity (geohash, S2) yes no Reads dominate writes (>10:1)? Feed / KV-lookup: cache + fan-out yes Ingest / pipeline: queue + stream + batch no

Whiteboard layout strategy

  • Divide the board into fixed regions before writing anything: left column = requirements + numbers (never erase it — you'll refer back constantly); top strip = API + entities; center = the architecture diagram; bottom-right = deep-dive scratch space.
  • Draw the request path left-to-right: client → CDN/edge → LB → services → stores. Async flows drop below the sync path with dashed arrows.
  • Write numbers on arrows ("40k QPS", "~2 KB"), not in the air. A diagram with QPS annotations photographs like a staff engineer's board.
  • Never erase the requirements or the high-level diagram. Deep-dive in the scratch region; redraw the one component you're zooming into.
  • Virtual interviews: same regions in Excalidraw/Miro; keep boxes coarse (10–15 max on the main diagram) and zoom with the tool, not with clutter.
Production best practice — preparing the playbook itself
  • Rehearse the 6 steps until the transitions are muscle memory — practice 3–5 full mock questions out loud, timed, ideally recorded.
  • Build one deeply-understood reference system per archetype row above; depth on nine systems beats shallow notes on fifty.
  • Memorize ~10 numbers (Chapter 2's latency table + one QPS/storage worked example) so estimation is instant.
  • Prepare 2–3 true production war stories with a metric in each ("cut p99 from 800ms to 90ms by…"). They answer "have you done this?" implicitly.
  • For staff loops, prepare to push back on one requirement per interview — practiced pushback sounds natural, improvised pushback sounds combative.
AWS mapping — the default vocabulary for your boxes
  • Edge: Route 53 (DNS), CloudFront (CDN), API Gateway or ALB (front door). Say "ALB" instead of "load balancer" and you've grounded the design for free.
  • Compute: ECS/Fargate or EKS for the stateless fleet; Lambda for spiky, event-driven glue.
  • Data: RDS/Aurora (relational), DynamoDB (KV at scale), ElastiCache Redis (cache), S3 (blobs), OpenSearch (search).
  • Async: SQS (queues), SNS/EventBridge (fan-out), Kinesis/MSK (ordered streams).
  • When to name AWS vs generic: generic first ("a distributed queue"), then instantiate ("SQS, or Kafka if we need replay"). Build custom only when a managed service demonstrably can't meet a stated requirement — and say that rule out loud.
What interviewers probe
  • Q: "Design X" with nothing else — do you start drawing? A: Never. Open with the requirements burst; the prompt's vagueness is a deliberate test of whether you scope before you build.
  • Q: "What scale should you assume?" thrown back at you. A: Propose a number and anchor: "Twitter-like, so I'll assume 200M DAU, 100:1 read:write — correct me if you want a different regime."
  • Q: "Why that database?" A: Answer from requirements, not fashion: access patterns, consistency needs, and the QPS math — then name the runner-up and why it lost.
  • Q: "What breaks first at 10×?" A: Identify the bottleneck component with a number ("the single writer at ~15k TPS"), then the specific evolution (shard, partition the queue, split the service).
  • Q: "You have 10 minutes left — what matters most?" A: Pick the highest-risk component, not the easiest; say why it's the risk.
  • Q: Deliberately impossible requirement (strong consistency + 5ms cross-region writes). A: Name the physics ("cross-region RTT alone is 50ms+"), then negotiate the requirement — that pushback IS the passing answer.
  • Q: "How would you operate this?" A: SLOs, dashboards on the golden signals, alerting on symptoms not causes, deploy + rollback story, capacity headroom, load-shedding under overload.
References
Part I · The Interview

2. Back-of-the-Envelope Estimation

Estimation is the five minutes that turns "I'd add a cache" into "at 120k reads/s and a 2 KB payload, one cache node's NIC saturates — we need a sharded cluster." Interviewers don't care whether your answer is off by 2×; they care that you can derive QPS, storage, and bandwidth from DAU in under five minutes and use the numbers to justify architecture. This chapter gives you the ~15 numbers to memorize, the four formulas, and three fully worked examples.

Latency numbers every engineer should know

These are the physics of your design — updated descendants of Jeff Dean's classic list. Memorize the orders of magnitude, not the digits: the useful fact is that RAM is ~1,000× faster than SSD and a cross-region hop costs a million times an L1 hit.

OperationLatencyRule-of-thumb takeaway
L1 cache reference~1 nsFree.
Main memory (RAM) reference~100 nsEverything in RAM is effectively instant vs I/O.
Compress 1 KB (Snappy/zstd-fast)~2–3 µsCompression is nearly always worth it before the network.
Read 1 MB sequentially from RAM~10–50 µsIn-memory scans are cheap; design around them.
NVMe SSD random read~100 µs~1,000× RAM. A cache hit saves you this, per miss.
Read 1 MB sequentially from SSD~200 µs–1 msSequential I/O >> random I/O — the reason LSM trees exist.
Round trip within a datacenter~0.5 msEach internal microservice hop costs this — hops add up.
HDD seek~10 msSpinning disks: fine for cold blobs, fatal for random hot reads.
Read 1 MB sequentially from HDD~20–30 msThroughput OK, latency terrible.
RTT same continent (e.g. NL→UK)~10–30 msRegional multi-AZ is cheap latency-wise.
RTT cross-region / cross-continent~50–150 msSpeed of light is the SLO killer — no cross-region sync calls on the hot path.
TCP + TLS handshake (cold, cross-region)~2–4 RTTs (100–400 ms)Connection reuse and edge termination matter.
Real-world analogy

Scale the numbers up by a billion: if an L1 hit is 1 second, RAM is ~2 minutes, an SSD read is ~1 day, a datacenter round trip is ~6 days, an HDD seek is ~4 months, and a cross-region round trip is 3–5 years. When you say "the service calls another region synchronously," you are proposing a multi-year voyage per request. This is why caches exist and why data locality dominates every design in this book.

Powers of two and data sizes

UnitBytes (≈)Mental anchor
1 KB (210)~103A tweet + metadata; a small JSON payload.
1 MB (220)~106A photo (compressed); ~500 pages of text.
1 GB (230)~109~1M small rows; an hour of HD video.
1 TB (240)~1012Fits on one NVMe drive; ~a day of a busy event firehose.
1 PB (250)~1015Cluster territory — object storage + sharding, not one box.
  • What fits in memory: commodity cache nodes carry 64–512 GB RAM; a modest Redis cluster holds 1–5 TB. If your hot set is <1 TB, it fits in memory — say that out loud, it kills whole classes of complexity.
  • What fits on one machine: tens of TB on disk, ~10–50k QPS for a tuned Postgres on simple queries, ~100k+ QPS for Redis. "Does it fit on one box?" is always estimation question zero.
  • Useful cheat: 210 ≈ 103, so 232 ≈ 4 billion (IDs), 264 ≈ 1.8 × 1019 (never exhausts).

The four formulas

QPS math

  • Seconds per day ≈ 86,400 — round to 100,000 (105). The single most useful rounding in interviews: it's 14% off, and 14% never changes an architecture.
  • Average QPS = DAU × actions/user/day ÷ 105. Example: 100M DAU × 10 reads/day = 109/105 = 10,000 QPS.
  • Peak QPS = 2–5× average. Use 2× for globally-spread traffic, 3× as default, 5× for lunchtime/prime-time spikes in one geography, 10×+ only for event-driven bursts (flash sale, Super Bowl). Design capacity for peak, cost for average.

Storage math

  • Raw/day = items/day × bytes/item. Then multiply by replication factor (usually 3) and retention (days, or ×365×years). Add ~20–30% for indexes and overhead if you want a flourish.

Bandwidth math

  • Egress = QPS × response bytes. 10k QPS × 2 KB = 20 MB/s = 160 Mbps — trivial. 10k QPS × 2 MB images = 20 GB/s — that's a CDN requirement, discovered in one line of math.
  • Sanity anchors: a 10 Gbps NIC ≈ 1.25 GB/s; a single cache node serving 500 MB/s is near its practical ceiling.

Memory / cache math

  • Pareto rule: ~20% of data serves ~80% of reads. Cache size ≈ 0.2 × hot dataset. Refine with TTLs: cache = QPS × bytes × TTL for pass-through caches.
// estimation.ts — the whole back-of-envelope toolkit, typed.
// Everything rounds aggressively: envelope math wants 1 significant figure.

const SECONDS_PER_DAY = 100_000; // 86,400 rounded — the interview classic

export function qps(dau: number, actionsPerUserPerDay: number): number {
  return (dau * actionsPerUserPerDay) / SECONDS_PER_DAY;
}

export function peakQps(avgQps: number, spikeFactor: 2 | 3 | 5 | 10 = 3): number {
  return avgQps * spikeFactor;
}

interface StorageOpts {
  replicationFactor?: number; // default 3 — say why: survive AZ loss
  retentionDays?: number;     // default 5 years
  indexOverhead?: number;     // default 1.25 (25% for indexes/metadata)
}

export function storageTotalBytes(
  itemsPerDay: number,
  bytesPerItem: number,
  { replicationFactor = 3, retentionDays = 5 * 365, indexOverhead = 1.25 }: StorageOpts = {},
): number {
  return itemsPerDay * bytesPerItem * retentionDays * replicationFactor * indexOverhead;
}

export function bandwidthMBps(qpsVal: number, bytesPerResponse: number): number {
  return (qpsVal * bytesPerResponse) / 1e6;
}

export function cacheSizeBytes(hotFraction: number, datasetBytes: number): number {
  return hotFraction * datasetBytes; // classic 80/20 => hotFraction 0.2
}

const fmt = (b: number): string =>
  b >= 1e15 ? `${(b / 1e15).toFixed(1)} PB`
  : b >= 1e12 ? `${(b / 1e12).toFixed(1)} TB`
  : b >= 1e9  ? `${(b / 1e9).toFixed(1)} GB`
  : `${(b / 1e6).toFixed(1)} MB`;

// --- Worked run: Twitter-scale feed ---
const DAU = 200e6;
const readQps  = qps(DAU, 20);            // 200M × 20 timeline loads / 1e5
const writeQps = qps(DAU, 1);             // ~1 tweet/user/day average
console.log(readQps);                     // 40,000 avg read QPS
console.log(peakQps(readQps));            // 120,000 peak read QPS
console.log(writeQps);                    // 2,000 avg write QPS

const tweetBytes = 300;                   // 140–280 chars + metadata + ids
const perDayRaw = DAU * 1 * tweetBytes;   // 60 GB/day of raw tweet text
console.log(fmt(storageTotalBytes(DAU * 1, tweetBytes)));
// ≈ 411 TB over 5y with 3× replication + indexes — text alone is
// "big but boring"; media is 100–1000× larger and goes to object storage.

Worked examples — do these out loud

1. Twitter-scale feed storage

  • 200M DAU, 1 tweet/user/day avg, ~300 bytes/tweet (text + metadata) → 60 GB/day raw.
  • × 3 replication × 5-year retention (~1,800 days) ≈ ~330 TB, call it 400 TB with indexes — sharded but unremarkable.
  • 10% of tweets carry a ~1 MB image: 20M × 1 MB = 20 TB/day of media → object storage + CDN, never the database. The math just partitioned your architecture for you.
  • Read side: 40k avg / 120k peak QPS — no relational DB serves that raw; you've just justified the feed cache and fan-out-on-write.

2. Concurrent connections for a chat app

  • 50M DAU, users online ~2 h/day → avg concurrent = 50M × (2/24) ≈ ~4M concurrent connections; peak (evening skew) ~2× → 8–10M.
  • A tuned WebSocket gateway holds ~100k–500k idle connections (memory-bound: ~10 KB/conn ≈ 1 GB per 100k). At 200k/box → ~50 gateway nodes at peak, plus headroom → ~75. Small fleet; the hard part is the connection registry, not the count.
  • Messages: 50M × 40 msgs/day = 2×109/day → 20k avg / ~60k peak msg/s — comfortably one Kafka cluster's territory.

3. Cache size for 20% hot data

  • Product catalog: 500M items × 2 KB = 1 TB total.
  • 80/20 rule → hot set = 0.2 × 1 TB = 200 GB → fits in one large Redis node's RAM, but run 3–4 nodes anyway: at 100k lookup QPS a single node saturates CPU/NIC before RAM, and you want failure isolation.
  • Expected hit rate ~80%+ → DB sees only 20k QPS of misses instead of 100k — this line is what justifies the cache to the interviewer.

Rounding rules and sanity checks

  • One significant figure, powers of ten. 86,400 → 105; 2.34 TB → "a few TB". Precision signals you're missing the point.
  • Round in a consistent direction for capacity: round traffic up, hardware capability down — errors then land on the safe side.
  • Sanity-check against knowns: Google Search is ~100k QPS; if your URL shortener estimate says 5M QPS, re-derive. Whole-of-Twitter writes are ~5–10k TPS; whole-of-Visa is ~2k–10k TPS average.
  • Dimensional analysis out loud: "users/day × bytes/user = bytes/day" — narrating units catches 90% of slips before the interviewer does.
  • State the conclusion, not just the number: every estimate must end in a design decision — "…therefore one Postgres is fine for year one" or "…therefore this must shard from day one."

Where latency accumulates: budget the request path

SLOs are budgets. Walk the path, add the costs, and see what's left — this diagram is worth drawing in almost any interview when someone says "p99 under 200 ms".

Client mobile, 4G CDN / edge TLS terminate LB + API app logic ~5ms Redis hit ~1ms Database miss +10ms ~40ms RTT ~10ms to region 0.5ms hop on miss (~20%) Cumulative p50: client→edge 40ms | →region 50ms | app 55ms | cache hit 56ms | return ~96ms Cache miss path adds ~10–15ms DB + serialization ⇒ ~110ms. Budget left under a 200ms SLO: ~90ms — one retry, no more hops. 1 2 3 4
  • The network to the user dwarfs everything inside your datacenter. 40 ms of last-mile RTT vs 1 ms of Redis: edge caching and payload size beat micro-optimizing service code.
  • Hops compound at the tail: five sequential internal calls at p99 5 ms each is 25 ms — and the p99 of the composition is far worse than 25 ms because tails multiply. Parallelize fan-out calls; cap sequential depth.
  • The p99 is set by the miss path, not the hit path. Always quote both.
// latency-budget.ts — compose a request path and see where the SLO goes.
interface Hop {
  name: string;
  p50Ms: number;
  p99Ms: number;
  parallel?: boolean; // parallel hops contribute max(), not sum()
}

function budget(sloMs: number, hops: Hop[]): void {
  const seq = hops.filter(h => !h.parallel);
  const par = hops.filter(h => h.parallel);
  const p50 = seq.reduce((s, h) => s + h.p50Ms, 0) + Math.max(0, ...par.map(h => h.p50Ms));
  // Crude but honest tail model: sequential p99s do NOT simply add in
  // reality (tails multiply); summing is a lower bound — say so aloud.
  const p99 = seq.reduce((s, h) => s + h.p99Ms, 0) + Math.max(0, ...par.map(h => h.p99Ms));
  console.log(`p50 ${p50}ms | p99 ≥ ${p99}ms | slack vs SLO: ${sloMs - p99}ms`);
}

budget(200, [
  { name: 'client→edge RTT', p50Ms: 40, p99Ms: 90 },
  { name: 'edge→region',     p50Ms: 10, p99Ms: 25 },
  { name: 'app logic',       p50Ms: 5,  p99Ms: 15 },
  { name: 'cache (20% miss→db)', p50Ms: 1, p99Ms: 12 },
  { name: 'user service',    p50Ms: 3,  p99Ms: 10, parallel: true },
  { name: 'ranking service', p50Ms: 8,  p99Ms: 30, parallel: true },
]);
// => p50 64ms | p99 ≥ 172ms | slack 28ms
// Conclusion you say in the interview: "the SLO is met only if ranking
// stays parallel and we allow at most ONE retry on the edge hop."
Pitfalls
  • Precision theater: computing 86,400-second days to four decimals while missing that you forgot replication (a 3× error).
  • Forgetting peak vs average: provisioning for 10k avg QPS and melting at the 40k lunchtime spike.
  • Sizing storage but not IOPS/bandwidth: "it fits on the disk" says nothing about whether the disk survives the read rate.
  • Confusing bits and bytes: a "10 Gb" NIC moves 1.25 GB/s. Off by 8 is off by an architecture.
  • Estimating and then ignoring the result: the number must drive a decision or it was theater.
  • Anchoring on stale hardware: "disks are slow" reflexes from the HDD era — NVMe changed the constants; know the 2020s numbers.
Production best practice
  • Keep a real capacity model per service (spreadsheet or code like the module above) reviewed quarterly — envelope math is the v0 of capacity planning, not a replacement for it.
  • Load-test to find each service's actual per-node ceiling; replace rules of thumb with measured numbers in your runbooks.
  • Maintain ~2× peak headroom for stateful tiers (they scale slowly) and rely on autoscaling for stateless tiers (they scale fast).
  • Set latency budgets per hop in the SLO doc, so a new downstream call is a negotiation, not an accident.
AWS mapping
  • Turning estimates into instance choices: your QPS/RAM math maps to instance families — r6g/r7g (memory: caches), c7g (compute: stateless API), i4i (NVMe: storage engines). "200 GB hot set → one r6g.8xlarge worth of RAM, but 3 nodes for isolation."
  • Storage math → tiers: S3 Standard vs Infrequent Access vs Glacier is the retention part of your storage formula turned into a price; lifecycle policies automate it.
  • DynamoDB capacity math: 1 RCU ≈ one 4 KB strongly-consistent read/s, 1 WCU ≈ one 1 KB write/s — the interview QPS math converts directly into provisioned capacity and dollars.
  • Bandwidth math → CloudFront: the moment egress crosses a few Gbps or the audience is global, the estimate itself justifies the CDN line item (and cuts S3 egress cost).
  • Validate estimates: CloudWatch metrics + AWS Cost Explorer are the ground truth your envelope math predicted; the Well-Architected cost pillar formalizes the review.
What interviewers probe
  • Q: "How many servers do you need?" A: Derive, don't guess: peak QPS ÷ measured per-node QPS (state your assumption, e.g. 5k QPS/node for this workload) + 50–100% headroom.
  • Q: "How much storage after 5 years?" A: items/day × size × 1,800 days × 3 replicas, rounded to one figure — then the decision: single DB, sharded DB, or object store.
  • Q: "Is that a lot?" (testing calibration) A: Compare to anchors: "400 TB is ~30 large NVMe boxes or a routine S3 bucket — big for Postgres, small for S3."
  • Q: "Your two estimates disagree" (they planted an inconsistency). A: Re-derive with units out loud; catching your own slip gracefully is worth more than never slipping.
  • Q: "Why cache? Prove it." A: Hit-rate math: 80% hits × 100k QPS = DB sees 20k instead of 100k, and p50 drops from ~10 ms to ~1 ms on hits.
  • Q: "What's your peak factor and why?" A: 2–3× for global consumer traffic, 5×+ for single-geo prime-time or event-driven products — pick per the product, not a fixed constant.
  • Q: "Can this be strongly consistent across regions at 50 ms p99?" A: No — cross-region RTT alone is 50–150 ms; consensus needs at least one RTT. Physics, then negotiate the requirement.
References
Part II · Foundations

3. Scaling Fundamentals — from 1 Server to Planet Scale

Every large system is a small system that survived. This chapter is the canonical evolution story — single box to active-active multi-region — plus the principles that make each step possible: statelessness, separating read scaling from write scaling, the scale cube, and knowing when not to scale. In interviews, narrating this evolution in order is how you show judgment: you add each layer of complexity only when a number forces you to.

Vertical vs horizontal scaling

  • Vertical (scale up): bigger box. Zero code changes, no distributed-systems problems, strong single-node consistency for free. Limits: a hardware ceiling (~a few hundred cores / a few TB RAM), price grows super-linearly, and it's still one blast radius — the box dies, you're down.
  • Horizontal (scale out): more boxes. Near-linear cost, no ceiling, failure of one node is survivable. Costs: you just bought load balancing, state distribution, partial failures, and consistency questions. Complexity is the price, paid forever.
  • Staff-level default: scale up until the numbers say otherwise. A single modern server handles ~10–50k QPS of real app logic and Postgres on NVMe handles low-tens-of-thousands of simple TPS — that's bigger than most businesses ever get.
  • The asymmetry that shapes everything: stateless things scale out trivially; stateful things (databases, sessions, caches) are where all the hard work lives. Hence the prime directive: push state to the edges of your architecture and keep the middle stateless.
Real-world analogy

Vertical scaling is hiring one superstar chef: the kitchen gets faster with zero coordination overhead — until you hit the best chef on Earth, and if they call in sick the restaurant closes. Horizontal scaling is hiring ten decent cooks: now you need a head chef (load balancer), shared recipes (externalized state), and rules for two cooks grabbing the same pan (locking). The food scales; the management problem is permanent.

The canonical evolution: eight stages

Tell this as a story driven by symptoms: each stage exists because the previous one hit a measurable wall. Approximate capacity signposts are per-stage guides, not laws.

Stage 1 — one box (app + DB) · ~100s QPS App + DB one server Wall: CPU contention, one failure kills everything Stage 2 — separate the DB · ~1k QPS App server Database Wall: app CPU saturates; still two SPOFs Stage 3 — LB + stateless fleet · ~10k QPS LB App 1 App N DB Requires statelessness. Wall: DB read load; repeated identical queries Stage 4 — cache + CDN · ~50k read QPS CDN App fleet Redis DB miss Wall: DB writes and remaining reads still hit one primary Stage 5 — read replicas · reads scale, writes don't Primary Replica 1 Replica N async repl. New problem: replication lag ⇒ read-your-writes needs care. Wall: write TPS Stage 6 — sharding · writes scale too Router Shard A (u0–u4) Shard B (u5–u9) Costs: no cross-shard joins/txns, resharding pain, hot shards. Choose the key carefully Stage 7 — multi-region active-passive Region A serves all traffic Region B warm standby replicate DR story: failover via DNS/routing. Know your RTO (minutes) and RPO (seconds of loss) Stage 8 — active-active · latency + availability Region A (live) US users Region B (live) EU users 2-way repl. Hard part: write conflicts. Options: geo-partition writes by user home region, CRDTs, or global consensus
  • Stage order is negotiable, walls are not. Some systems jump straight to Stage 4 (consumer apps behind a CDN from day one); nobody skips the statelessness prerequisite of Stage 3.
  • Stages 1–5 are configuration; Stages 6–8 are architecture. Replicas and caches bolt on; sharding and multi-region change your data model and your team's life. Delay them until forced — then do them deliberately.

Statelessness: the enabler of everything

  • Why: if any app node can serve any request, then adding nodes is pure capacity, deploys are rolling restarts, and a node death loses nothing. Every horizontal-scaling benefit assumes this.
  • Externalize the state: sessions → Redis/DynamoDB; uploaded files → S3; locks/leases → the database or a coordination store. The app process holds only per-request scratch.
  • Sticky sessions are the anti-pattern: pinning a user to a node via LB cookies "works" until a deploy, a scale-in event, or a node crash logs everyone out. It also skews load and blocks autoscaling from actually removing nodes. Acceptable only as a short-lived migration crutch or for genuinely connection-oriented protocols (WebSockets — and even then the state lives elsewhere; only the socket is pinned).
  • JWTs vs server-side sessions: JWTs remove the session store read but make revocation hard (you end up adding a denylist — a session store with extra steps). Server-side sessions in Redis are the boring, correct default; say the trade-off either way.
// session.ts — externalizing session state (express-style middleware).
// The node keeps NOTHING between requests; kill it mid-flight and the
// user never notices. That property is what "stateless" buys you.
import type { Redis } from 'ioredis';

interface SessionData {
  userId: string;
  roles: ('user' | 'admin')[];
  createdAt: number;            // epoch ms
  lastSeenAt: number;
}

const SESSION_TTL_S = 60 * 60 * 24;      // 24h sliding window

function key(sessionId: string): string {
  return `sess:${sessionId}`;            // namespacing: ops can SCAN sess:*
}

export function sessionMiddleware(redis: Redis) {
  return async (req: any, res: any, next: () => void): Promise<void> => {
    const sid: string | undefined = req.cookies?.sid;
    if (!sid) { req.session = null; return next(); }

    const raw = await redis.get(key(sid));      // ~1ms in-region
    if (!raw) { req.session = null; return next(); }

    const session = JSON.parse(raw) as SessionData;
    session.lastSeenAt = Date.now();

    // Sliding expiry: refresh TTL on activity. Fire-and-forget —
    // losing one refresh costs nothing, so don't block the request.
    void redis.set(key(sid), JSON.stringify(session), 'EX', SESSION_TTL_S);

    req.session = session;
    next();
  };
}

export async function createSession(
  redis: Redis, sessionId: string, userId: string, roles: SessionData['roles'],
): Promise<void> {
  const now = Date.now();
  const data: SessionData = { userId, roles, createdAt: now, lastSeenAt: now };
  await redis.set(key(sessionId), JSON.stringify(data), 'EX', SESSION_TTL_S);
}
// Sizing check (Ch. 2 math): 10M sessions × ~200B ≈ 2GB — one small
// Redis with a replica. Session stores are rarely the scaling problem.

Read scaling vs write scaling — two different toolkits

DimensionRead scalingWrite scaling
Primary toolsCaches, CDN, read replicas, indexes, denormalized/materialized viewsSharding/partitioning, queues to absorb bursts, batching, append-only (LSM) storage
DifficultyMostly bolt-on; days to weeksInvasive; changes data model and app; months
Consistency costStaleness (cache TTLs, replica lag)Loss of cross-shard transactions/joins; conflict resolution in multi-writer setups
Failure modesStampedes on cache expiry, stale reads after writeHot shards, resharding migrations, unbalanced keys
CeilingNearly unlimited (add replicas/edges)Bounded by shard key quality and coordination cost
  • Interview reflex: the moment you learn the read:write ratio (Ch. 1), you know which toolkit dominates the design. A 100:1 system is a caching problem; a 1:10 system is a partitioning problem.

The scale cube (AKF)

  • X-axis — clone it: identical stateless copies behind an LB (Stage 3). Cheapest; solves throughput, not data size.
  • Y-axis — split by function: decompose into services (orders, payments, users). Scales teams and isolates failure domains as much as it scales load.
  • Z-axis — split by data: shard by customer/key (Stage 6). Solves data size and write throughput; the most expensive axis.
  • Use the cube as a vocabulary check: "we've maxed X, this bottleneck is data volume, so it's a Z-axis problem" is a crisp staff sentence.
Real-world analogy

The scale cube is a supermarket. X-axis: open more identical checkout lanes. Y-axis: split into specialized counters — bakery, butcher, pharmacy — each with its own queue and staff. Z-axis: open branches per neighborhood, each stocking data for its local customers. Adding lanes is trivial, opening a pharmacy needs new skills, and opening branches means logistics (replication) between stores forever.

When NOT to scale

  • "You are not Google": 99% of systems live and die below 10k QPS — inside one beefy Postgres and a handful of app nodes. Kubernetes-and-Kafka for a 200-QPS product is résumé-driven design, and interviewers penalize it.
  • Premature optimization has compounding cost: every layer (shards, queues, regions) adds failure modes, on-call load, and slower feature work — permanently.
  • Profile before scaling: most "we need to scale" incidents are a missing index, an N+1 query, or an unbounded payload. A 10× win from a query plan is cheaper than any architecture change.
  • The staff move: design the seams (stateless app, data access behind a repository, IDs that don't assume one DB) so future scaling is possible — then don't build it yet. Say exactly that in the interview.

Capacity headroom and autoscaling basics

  • Headroom: run stateless tiers at ~40–60% utilization at peak; stateful tiers with ~2× headroom (they scale slowly and painfully). Headroom is what absorbs the spike while autoscaling reacts.
  • Scale on what metric? Target-tracking on CPU (~50–60%) is the default for CPU-bound services; for I/O-bound or queue-fed services use requests-per-target or queue depth ÷ processing rate. Never scale on memory for GC'd runtimes (it lies) and never on p99 latency alone (too noisy, reacts too late).
  • Physics of reaction time: metric period (60s) + evaluation + instance boot (60–300s) means autoscaling answers in minutes. Sub-minute spikes are handled by headroom and load-shedding, not by scaling.
  • Scale up fast, down slow: aggressive scale-out, conservative scale-in with cooldowns — flapping wastes more than idle capacity does.
// autoscaler.ts — a target-tracking policy evaluator (what ASG/HPA do).
interface AutoscalePolicy {
  metric: 'cpuUtilization' | 'requestsPerTarget' | 'queueDepthPerWorker';
  target: number;            // e.g. 55 (% CPU) or 800 (req/target)
  minInstances: number;
  maxInstances: number;
  scaleInCooldownS: number;  // slow down; flapping is worse than idle
  scaleOutCooldownS: number; // speed up; user pain is worse than cost
}

interface FleetState {
  instances: number;
  metricValue: number;       // current observed value of policy.metric
  lastScaleOutAt: number;    // epoch s
  lastScaleInAt: number;
}

type Decision =
  | { action: 'scale-out'; to: number; reason: string }
  | { action: 'scale-in';  to: number; reason: string }
  | { action: 'hold';      reason: string };

export function evaluate(p: AutoscalePolicy, s: FleetState, nowS: number): Decision {
  // Core of target tracking: desired = current × (observed / target).
  // 8 instances at 80% CPU targeting 55% => ceil(8 × 80/55) = 12.
  const desiredRaw = s.instances * (s.metricValue / p.target);
  const desired = Math.min(p.maxInstances, Math.max(p.minInstances, Math.ceil(desiredRaw)));

  if (desired > s.instances) {
    if (nowS - s.lastScaleOutAt < p.scaleOutCooldownS)
      return { action: 'hold', reason: 'scale-out cooldown active' };
    return { action: 'scale-out', to: desired,
             reason: `${p.metric}=${s.metricValue} above target ${p.target}` };
  }
  // Deadband: only scale in when meaningfully under target (<90% of it),
  // otherwise metric noise makes the fleet oscillate.
  if (desired < s.instances && s.metricValue < p.target * 0.9) {
    if (nowS - s.lastScaleInAt < p.scaleInCooldownS)
      return { action: 'hold', reason: 'scale-in cooldown active' };
    const to = Math.max(desired, s.instances - 1);   // step down gently
    return { action: 'scale-in', to, reason: 'sustained under target' };
  }
  return { action: 'hold', reason: 'within deadband' };
}

const decision = evaluate(
  { metric: 'cpuUtilization', target: 55, minInstances: 4, maxInstances: 40,
    scaleInCooldownS: 600, scaleOutCooldownS: 120 },
  { instances: 8, metricValue: 82, lastScaleOutAt: 0, lastScaleInAt: 0 },
  1_000,
);
// => { action: 'scale-out', to: 12, reason: 'cpuUtilization=82 above target 55' }

Decision flowchart: "the system is slow — scale what?"

Never answer "what would you scale?" with a component. Answer with this procedure: profile first, then match the bottleneck to its toolkit.

Profile first APM, slow logs, USE method DB-bound? (high DB time, IOPS) Index / fix queries first, then cache, then read replicas yes no App CPU-bound? (fleet CPU > 70%) Horizontal: more stateless nodes + LB, autoscaling policy yes no Static / media heavy? CDN + compression, cache headers, edge caching yes no Look for downstream calls / lock contention / N+1 — async them, batch them, or queue them
Pitfalls
  • Scaling the wrong tier: doubling app servers when the DB is the bottleneck just doubles the pressure on the DB. Profile first, always.
  • Sticky sessions as "state management": works in the demo, breaks on deploy/scale-in/crash, and silently defeats autoscaling.
  • Sharding before caching: taking on the hardest tool while the easy 10× (cache, indexes, replicas) is still on the table.
  • Autoscaling stateful things naively: databases don't "scale in" — removing a node means rebalancing data. Autoscaling is a stateless-tier tool.
  • Ignoring replication lag after adding replicas: user writes, next read hits a lagging replica, "my update vanished." Route read-your-writes reads to the primary or session-pin recent writers.
  • Multi-region as a checkbox: claiming active-active without an answer for write conflicts is an instant staff-level fail; say "geo-partitioned writes" or "CRDTs" or don't say active-active.
Production best practice
  • Keep the app tier stateless from day one — it costs nothing early and buys every later stage.
  • Load-test to find the real per-node ceiling and the next wall before traffic finds it for you; retest after major releases.
  • Automate failover paths and actually run them (game days). An untested standby region is a hope, not a DR plan — know your RTO/RPO numbers.
  • Set scale-out alarms below the pain threshold: you want capacity added at 60% utilization, not paged at 95%.
  • Write down the scaling plan one stage ahead ("when writes exceed 8k TPS we shard by user_id") so the migration is a scheduled project, not an incident.
AWS mapping
  • Stage 1–2: one EC2 instance → app on EC2 + managed RDS. RDS Multi-AZ gives the standby-DB failover story for free.
  • Stage 3: ALB + Auto Scaling Group of EC2, or ECS/Fargate services (no instances to manage), or Lambda for spiky/low-traffic APIs. Pick Fargate as the modern default, Lambda when traffic is bursty and per-request isolation fits, EC2/ASG when you need daemons or special hardware.
  • Stage 4: ElastiCache (Redis) for sessions and hot reads; CloudFront + S3 for static/media.
  • Stage 5: RDS/Aurora read replicas — Aurora gives up to 15 low-lag replicas off shared storage, which is why "Aurora" is the strong answer for read-heavy relational scaling.
  • Stage 6: DynamoDB partitions for you (managed Z-axis) — often the honest answer instead of hand-sharding RDS. Build custom sharding only when you need relational semantics AND write scale.
  • Stage 7–8: Route 53 failover/latency routing + health checks; Aurora Global Database (active-passive, ~1s replication, promotes in ~1 min) vs DynamoDB Global Tables (active-active, last-writer-wins conflicts — know that caveat).
  • Autoscaling: ASG/ECS target tracking is exactly the evaluator coded above; scale on CPU or ALB RequestCountPerTarget, queue-depth scaling for SQS consumers.
What interviewers probe
  • Q: "Vertical or horizontal?" A: Vertical until a number forces horizontal — then stateless-out first. Name the ceiling: hardware limit, blast radius, super-linear cost.
  • Q: "Why must the app tier be stateless?" A: So any node serves any request — that's what makes LB round-robin, autoscaling, and rolling deploys safe. State goes to Redis/S3/DB.
  • Q: "You added read replicas — what just broke?" A: Read-your-writes, via replication lag. Fix: primary reads for the writing user (short pin) or wait-for-LSN semantics.
  • Q: "When would you shard?" A: When the write path or dataset exceeds one primary after caching/replicas/indexing — and I'd give the trigger number in the design doc up front.
  • Q: "Active-active — how do you handle conflicting writes?" A: Best: avoid them — home each user's writes to one region. Else CRDTs for mergeable data, or accept LWW with explicit business sign-off on lost updates.
  • Q: "What metric do you autoscale on and why not p99?" A: CPU or requests-per-target — leading, stable indicators. p99 is noisy and lags: by the time it fires, users already hurt.
  • Q: "Would you design this for 100M users on day one?" A: No — design seams, not scale: stateless app, clean data access layer, shardable IDs. Build stage N+1 when metrics forecast the wall.
References
Part II · Foundations

4. Networking — the Life of a Request

Every system design answer ultimately rides on packets. This chapter walks one HTTPS request from a browser to a database and back — layers, DNS, TCP/TLS/HTTP internals, why things are fast or slow, the headers that actually matter, and the firewalls in the way. Staff-level candidates win interviews here by counting round trips out loud and knowing exactly which knob (keep-alive, TTL, edge, protocol version) removes each one.

The layer models: OSI 7 vs TCP/IP 4

OSI is the vocabulary; TCP/IP is what actually ships. Interviewers use OSI numbers as shorthand ("L4 vs L7 load balancer"), so you need the mapping cold — but in practice engineers only touch four layers.

OSI layerTCP/IP layerProtocolsReal exampleWhere you actually touch it
7 ApplicationApplicationHTTP, gRPC, DNS, TLS*, WebSocketGET /orders/42Daily: APIs, headers, status codes
6 PresentationTLS, JSON/Protobuf encodingSerialization, encryptionCert config, content-type bugs
5 Session(folded into app/transport)TLS session resumptionRarely — session tickets
4 TransportTransportTCP, UDP, QUICPort 443, a TCP segmentTimeouts, keep-alive, NLB, retries
3 NetworkInternetIP, ICMP, BGP10.0.1.7 → 52.94.x.xVPC CIDRs, route tables, NACLs
2 Data linkLinkEthernet, ARP, Wi-FiMAC addresses, framesAlmost never (cloud abstracts it)
1 PhysicalFiber, radio, copperPhotons in a cableOnly as latency physics
Real-world analogy

International shipping. Your letter (HTTP payload) goes into an envelope with a name (TCP port), into a courier bag with a street address (IP), into a truck routed hop-by-hop (Ethernet/links). Each layer only reads its own label and never opens the inner package — that isolation is why you can swap HTTP/1.1 for HTTP/2 without touching IP, or swap Wi-Fi for fiber without touching HTTP.

Encapsulation: how bytes physically travel

  • Your app writes an HTTP request; the kernel slices it into TCP segments (each stamped with ports + sequence number).
  • Each segment is wrapped in an IP packet (source/destination IP, TTL) — routers only ever read this header.
  • Each packet is wrapped in an Ethernet frame (MAC addresses, checksum) for the current hop; the frame is rewritten at every router, the IP packet survives end-to-end.
  • Typical MTU is 1,500 bytes, so a 100 KB response is ~70 packets — each one an independent unit that can be lost, reordered, or duplicated. TCP's whole job is hiding that.
L7 — application writes HTTP request (headers + body) L4 — kernel segments TCP hdr HTTP payload ports, seq #, ACK, window L3 — routed end-to-end IP hdr TCP hdr HTTP payload src/dst IP, TTL L2 — rewritten per hop Eth hdr IP hdr TCP hdr payload… FCS

DNS: the first round trip

Before any connection exists, the client must turn api.example.com into an IP. The strong answer narrates the full cold path and then explains why it's almost never taken.

  • 1. Stub → recursive resolver. The OS asks its configured resolver (ISP, 8.8.8.8, or the VPC's Route 53 Resolver at 10.0.0.2). If cached and TTL-fresh: done in <1 ms.
  • 2. Root servers. Cold path: the recursive resolver asks a root server, which replies "ask the .com TLD servers" (an NS referral, not the answer).
  • 3. TLD servers. The .com server refers to example.com's authoritative nameservers — for AWS-hosted zones, four Route 53 nameservers.
  • 4. Authoritative answer. Route 53 returns the A/AAAA record (or an alias to an ALB/CloudFront) plus a TTL. Every hop caches the result for that TTL.
  • TTL trade-off: 300 s is the pragmatic default — low enough to fail over in minutes, high enough that >99% of lookups hit cache. TTL 60 s or less is for records you actively fail over; TTL 86,400 s means a bad record haunts you for a day.
  • Route 53 isn't just a phone book: latency-based, geolocation, weighted, and failover routing policies make DNS your global load balancer (details in chapter 6).
Client stub Recursive resolver Root .com TLD Route 53 authoritative 1 A? api.example.com 2 cache miss → ask root referral: ask .com 3 referral: NS = Route 53 4 A 52.94.1.7, TTL 300 5 cached answer
Real-world analogy

DNS is asking a librarian (recursive resolver) for a book. Cold path: the librarian asks the national catalog (root), which points to the fiction wing (TLD), which points to the exact shelf (authoritative). The librarian then remembers the shelf for the TTL — which is why the second reader gets an instant answer, and also why moving the book (failover) takes until everyone's memory expires.

TCP: reliable delivery over an unreliable network

  • 3-way handshake (1 RTT before any data): client sends SYN (with a random initial sequence number), server replies SYN-ACK, client sends ACK — and can attach data to it. Both sides now agree on sequence numbers.
  • Reliability: every byte is sequenced; receiver ACKs; sender retransmits on timeout or 3 duplicate ACKs. Lost packet = stall + retransmit, not corruption.
  • Flow control (protect the receiver): the receiver advertises a window ("I can buffer 64 KB") in every ACK; a slow consumer shrinks it, possibly to zero — this is backpressure at L4.
  • Congestion control (protect the network): slow start begins with ~10 packets (initcwnd) and doubles the congestion window every RTT until loss, then backs off (CUBIC/BBR). Consequence: a new connection can't use your bandwidth for the first several RTTs — the #1 reason connection reuse matters.
  • Teardown: FIN/ACK in each direction; the closer parks in TIME_WAIT for ~60 s to absorb stragglers. Servers that open one connection per request exhaust ports on TIME_WAIT — another argument for pooling.

UDP vs TCP — when each

TCPUDP
GuaranteesOrdered, reliable, exactly-once byte streamFire-and-forget datagrams; loss/reorder is your problem
Setup cost1 RTT handshake + slow startNone — first packet is data
Head-of-line blockingYes — one lost packet stalls the streamNo — each datagram independent
Use whenCorrectness beats latency: HTTP, DB wire protocols, paymentsFreshness beats completeness: DNS, video/voice (RTP), game state, metrics (StatsD), QUIC
Losing a packet meansWait for retransmitSkip it — yesterday's frame is worthless anyway

QUIC and HTTP/3

  • QUIC rebuilds TCP's reliability per-stream in userspace over UDP — chosen precisely because middleboxes and kernels ossified TCP; UDP passes through everywhere and Google could iterate without waiting for OS upgrades.
  • Transport + TLS 1.3 handshake are fused: 1 RTT cold, 0-RTT resumed (send the request with the first packet).
  • A lost packet stalls only its own stream — kills TCP's head-of-line blocking, which is why HTTP/3 shines on lossy mobile networks.
  • Connection ID instead of the 4-tuple: switching Wi-Fi → LTE migrates the connection instead of resetting it.

TLS: the handshake tax

  • TLS 1.2: 2 RTTs — hello/cipher negotiation, then certificate + key exchange, then finished. Only then application data.
  • TLS 1.3: 1 RTT — client guesses the key-exchange group and sends its key share in the first flight; server responds with cert + finished. Weak ciphers (RSA key exchange, CBC) are simply removed, and forward secrecy is mandatory.
  • Session resumption: a returning client presents a session ticket (PSK) and skips certificate verification; TLS 1.3 even allows 0-RTT early data — with a catch: 0-RTT requests are replayable, so only idempotent GETs belong there. Never a payment POST.
  • Certificate chain validation, OCSP, and SNI all ride inside the handshake — SNI is why one IP can serve many hostnames, and why the ALB/CloudFront picks a cert before seeing HTTP.

HTTP/1.1 vs HTTP/2 vs HTTP/3

  • HTTP/1.1: one in-flight request per connection (pipelining is dead in practice). Browsers hack around it with 6 parallel connections per host; ops hack around it with domain sharding and sprite sheets. Application-level head-of-line blocking: a slow response blocks the connection.
  • HTTP/2: one TCP connection, many interleaved binary streams (multiplexing) + header compression (HPACK). Kills app-level HOL and the 6-connection hack — but inherits TCP-level HOL: one lost packet stalls all streams, because TCP delivers bytes in order regardless of which stream they belong to.
  • HTTP/3: same semantics over QUIC — multiplexing without TCP HOL, faster handshakes, connection migration. Semantics (methods, status codes, headers) are identical across all three; only the framing changes.
  • Backend reality: your CDN speaks h2/h3 to browsers and typically plain HTTP/1.1 over warm keep-alive connections to origin — the client-side wins matter most on high-latency last miles.

Why is something fast vs slow?

This is the section to internalize. Almost every "why is it slow" answer reduces to: count the round trips, then check payload size, then check server time — in that order.

  • Latency ≠ bandwidth. Latency is how long one bit takes to arrive (physics: ~1 ms per 100 km in fiber; NYC↔Sydney ~200 ms RTT and no product manager can fix the speed of light). Bandwidth is how many bits per second flow once moving. Upgrading a 1 Gbps to 10 Gbps link does nothing for a 2 KB API call — its cost is pure RTTs.
  • RTT accounting for a cold HTTPS request: DNS (1 RTT to resolver, more if cold) + TCP handshake (1) + TLS 1.3 (1; TLS 1.2: 2) + HTTP request/response (1) ≈ 4 RTTs minimum. At 150 ms RTT that's 600 ms before the first byte of your 5 ms backend response. Same request on a reused connection: 1 RTT = 150 ms. Same request to an edge 15 ms away, warm: 15 ms. That's a 40× spread with identical code.
  • Connection reuse is the highest-leverage fix: keep-alive, HTTP/2 multiplexing, and client-side pooling skip DNS + TCP + TLS and keep the congestion window warm (a fresh connection is also slow-start throttled). Lambda→RDS without a pool re-pays this tax on every invocation — hence RDS Proxy.
  • Nagle's algorithm: the kernel batches small writes until the previous ACK returns; combined with delayed ACKs it injects up to ~40–200 ms stalls into chatty request/response protocols. Every serious server/DB client sets TCP_NODELAY (Node's socket.setNoDelay()).
  • TCP head-of-line blocking: ordered delivery means one lost packet at position n quarantines every received packet after it. On a 1%-loss mobile link this dominates tail latency — the p99 killer that HTTP/3 exists to fix.
  • Nearby edge beats fatter pipes: for small payloads, total time ≈ RTT × round-trips. A CDN edge 15 ms away that terminates TLS and holds warm origin connections converts a 4-RTT × 150 ms cold start into 4 × 15 ms + one warm origin hop. This is why chapter 5 exists.
Real-world analogy

Latency vs bandwidth: a truck full of backup tapes driving cross-country has monstrous bandwidth (petabytes/trip — this is literally AWS Snowball) and atrocious latency (3 days to the first byte). A phone call has tiny bandwidth and great latency. Chatty protocols are like mailing a letter and waiting for a reply before mailing the next — the fix isn't a bigger mailbox (bandwidth), it's fewer exchanges (round trips) or a closer pen pal (edge).

// Typed fetch wrapper: conditional requests with ETag + 304 handling.
// Demonstrates the caching headers from the table below, in code.
interface CacheEntry<T> {
  etag: string;
  value: T;
  storedAt: number;
}

type FetchResult<T> =
  | { source: "network"; status: 200; value: T }
  | { source: "cache";   status: 304; value: T }; // revalidated, body skipped

class ConditionalClient {
  private cache = new Map<string, CacheEntry<unknown>>();

  async getJson<T>(url: string): Promise<FetchResult<T>> {
    const cached = this.cache.get(url) as CacheEntry<T> | undefined;
    const headers = new Headers({ accept: "application/json" });
    // If we have a prior ETag, ask the server "only send the body if it changed".
    if (cached) headers.set("if-none-match", cached.etag);

    const res = await fetch(url, { headers });

    if (res.status === 304) {
      if (!cached) throw new Error("304 without a cached entry — server bug or evicted cache");
      // Server confirmed our copy is fresh: zero bytes of body crossed the wire.
      return { source: "cache", status: 304, value: cached.value };
    }
    if (!res.ok) throw new Error(`GET ${url} failed: ${res.status}`);

    const value = (await res.json()) as T;
    const etag = res.headers.get("etag");
    if (etag) this.cache.set(url, { etag, value, storedAt: Date.now() });
    return { source: "network", status: 200, value };
  }
}

// Usage: second call sends If-None-Match and typically pays only ~1 RTT + headers.
const api = new ConditionalClient();
interface Price { symbol: string; usd: number }
const first  = await api.getJson<Price>("https://api.example.com/price/BTC"); // 200, full body
const second = await api.getJson<Price>("https://api.example.com/price/BTC"); // 304, cached body
console.log(first.source, second.source); // "network" "cache"

The headers that actually matter

Caching

HeaderWhat it doesStaff-level note
Cache-Controlmax-age (browser), s-maxage (CDN), no-store, private, immutable, stale-while-revalidateThe contract for every cache between you and the user. no-cache means "revalidate", not "don't cache".
ETag / If-None-MatchContent fingerprint; enables 304 revalidationAlso doubles as an optimistic-concurrency token via If-Match on writes.
Last-Modified / If-Modified-SinceTimestamp-based revalidation1-second granularity; ETag wins when both present.
VaryAdds request headers to the cache keyVary: Accept-Encoding is mandatory with compression; Vary: Cookie effectively disables shared caching.

Content negotiation

HeaderWhat it does
Accept / Accept-Encoding / Accept-LanguageClient's wishes: media type, compression (gzip/br), language.
Content-TypeWhat the body actually is (+charset). Wrong value = parsing bugs and XSS vectors.
Content-EncodingApplied compression. Brotli cuts JSON ~15–25% vs gzip.

Auth & cookies

HeaderWhat it doesStaff-level note
AuthorizationBearer <JWT>, Basic, SigV4Explicitly attached by code — immune to CSRF by construction.
Cookie / Set-CookieServer-set state auto-attached by the browserAuto-attach is the convenience and the CSRF risk.
Cookie attributesHttpOnly (no JS access — XSS can't steal it), Secure (HTTPS only), SameSite=Lax|Strict|None (CSRF defense), Domain/Path, Max-AgeSession cookie baseline: HttpOnly; Secure; SameSite=Lax. SameSite=None requires Secure.

CORS

  • CORS is a browser-enforced read guard, not a server firewall — curl ignores it entirely. The server opts pages from other origins in.
  • "Simple" requests (GET/POST with vanilla headers) fly directly; the browser just checks Access-Control-Allow-Origin on the response before releasing it to JS.
  • Anything else — Authorization header, Content-Type: application/json, PUT/DELETE — triggers a preflight: an OPTIONS request asking permission. That's a whole extra RTT; cache the verdict with Access-Control-Max-Age: 86400.
  • Key response headers: Access-Control-Allow-Origin (echo one origin, not *, when using credentials), -Allow-Methods, -Allow-Headers, -Allow-Credentials, -Expose-Headers.
Browser JS origin: app.example.com API api.example.com 1 OPTIONS /orders — Origin, Access-Control-Request-Method: PUT 2 204 — Allow-Origin: app.example.com, Allow-Methods: PUT, Max-Age: 86400 3 PUT /orders/42 (actual request, with credentials) 4 200 — Allow-Origin echoed; browser releases response to JS

Security & infrastructure

HeaderGroupWhat it does
Strict-Transport-SecuritySecurityBrowser refuses HTTP for this host for max-age seconds — kills downgrade/SSL-strip attacks.
Content-Security-PolicySecurityWhitelist of script/style/img sources — the strongest XSS mitigation.
X-Frame-Options / CSP frame-ancestorsSecurityBlocks clickjacking via hostile iframes.
HostInfraWhich virtual host on a shared IP — routing key for every proxy and the ALB.
X-Forwarded-For / -ProtoInfraReal client IP/scheme after proxies. Trust only the last hop you control — clients can forge the rest.
Connection: keep-aliveInfraHTTP/1.1 connection reuse (default). Hop-by-hop, stripped by proxies; gone in h2/h3.

Cookies vs tokens

  • Cookies: browser-managed, auto-attached, HttpOnly shields them from XSS — but auto-attach means you must defend CSRF (SameSite + anti-CSRF tokens). Natural fit for same-site web apps and server-rendered sessions.
  • Bearer tokens (JWT): code-attached (CSRF-immune), work for mobile/M2M/cross-domain, self-contained claims avoid a session-store lookup — but JS-readable storage is XSS-stealable, and stateless tokens can't be revoked before expiry (mitigate with short-lived access tokens ~5–15 min + refresh-token rotation).
  • Pragmatic hybrid for SPAs: JWT delivered in an HttpOnly; Secure; SameSite=Lax cookie — XSS-resistant storage, CSRF-resistant attribute, stateless verification.

Firewalls, security groups, NAT

  • Stateless filtering evaluates each packet alone against rules — so you must open both directions, including high-numbered ephemeral return ports. Stateful filtering tracks connections: allow the inbound request and the response is automatically permitted.
  • WAF is L7: it reads the HTTP itself — SQLi/XSS signatures, bot fingerprints, per-IP rate rules, geo blocks. AWS WAF attaches to CloudFront, ALB, or API Gateway. It complements, never replaces, SGs.
  • NAT & private subnets: app servers and DBs live in private subnets with no public IPs — unroutable from the internet by construction. Outbound calls (package repos, Stripe) traverse a NAT gateway that rewrites source addresses and statefully maps returns. One NAT GW per AZ; NAT data-processing charges are a classic surprise line item (VPC endpoints for S3/DynamoDB dodge them).
Security groupNetwork ACL
Attaches toENI (instance/task/pod)Subnet
StateStateful — replies auto-allowedStateless — must allow ephemeral return ports
RulesAllow onlyAllow + deny, evaluated by rule number
Killer featureReference other SGs: "DB accepts 5432 only from app-SG"Explicit denies: blacklist a CIDR at the subnet edge
Default postureDeny all inboundAllow all (default NACL)
Use asPrimary control, per-tier micro-segmentationCoarse backstop / compliance guardrail

Networking in code

// Tiny TCP echo server + client with node:net — the handshake made tangible.
// `nc localhost 4000` works against it too.
import net from "node:net";

const server = net.createServer((socket: net.Socket) => {
  // This callback fires only AFTER the kernel completed SYN → SYN-ACK → ACK.
  // The 3-way handshake already cost 1 RTT before we see any bytes.
  socket.setNoDelay(true); // disable Nagle: don't batch our small writes
  console.log(`established: ${socket.remoteAddress}:${socket.remotePort}`);

  socket.on("data", (chunk: Buffer) => {
    // TCP is a BYTE STREAM, not a message stream: one client write() may
    // arrive as several 'data' events, or several writes as one. Real
    // protocols add framing (length prefix / newline) on top.
    socket.write(chunk); // echo back
  });
  socket.on("end", () => socket.end()); // peer sent FIN; send ours (teardown)
});
server.listen(4000);

// --- client ---
const client = net.createConnection({ port: 4000 }, () => {
  // 'connect' = our ACK is sent; connection is ESTABLISHED.
  client.setNoDelay(true);
  client.write("ping over a fresh connection\n");
});
client.on("data", (buf: Buffer) => {
  console.log("echo:", buf.toString().trim());
  client.end(); // FIN → server ACK+FIN → our ACK; we hold TIME_WAIT ~60s
});
// Connection pooling with undici: pay DNS + TCP + TLS + slow-start ONCE,
// then multiplex thousands of requests over warm connections.
import { Pool } from "undici";

const upstream = new Pool("https://payments.internal.example.com", {
  connections: 32,          // cap concurrent sockets to this origin
  pipelining: 1,            // 1 = safe request/response; h2 would multiplex
  keepAliveTimeout: 30_000, // hold idle sockets 30s — must be BELOW the
                            // server/LB idle timeout (ALB default 60s), or
                            // you'll write into a socket the peer just closed
  connectTimeout: 3_000,
  bodyTimeout: 5_000,
});

interface ChargeRequest { orderId: string; amountMinor: number; currency: "USD" | "EUR" }
interface ChargeResponse { chargeId: string; status: "captured" | "declined" }

export async function charge(req: ChargeRequest): Promise<ChargeResponse> {
  const { statusCode, body } = await upstream.request({
    path: "/v1/charges",
    method: "POST",
    headers: {
      "content-type": "application/json",
      "idempotency-key": req.orderId, // safe retries over flaky networks (ch. 12)
    },
    body: JSON.stringify(req),
  });
  if (statusCode >= 500) throw new Error(`upstream ${statusCode}`); // retryable
  if (statusCode >= 400) throw new Error(`charge rejected: ${statusCode}`); // don't retry
  return (await body.json()) as ChargeResponse;
}
// Warm-pool p50 ≈ 1 RTT + server time. Cold socket per request ≈ 3-4 RTTs extra
// — at 40ms RTT that's ~120ms of pure, avoidable handshake tax per call.

Putting it together: life of a request

Browser DNS resolver → Route 53 CDN edge CloudFront · TLS term AWS region · VPC ALB L7 · public subnet App service private subnet PostgreSQL pooled conns 1 resolve → edge IP (~1 RTT, cached) 2 TCP + TLS 1.3 to NEAR edge 3 cache miss: warm conn to origin 4 route + LB 5 query 6 response back out, edge caches per Cache-Control
AWS mapping
  • Route 53 — DNS + routing policies (latency, geo, weighted, failover) and health checks; alias records point at ALB/CloudFront for free.
  • VPC — your L3 world: subnets, route tables, internet/NAT gateways; security groups (stateful, per-ENI) and NACLs (stateless, per-subnet).
  • CloudFront — TLS termination at the edge, HTTP/3 to clients, warm connections to origin; ACM issues and rotates the certs.
  • ALB (L7, HTTP-aware) vs NLB (L4, millions of conns, static IPs) — full decision treatment in chapter 6.
  • AWS WAF + Shield — L7 filtering and DDoS absorption in front of CloudFront/ALB/API Gateway.
  • Global Accelerator — anycast IPs put clients onto the AWS backbone at the nearest edge: CDN-like RTT wins for non-cacheable/TCP traffic.
  • Build custom only at the app layer (retry budgets, pools, hedging); never rebuild DNS, TLS, or firewalls yourself.
What interviewers probe
  • Q: "Walk me through what happens when you type a URL." A: Don't recite trivia — count cost: DNS (~0–1 RTT cached), TCP (1), TLS 1.3 (1), HTTP (1+); then name the mitigations per step: caching/TTLs, keep-alive, session resumption, CDN, HTTP/3 0-RTT.
  • Q: Why is my API slow from Sydney but fast from Virginia? A: RTT physics × round-trip count. Fixes ordered by leverage: reuse connections, terminate TLS at edge (CloudFront/Global Accelerator), cut payloads, and only then consider a second region.
  • Q: HTTP/2 fixed head-of-line blocking — true? A: Only at the application layer. One lost TCP packet still stalls every h2 stream; HTTP/3/QUIC fixes it by making streams independently reliable over UDP.
  • Q: Why does the very first request after deploy spike latency? A: Cold everything: DNS cache, TCP handshake, TLS full handshake, slow-start window, empty app/DB pools, cold CDN. Warmup and pre-established pools are the fix.
  • Q: Cookies or JWTs for the SPA? A: Discuss the threat model: cookies risk CSRF (fix: SameSite), tokens in JS risk XSS theft; strong answer is JWT inside an HttpOnly+Secure+SameSite cookie, short expiry, refresh rotation.
  • Q: Does CORS protect my API from attackers? A: No — it's a browser courtesy; curl skips it. Server-side authN/Z and CSRF defenses do the protecting; CORS just lets legitimate cross-origin pages read responses.
  • Q: Security group vs NACL? A: SG = stateful, per-ENI, allow-only, can reference other SGs (primary tool); NACL = stateless, per-subnet, supports deny (coarse backstop).
  • Q: When pick UDP? A: When a late packet is worth less than no packet — live media, game state, DNS, telemetry — or when building a smarter transport on top (QUIC).
Pitfalls
  • Blaming bandwidth for latency problems — a fatter pipe does nothing for a 4-RTT, 2 KB exchange.
  • Client keep-alive timeout longer than the server/LB idle timeout → sporadic ECONNRESET as you write into half-closed sockets. Always keep client < server (ALB default 60 s).
  • Forgetting Vary: Accept-Encoding (or Origin for per-origin CORS responses) — caches serve gzip to clients that can't decode it, or the wrong CORS header to the wrong site.
  • Trusting the whole X-Forwarded-For chain for rate limiting or auth — clients forge it; trust only the hop appended by your own proxy.
  • Enabling TLS 1.3 0-RTT for mutating endpoints — early data is replayable; keep it for idempotent GETs.
  • Setting DNS TTL to 24 h "for cache efficiency", then discovering failover takes a day.
  • One connection per request under load: TIME_WAIT port exhaustion plus a permanent slow-start tax.
Production best practice
  • Set explicit timeouts on every hop — connect (~3 s), request (~5–10 s) — and make them shrink as you go deeper (edge 30 s → app 10 s → DB 5 s) so inner layers fail before outer ones give up.
  • Pool and reuse connections everywhere: HTTP clients, DB drivers, Redis; use RDS Proxy for Lambda.
  • Default response headers on every service: Cache-Control (explicit, even if no-store), HSTS, CSP, correct Vary.
  • Measure TTFB split by phase (DNS/connect/TLS/wait) — browser resource timing or curl -w — before guessing which layer is slow.
  • TLS 1.3 + HTTP/2 minimum at the edge; enable HTTP/3 on CloudFront (a checkbox) for mobile-heavy traffic.
References
Part II · Foundations

5. CDN & Edge

Chapter 4 established that round trips to a distant origin dominate latency. A CDN attacks the problem at the root: move the answer — or at least the TLS/TCP termination — to within ~20 ms of every user. This chapter covers caching mechanics (keys, TTLs, invalidation), what's actually cacheable (more than you think), dynamic acceleration, edge compute, and protecting private content. Interviewers love CDNs because they expose whether you understand cache keys and invalidation, not just "put CloudFront in front".

Why CDNs: you can't patch the speed of light

  • Light in fiber travels ~200,000 km/s → ~1 ms per 100 km one way, before adding router hops and congestion. São Paulo → Virginia is ~140 ms RTT on a good day.
  • A cold HTTPS fetch costs ~4 RTTs (DNS + TCP + TLS + HTTP). At 140 ms that's ~560 ms; from an edge 15 ms away it's ~60 ms. Same bytes, ~10× faster — no code changed.
  • A CDN is thousands of edge locations (CloudFront: 600+) that (a) serve cached responses locally and (b) terminate connections locally even when they can't cache.
  • Secondary wins: origin offload (90%+ of static traffic never reaches you), DDoS absorption at the edge, and a global anycast front door.
Without CDN — Sydney user, us-east-1 origin (RTT ~200 ms) Browser Origin (us-east-1) DNS + TCP + TLS + HTTP ≈ 4 × 200 ms ≈ 800 ms TTFB With CDN — same user, edge in Sydney (RTT ~15 ms), warm origin path Browser Edge (Sydney) TLS terminates here Origin (us-east-1) 4 × 15 ms ≈ 60 ms miss only: warm keep-alive, ~1 × 200 ms HIT: ~60 ms TTFB (13× faster) · MISS: ~260 ms (still 3× faster — handshakes stayed local) Mental model: hundreds of edge dots on a world map, all announcing the same anycast IPs — users land on the nearest dot.
Real-world analogy

A CDN is a convenience-store chain vs one central warehouse. Milk (popular static assets) is stocked at every corner store — you fetch it in minutes. Special orders (cache misses) still come from the warehouse, but on the store's scheduled delivery truck (warm origin connection), not your personal cross-country drive (cold handshakes). Push vs pull: push is the warehouse pre-stocking every store; pull is the store ordering an item the first time a customer asks, then keeping it on the shelf.

Pull vs push CDNs

Pull (origin-pull)Push (pre-upload)
How content arrivesEdge fetches from origin on first miss, then cachesYou upload to CDN storage ahead of time
First requestSlow (miss penalty pays full origin trip)Fast — already at edge/regional store
Ops burdenMinimal — set origin + TTLsYou manage uploads, sync, cleanup
FitsLong-tail catalogs, websites, APIs — the default (CloudFront, Fastly, Cloudflare)Predictable large blobs: video launches, game patches, installers
Storage costOnly hot content occupies edge cache (LRU eviction)Pay to store everything, including cold objects

Cache keys and normalization

The cache key is the identity of a cached object — by default roughly host + path (+ selected query strings/headers/cookies). Getting it wrong causes the two classic CDN failures:

  • Key too broad → serving user A's private response to user B (cache poisoning territory). E.g. caching /me without keying on the session.
  • Key too narrow → fragmentation: ?utm_source=twitter vs ?utm_source=email become different objects, hit ratio collapses, origin melts.
  • Normalize aggressively: strip marketing params, sort query strings, lowercase where safe, collapse Accept-Encoding variants to gzip/br buckets. Include only what changes the response (CloudFront cache policies express exactly this).
  • Vary (ch. 4) is the origin's way to add request headers into the key — use surgically; Vary: Cookie ≈ cache disabled.

TTLs and the freshness ladder

  • Cache-Control: s-maxage=600, max-age=60 — CDN keeps 10 min, browsers 1 min. s-maxage exists because you can purge shared caches but not browsers: give the tier you control the longer TTL.
  • stale-while-revalidate=30 — serve the stale copy instantly while refetching in the background: users never pay the miss penalty on refresh; latency stays flat across TTL expiry.
  • stale-if-error=86400 — origin down? Serve yesterday's copy rather than a 502. Free resilience; your origin can burn for a day while the site stays up.
  • Pattern: short-ish s-maxage + generous stale-* directives beats a long TTL — freshness within minutes, availability in hours.

Invalidation: version, don't purge

  • Versioned URLs / content hashing (preferred): app.3f2a91.js — new deploy → new hash → new cache key. Old and new coexist (no mid-deploy mixed pages), "invalidation" is instant and atomic, and assets become Cache-Control: public, max-age=31536000, immutable. Every modern bundler does this for free.
  • Purge/invalidation API (fallback): for URLs you can't version — /, /sitemap.xml, an urgent legal takedown. CloudFront invalidations take seconds-to-minutes to propagate and cost after the first 1,000 paths/month; wildcard /* is the sledgehammer.
  • Surrogate keys / cache tags (Fastly, Cloudflare): tag responses (product-42), purge by tag when that product changes — the precision tool for cached APIs.
  • Rule of thumb: if you're purging on every deploy, your URLs should have been versioned.

What to cache (more than static files)

  • Obvious: JS/CSS/images/fonts — hashed, immutable, year-long TTL.
  • API responses — yes, really: anything shared across users and tolerant of brief staleness: product catalogs, search suggestions, FX rates, config/flags, leaderboards. Even s-maxage=1 with request collapsing turns 10,000 req/s into ~1 origin req/s per key — a 5-second-stale price list is fine; a melted origin is not.
  • Video: HLS/DASH segments are just small immutable files — this is how Netflix-scale delivery works. Keep manifests short-TTL (live) and segments long-TTL; protect them with signed URLs/cookies.
  • Never: per-user personalized responses (unless keyed per-user at edge — usually not worth it), anything with Set-Cookie, payments/auth endpoints. Mark them Cache-Control: private, no-store explicitly.

Dynamic acceleration: CDN value with zero caching

  • Even for no-store APIs, the edge terminates TCP + TLS ~15 ms from the user — the 3–4 cold handshake RTTs happen locally instead of across an ocean.
  • Edge → origin rides warm, pooled, long-lived connections over the provider's backbone (often better routes than the public internet); slow-start is already paid.
  • Result: 20–40% TTFB cuts on fully dynamic traffic. This is CloudFront-in-front-of-ALB, or AWS Global Accelerator when you want pure L4 anycast without HTTP awareness.
  • Origin shield: one designated regional cache between all edges and your origin. N edges no longer each miss independently — misses funnel through the shield, collapsing duplicate fetches. Origin sees ~1 fetch per object per TTL instead of hundreds; also the fix for "every PoP hammers my origin at TTL expiry".

Edge compute: running code at the PoP

CloudFront FunctionsLambda@EdgeWorkers-style (Cloudflare/Fastly)
RuntimeSlim JS, no network calls, ~1 ms budget, sub-ms startFull Node/Python, network + body access, ~5 s (viewer) / 30 s (origin) limitsV8 isolates, ~0 ms cold start, full fetch + KV/storage access
Runs atAll 600+ edge locationsRegional edge caches (~13)All PoPs
TriggersViewer request/response onlyViewer + origin request/responseAny request, full app logic
Use forHeader rewrites, redirects, cache-key normalization, A/B bucket assignment, JWT checkAuth with a network call, origin selection/failover, geo-routing, response personalization on missEntire APIs at edge, rendering, edge state
Cost model~$0.10/M invocationsLambda pricing × regions (noticeably pricier)Per-request, generous free tier
  • Canonical edge jobs: auth at edge (verify JWT signature, reject before origin), A/B assignment (sticky cookie + cache-key variant), geo-routing (CloudFront-Viewer-Country → origin or redirect), header hygiene (normalize before the cache key is computed — order matters: viewer-request functions run before cache lookup).
// Lambda@Edge-style viewer-request handler: sticky A/B assignment.
// Runs BEFORE cache lookup, so the variant cookie can join the cache key
// (configure the cache policy to include the "ab" cookie) — each variant
// caches independently and stays fast.
import type { CloudFrontRequestEvent, CloudFrontRequest } from "aws-lambda";

const EXPERIMENT = { name: "checkout_v2", variants: ["control", "treatment"] as const };
type Variant = (typeof EXPERIMENT.variants)[number];

function readAbCookie(req: CloudFrontRequest): Variant | undefined {
  const raw = req.headers["cookie"]?.map(h => h.value).join("; ") ?? "";
  const m = raw.match(/(?:^|;\s*)ab=([^;]+)/);
  return EXPERIMENT.variants.find(v => v === m?.[1]);
}

export const handler = async (event: CloudFrontRequestEvent): Promise<CloudFrontRequest> => {
  const request = event.Records[0].cf.request;

  // Sticky: returning users keep their bucket (stable UX + valid stats).
  let variant = readAbCookie(request);
  if (!variant) {
    // 50/50 assignment; deterministic hashing on a user id is even better.
    variant = Math.random() < 0.5 ? "control" : "treatment";
    // Normalize the cookie header so the cache key sees exactly one form.
    request.headers["cookie"] = [{ key: "Cookie", value: `ab=${variant}` }];
  }

  // Route treatment to a different origin path — same URL for the user.
  if (variant === "treatment" && request.uri.startsWith("/checkout")) {
    request.uri = `/v2${request.uri}`;
  }
  return request; // continue to cache lookup → origin on miss
};

Private content: signed URLs and signed cookies

  • Problem: paid video / private docs must flow through the CDN (offload!) but not be shareable links.
  • Signed URL: URL + policy (expiry, optional IP/path constraints) + signature from your private key; the edge verifies with the public key — no call to your origin. One URL per object; ideal for a single download.
  • Signed cookies: same trust model, but the policy rides in cookies covering a path pattern (/videos/course-42/*) — one grant for thousands of HLS segments without rewriting every URL in the manifest.
  • Keep expiries short (minutes–hours) and combine with S3 Origin Access Control so nobody can bypass CloudFront and hit the bucket directly.
// CloudFront signed-URL generator (canned policy): time-boxed access to a
// private object, verified AT THE EDGE with zero origin involvement.
import { createSign } from "node:crypto";

interface SignedUrlInput {
  url: string;            // e.g. https://d111abc.cloudfront.net/videos/ep1/seg-001.ts
  keyPairId: string;      // public key id registered with the distribution
  privateKeyPem: string;  // from Secrets Manager — never bundle in code
  expiresInSeconds: number;
}

// CloudFront's URL-safe base64: + → -, = → _, / → ~
const cfSafe = (b64: string) => b64.replace(/\+/g, "-").replace(/=/g, "_").replace(/\//g, "~");

export function signUrl(input: SignedUrlInput): string {
  const expires = Math.floor(Date.now() / 1000) + input.expiresInSeconds;

  // Canned policy: this exact resource, valid until `expires`. Custom
  // policies add IP ranges and start times — same signing flow.
  const policy = JSON.stringify({
    Statement: [{
      Resource: input.url,
      Condition: { DateLessThan: { "AWS:EpochTime": expires } },
    }],
  });

  const signature = createSign("RSA-SHA1") // CloudFront's required scheme
    .update(policy)
    .sign(input.privateKeyPem, "base64");

  const sep = input.url.includes("?") ? "&" : "?";
  return `${input.url}${sep}Expires=${expires}` +
         `&Signature=${cfSafe(signature)}` +
         `&Key-Pair-Id=${input.keyPairId}`;
}

// 10-minute grant: plenty for a player to fetch the segment, useless to share.
const url = signUrl({
  url: "https://d111abc.cloudfront.net/videos/ep1/seg-001.ts",
  keyPairId: "K2JCJMDEHXQW5F",
  privateKeyPem: process.env.CF_SIGNING_KEY!,
  expiresInSeconds: 600,
});

Decision: should this go through the CDN / be cached at edge?

Response identical for many users? yes no (per-user) no-store; still proxy via CDN* Staleness OK for seconds+? yes no Dynamic accel only (TLS at edge) URL versionable (content hash)? yes Immutable + max-age=1y no Private / paid content? no s-maxage + SWR + stale-if-error yes Cache + signed URLs / cookies * per-user APIs still gain from edge TLS termination, WAF, and warm origin connections — route them through the CDN uncached.
AWS mapping
  • CloudFront — the CDN: pull-through caching, HTTP/3, origin shield, signed URLs/cookies, field-level encryption. Pair with S3 + Origin Access Control for static, ALB origin for dynamic.
  • CloudFront Functions for sub-ms header/cookie/URL logic at every edge; Lambda@Edge when you need network calls or body access. Pick Functions first — 6× cheaper, faster.
  • Cache/Origin-request policies — the cache key lives here (query/header/cookie allowlists). This is where normalization is configured, not in app code.
  • Global Accelerator — anycast L4 acceleration when there's nothing to cache and no HTTP to inspect (TCP/UDP, gaming, VoIP); static IPs are a bonus for allowlist-happy enterprise clients.
  • AWS WAF + Shield attach at the CloudFront layer — filter hostile traffic at the edge before paying for origin capacity.
  • Build custom only for exotic cache coordination (e.g., Netflix Open Connect appliances inside ISPs); everyone else configures, not builds.
What interviewers probe
  • Q: Design an image/video service — where does the CDN fit? A: CDN serves ~95%+ of egress; origin (S3) is the source of truth; hashed URLs, immutable TTLs, signed cookies for private segments, origin shield to protect S3 request rates.
  • Q: How do you invalidate after a deploy? A: You mostly don't — content-hashed filenames make deploys atomic; purge only unversionable entry points like /index.html (or give it s-maxage=60 + SWR).
  • Q: Can you cache API responses? A: Shared, staleness-tolerant ones, absolutely — even 1–5 s TTLs with request collapsing flatten thundering herds; key precisely and use surrogate-key purges for accuracy.
  • Q: CDN for a fully dynamic app — pointless? A: No: edge TLS/TCP termination + warm backbone connections to origin cut TTFB 20–40%, plus WAF/DDoS at the edge.
  • Q: What's a cache stampede at the CDN layer and the fix? A: Popular object expires → all edges miss simultaneously → origin spike. Fixes: origin shield, request collapsing, stale-while-revalidate.
  • Q: Signed URL vs signed cookie? A: URL = one object, self-contained, great for downloads/API grants; cookie = path-pattern grant for many objects (HLS), needs a browser context.
  • Q: Edge compute vs origin compute — how do you split? A: Edge: cheap per-request decisions on headers/cookies (auth verify, A/B, routing, normalization). Origin: anything needing your data or transactions. Keep edge logic deploy-simple — it ships to 600 PoPs.
Pitfalls
  • Caching responses that carry Set-Cookie or per-user data — one user's session served to everyone. Strip cookies at the edge for cacheable routes.
  • Forwarding all headers/cookies/query strings to origin "to be safe" — every request becomes a unique cache key; hit ratio ~0 and you paid for a CDN that does nothing.
  • Relying on purge latency for correctness (e.g., pulled legal content) — invalidation is eventually consistent; versioned URLs or short TTLs for anything correctness-critical.
  • Long max-age on browsers for mutable files — you can purge the CDN, you cannot purge users' browsers.
  • Leaving the S3 bucket public alongside CloudFront — bypasses WAF, signed URLs, and metrics. Lock origin to the distribution (OAC).
  • Testing cache behavior only from your desk — one PoP's HIT says nothing about global hit ratio; watch the origin request rate instead.
Production best practice
  • Set Cache-Control explicitly on every response class; never rely on CDN default TTL heuristics.
  • Standard recipe: hashed static assets public, max-age=31536000, immutable; HTML/entry points s-maxage=60, stale-while-revalidate=300, stale-if-error=86400; personalized APIs private, no-store.
  • Enable origin shield for any origin that would suffer at (number of PoPs) × (miss rate) request volume.
  • Alert on cache hit ratio and origin egress — a config change that silently halves hit ratio is a production incident with a delay fuse.
  • Emit x-cache/hit-miss headers to clients in staging; sample them in RUM to see the real global hit ratio.
References
Part II · Foundations

6. Load Balancers, Reverse Proxies & API Gateways

Everything between "the request left the CDN" and "my code ran" is this chapter: L4 vs L7 balancing, the algorithms and health-check machinery that decide which instance answers, the reverse-proxy chores (TLS, compression, buffering), what an API gateway owns vs a service mesh, and how Route 53 turns it all multi-region. In interviews this is where you show you know the traffic path cold — and can defend ALB vs NLB vs API Gateway with numbers, not vibes.

L4 vs L7: what each layer can see and do

L4 (transport) — e.g. NLBL7 (application) — e.g. ALB, Nginx, Envoy
SeesIPs, ports, TCP/UDP flags — payload is opaque bytesFull HTTP: method, path, headers, cookies, gRPC service
Can doForward packets/flows; preserve source IP; TLS passthroughRoute by path/host/header, terminate TLS, retry, rewrite, compress, auth, rate-limit
ConnectionsOne client↔server flow, NAT-style — LB is nearly invisibleTwo separate TCP connections: client↔LB and LB↔server (multiplexed, pooled)
Latency~μs–low ms, in-kernel/hardware speeds~ms — parses every request
Scale ceilingMillions of concurrent flows, spiky traffic, static IPsVery high, but per-request CPU work; scaling is managed for you (ALB)
FitsNon-HTTP (MQTT, game servers, DB proxies), extreme throughput, IP allowlistsWeb/API traffic — i.e., the default for services
L4 — packets forwarded, one logical connection, payload never inspected Client NLB (L4) flow hash → target Server TCP flow (TLS opaque) same flow, src IP preserved L7 — TLS terminated, HTTP parsed, two connections, content-based routing Client ALB (L7) TLS term · route rules orders-svc /orders/* users-svc /users/* conn 1: client TLS conn 2: pooled, per path Rule of thumb: HTTP workloads → L7 unless you need raw throughput, non-HTTP protocols, or client IPs at the socket level → L4.

Balancing algorithms — and when each wins

  • Round robin: rotate through targets. Fine when requests are uniform and instances identical. Degrades when one request is 100× another.
  • Weighted round robin: RR with per-target weights — mixed instance sizes, or canaries (99/1 traffic splits, ALB weighted target groups).
  • Least connections: pick the target with fewest in-flight requests — self-corrects for slow requests and slow instances; the sane default for variable workloads (long polls, reports next to fast reads).
  • Least response time: least-conns plus latency awareness — routes around a degrading node before health checks notice. More state to track; supported by Nginx Plus/Envoy.
  • IP hash: same client IP → same target. Crude stickiness with zero cookies — but mobile NATs put thousands of users behind one IP, and any target change reshuffles everyone.
  • Consistent hashing: hash(key) → position on a ring; adding/removing a node remaps only ~1/N of keys. Essential when targets hold per-key state (caches, WebSocket hubs, shard routers) — full treatment in chapter 11.
  • Reality check: with health checks, connection reuse, and autoscaling, algorithm choice matters less than people think — except under heterogeneous load, where least-connections visibly beats RR.
Real-world analogy

Supermarket checkout. Round robin: a greeter sends each cart to the next lane regardless of queue — fine until someone shows up with 300 items. Least connections: you pick the shortest line yourself. Least response time: shortest line and a fast cashier. Sticky sessions: being forced to the one cashier who remembers your coupons — great until she goes on break and your coupons are gone (that's your session state on a killed instance).

Health checks, draining, slow start

  • Active checks: the LB probes /healthz every N seconds; fail K-in-a-row → out of rotation. Make the endpoint check the instance's own dependencies-critical state — not its downstreams, or one flaky dependency fails your whole fleet at once (cascading self-inflicted outage).
  • Passive checks / outlier ejection: watch real traffic; a target throwing 5xx or timeouts gets ejected temporarily (Envoy's outlier detection). Catches "healthy /healthz, broken /checkout" — combine both.
  • Connection draining (deregistration delay): on deploy/scale-in, stop sending new requests but let in-flight ones finish (ALB default 300 s). Skipping this is why deploys "randomly" 502.
  • Slow start: ramp a freshly registered target from 0→full weight over e.g. 60 s — cold JITs, empty local caches, and unfilled DB pools don't get instantly firehosed.

Sticky sessions — and why to avoid them

  • Mechanism: LB sets a cookie (ALB: AWSALB) pinning a client to one target.
  • Costs: load skews (hot targets can't shed), scale-in/deploys log users out, autoscaling effectiveness drops, and one instance's death is now user-visible.
  • Staff answer: externalize the state instead — sessions in Redis/DynamoDB, or a signed cookie/JWT — so any instance can serve any user. Stickiness is a migration crutch for legacy in-memory-session apps, not a design choice.

Reverse proxy chores (the unglamorous 80%)

  • TLS termination: decrypt once at the proxy; plain HTTP (or lighter mTLS) inside the VPC. Centralizes certs/ciphers (ACM on ALB = zero-touch rotation) and offloads handshake CPU from app nodes.
  • Compression: gzip/brotli at the proxy so app code never thinks about Accept-Encoding.
  • Buffering slow clients: the killer feature nobody mentions — a mobile client trickling a response at 3G speeds would pin an app worker for 30 s; the proxy slurps the app's response in ms, frees the worker, and spoon-feeds the client from its own cheap buffers. This is why Node/Python apps sit behind Nginx/ALB even single-instance.
  • Plus: request normalization, header hygiene (set X-Forwarded-For, strip hop-by-hop), static files, and a single choke point for logging/metrics.

API gateway vs service mesh

  • API gateway = north–south (edge, client-facing): authN/Z (JWT/OIDC validation, API keys), per-client rate limiting and quotas, routing/versioning (/v1 → service A), request/response transforms, canary routing, developer portal. One policy enforcement point so 30 services don't reimplement auth 30 ways.
  • Service mesh = east–west (service-to-service): sidecars (Envoy/Istio) or ambient handling mTLS between services, retries/timeouts/circuit breaking, and traffic shifting — uniformly, in infrastructure, out of app code.
  • They coexist: gateway at the front door, mesh in the hallways. Don't let an interviewer catch you rate-limiting internal traffic at the gateway or exposing the mesh to the internet.
// Build-your-own mini API gateway: a typed middleware pipeline.
// auth → rate limit → route → proxy, composed like ALB/Kong stages.
interface GatewayContext {
  req: { method: string; path: string; headers: Record<string, string>; body?: string };
  clientId?: string;                    // set by auth middleware
  upstream?: string;                    // set by router
  res?: { status: number; body: string };
}
// Each middleware either responds (short-circuit) or calls next().
type Middleware = (ctx: GatewayContext, next: () => Promise<void>) => Promise<void>;

const compose = (mws: Middleware[]) => (ctx: GatewayContext): Promise<void> => {
  const run = (i: number): Promise<void> =>
    i < mws.length ? mws[i](ctx, () => run(i + 1)) : Promise.resolve();
  return run(0);
};

const auth: Middleware = async (ctx, next) => {
  const token = ctx.req.headers["authorization"]?.replace(/^Bearer /, "");
  const claims = token ? verifyJwt(token) : undefined;   // signature + exp check
  if (!claims) { ctx.res = { status: 401, body: "unauthenticated" }; return; }
  ctx.clientId = claims.sub;
  await next();
};

// Token bucket per client: `rate` tokens/sec, burst up to `capacity`.
const buckets = new Map<string, { tokens: number; last: number }>();
const rateLimit = (rate: number, capacity: number): Middleware => async (ctx, next) => {
  const key = ctx.clientId!;
  const now = Date.now();
  const b = buckets.get(key) ?? { tokens: capacity, last: now };
  b.tokens = Math.min(capacity, b.tokens + ((now - b.last) / 1000) * rate);
  b.last = now;
  if (b.tokens < 1) { ctx.res = { status: 429, body: "rate limit exceeded" }; return; }
  b.tokens -= 1;
  buckets.set(key, b);
  await next();
};

const routes: Array<{ prefix: string; upstream: string }> = [
  { prefix: "/orders", upstream: "http://orders.internal:8080" },
  { prefix: "/users",  upstream: "http://users.internal:8080" },
];
const router: Middleware = async (ctx, next) => {
  const match = routes.find(r => ctx.req.path.startsWith(r.prefix));
  if (!match) { ctx.res = { status: 404, body: "no route" }; return; }
  ctx.upstream = match.upstream;
  await next();
};

const proxy: Middleware = async (ctx) => {
  const r = await fetch(ctx.upstream! + ctx.req.path, {
    method: ctx.req.method,
    headers: { ...ctx.req.headers, "x-client-id": ctx.clientId! },
    body: ctx.req.body,
  });
  ctx.res = { status: r.status, body: await r.text() };
};

export const gateway = compose([auth, rateLimit(50, 100), router, proxy]);
declare function verifyJwt(token: string): { sub: string } | undefined;
// Balancer strategies behind one interface — swap algorithms without
// touching call sites (exactly how Envoy/Nginx model lb_policy).
interface Target { id: string; url: string; weight: number; inflight: number }
interface BalancingStrategy { pick(targets: Target[]): Target }

// Smooth weighted round robin (Nginx's algorithm): spreads a {5,1,1}
// weighting as A A B A C A A — no bursts of the same target.
class SmoothWeightedRR implements BalancingStrategy {
  private current = new Map<string, number>();
  pick(targets: Target[]): Target {
    let best: Target | undefined;
    let total = 0;
    for (const t of targets) {
      const cur = (this.current.get(t.id) ?? 0) + t.weight; // earn credit
      this.current.set(t.id, cur);
      total += t.weight;
      if (!best || cur > (this.current.get(best.id) ?? 0)) best = t;
    }
    this.current.set(best!.id, this.current.get(best!.id)! - total); // spend it
    return best!;
  }
}

// Power-of-two-choices least-connections: sample 2 random targets, take the
// less loaded — near-optimal spread without scanning the whole fleet.
class LeastConnections implements BalancingStrategy {
  pick(targets: Target[]): Target {
    const a = targets[Math.floor(Math.random() * targets.length)];
    const b = targets[Math.floor(Math.random() * targets.length)];
    return a.inflight <= b.inflight ? a : b;
  }
}

class LoadBalancer {
  constructor(private targets: Target[], private strategy: BalancingStrategy) {}
  async forward(path: string): Promise<Response> {
    const healthy = this.targets.filter(t => t.weight > 0); // ejected = weight 0
    if (healthy.length === 0) throw new Error("no healthy upstreams (503)");
    const target = this.strategy.pick(healthy);
    target.inflight++;
    try { return await fetch(target.url + path); }
    finally { target.inflight--; }
  }
}

const lb = new LoadBalancer(
  [
    { id: "a", url: "http://10.0.1.10:8080", weight: 5, inflight: 0 },
    { id: "b", url: "http://10.0.1.11:8080", weight: 1, inflight: 0 }, // canary
  ],
  new SmoothWeightedRR(), // or: new LeastConnections()
);
API gateway — every stage can short-circuit Client Auth Rate limit Router Proxy Service pool 401 429 404 Order matters: reject cheaply (auth, 401) before spending work (rate-limit state, upstream capacity).

ALB vs NLB vs API Gateway vs CloudFront

ALBNLBAPI Gateway (HTTP/REST)CloudFront
LayerL7 (HTTP/gRPC/WebSocket)L4 (TCP/UDP/TLS)L7 managed API front doorL7 global edge/CDN
Added latency~ms~100 μs~10–30 ms (REST higher than HTTP API)Negative for hits; edge-terminated for misses
ProtocolsHTTP/1.1, h2, gRPC, WSAny TCP/UDP; TLS passthroughHTTP, WebSocket APIsHTTP/1.1, h2, h3 to clients
RoutingPath/host/header/query rules, weighted target groupsFlow hash → target groupRoute → Lambda/HTTP/AWS service integrationsPath patterns → origins
Built-insHealth checks, stickiness, OIDC auth action, WAFStatic IPs/EIP, source-IP preservation, extreme scaleAuth (IAM/JWT/authorizers), throttling, API keys, request validation, cachingCaching, signed URLs, edge functions, WAF, Shield
Pricing modelHourly + LCU (conns/bandwidth/rules)Hourly + NLCU — cheapest per GB at volumePer request (~$1.00/M HTTP API, ~$3.50/M REST) — expensive at high RPSPer GB egress + per request
Sweet spotDefault for HTTP microservices on ECS/EKS/EC2Non-HTTP, static-IP requirements, millions of conns, PrivateLinkServerless/Lambda APIs, per-client quotas, small-to-mid RPSGlobal user base, cacheable content, edge security layer

They stack: CloudFront → (WAF) → ALB → services is the canonical web path; API Gateway → Lambda the canonical serverless path; NLB fronts databases, brokers, and anything PrivateLink-exposed.

Decision flowchart

HTTP(S) traffic? no (TCP/UDP) NLB also: static IP needs yes Global users or cacheable content? yes + CloudFront in front, then continue either way Backend mostly Lambda / serverless? yes API Gateway HTTP API (cheaper) no — containers/VMs Need per-client quotas, keys, transforms? yes API GW → ALB (or gateway on ECS) no ALB the default answer

Global load balancing

  • DNS-based: Route 53 answers differently per client — latency (lowest-RTT region), geolocation (compliance: EU users → eu-central-1), weighted (gradual region migration), failover (primary/secondary gated on health checks). Caveat: failover speed is bounded by TTL + resolvers that ignore TTLs — think minutes, not seconds.
  • Anycast: the same IP announced from every edge via BGP; the internet routes each user to the nearest one. Failover is route reconvergence — seconds, no client cache to expire. This is CloudFront's and Global Accelerator's mechanism.
  • Staff pattern: anycast front door (Global Accelerator/CloudFront) for fast failover + Route 53 policies for coarse geography/compliance, health checks deep enough to detect a sick region, not just a live LB.
Client Route 53 failover policy + checks us-east-1 — PRIMARY ALB Services eu-west-1 — STANDBY ALB Services 1 resolve 2 healthy → primary IP 3 checks fail → standby (bounded by TTL) health checks
AWS mapping
  • ALB — default L7: path/host routing, weighted target groups (canary), OIDC auth action, WAF attach, gRPC + WebSocket support.
  • NLB — L4: static IPs, source-IP preservation, TLS passthrough, PrivateLink endpoints, millions of connections.
  • API Gateway — managed gateway for serverless: authorizers, usage plans/API keys, throttling, request validation. HTTP APIs are ~70% cheaper than REST APIs — default to HTTP unless you need usage plans/private endpoints.
  • App Mesh / ECS Service Connect / Istio on EKS — east–west concerns (mTLS, retries, outlier ejection); Route 53 — global policies + health checks; Global Accelerator — anycast static IPs with second-scale failover.
  • Build custom (Envoy/Nginx/HAProxy you operate) only for exotic needs: bespoke consistent-hash routing, request coalescing, or protocol translation ALB can't do. Managed LBs win on undifferentiated ops.
What interviewers probe
  • Q: ALB or NLB here — why? A: Lead with layer: HTTP semantics needed (path routing, retries, auth) → ALB; raw TCP/UDP, static IPs, source IP, extreme conn counts, PrivateLink → NLB. Mention they stack (NLB → Nginx → apps) for self-managed L7.
  • Q: How do you deploy without dropping requests? A: Health checks gate registration, connection draining on deregistration, slow start for new targets, and weighted target groups for canary — narrate the full sequence.
  • Q: User's session vanishes when an instance dies — fix? A: Don't reach for stickiness; externalize session state (Redis/DynamoDB or signed tokens) so all targets are interchangeable. Stickiness is the legacy escape hatch, and say why it hurts autoscaling.
  • Q: Which balancing algorithm and why? A: Least-connections (or power-of-two-choices) for variable request costs; weighted RR for canaries/mixed hardware; consistent hashing only when targets hold per-key state — and name the state.
  • Q: Gateway vs mesh — where does auth live? A: Client identity (JWT, API keys, quotas) at the gateway (north–south); service identity (mTLS) and retries/circuit-breaking in the mesh (east–west).
  • Q: Why did a healthy deploy start returning 502s? A: Classic causes: app keep-alive idle timeout shorter than the ALB's 60 s (LB reuses a closed conn), draining skipped, or health check passing while the app can't serve real routes.
  • Q: Multi-region active-passive — how does traffic fail over? A: Route 53 failover records + health checks, TTL ≤ 60 s, honest answer about resolver TTL-pinning; for seconds-level failover put Global Accelerator's anycast IPs in front instead.
Pitfalls
  • Health check hits / which returns 200 from a static handler while every real route 500s — the LB happily routes to a corpse. Check something that exercises the app's critical path (but not its downstreams).
  • App server idle timeout < ALB idle timeout (60 s) → intermittent 502 ECONNRESET races. Keep the app's keep-alive timeout above the LB's.
  • Rate limiting per gateway instance with in-memory buckets — N instances silently multiply every limit by N. Centralize (Redis) or accept and document the approximation.
  • Sticky sessions + autoscaling: scale-out doesn't rebalance existing users, so new capacity idles while old targets stay hot.
  • Trusting client-supplied X-Forwarded-For at the gateway for allowlists/rate limits — use the hop your own LB appended.
  • REST API Gateway in front of a 5,000 RPS service "for consistency" — that's ~$45/hour in per-request fees doing what an ALB does for a few dollars.
  • One health-check config for wildly different services — a 2 s timeout that's fine for the API flaps the report generator into permanent ejection.
Production best practice
  • Terminate TLS at the ALB with ACM certs (auto-rotation); use mTLS internally only where the threat model demands it.
  • Always configure: deregistration delay ≥ your p99 request time, slow start for JIT/cache-heavy services, and health check paths owned by the service team.
  • Ship canaries via weighted target groups (start 1–5%), automated rollback on error-rate/latency SLO breach.
  • Put WAF on the outermost L7 hop (CloudFront or ALB) and alert on LB-emitted 5xx separately from target 5xx — they implicate different owners.
  • Load-test through the LB, not around it — surge queue behavior, LCU scaling, and idle-timeout races only appear on the real path.
References
Part II · Foundations

7. API Design

The API is the contract everything else hangs off — get it wrong and every consumer inherits the mistake forever. This chapter covers REST done properly, when GraphQL/gRPC/WebSockets beat it, payment-grade idempotency (the single most fintech-critical pattern in this book), pagination that survives concurrent writes, versioning, and error contracts. Interviewers use API design as a proxy for "has this person operated a public contract in production" — the details below are how you prove it.

REST done properly

REST is not "JSON over HTTP." It is resource-oriented design that exploits HTTP's built-in semantics — caching, safety, idempotency — instead of fighting them.

  • Resources are nouns, plural: /payments, /payments/{id}, /payments/{id}/refunds. Never verbs in paths (/createPayment is RPC wearing a REST costume).
  • Verbs carry semantics: GET is safe (no side effects) and cacheable; PUT and DELETE are idempotent by definition; POST is neither — which is exactly why it needs idempotency keys (below). PATCH is partial update (use JSON Merge Patch or JSON Patch, and say which).
  • Sub-resources for containment, query params for filtering: /accounts/{id}/transactions?status=settled&from=2026-01-01. Don't nest deeper than two levels — link instead.
  • Use HTTP's machinery: ETag/If-Match for optimistic concurrency, Cache-Control for freshness, Location header on 201, Retry-After on 429/503.
  • Actions that don't map to CRUD: model the action as a resource. Cancelling a payment is POST /payments/{id}/cancellations — you get an auditable record and idempotency for free. Fintech loves this: every state change becomes a first-class object.

The ~15 status codes engineers actually use

CodeMeaningWhen to use
200 OKSuccess with bodyGET, PATCH, or POST-as-action that returns a representation
201 CreatedResource createdPOST that created something; include Location: /payments/pay_123
202 AcceptedQueued for async processingLong-running work (payouts, batch jobs); return a status URL to poll
204 No ContentSuccess, no bodyDELETE, or PUT when returning the body adds nothing
301/308Permanent redirectResource moved; 308 preserves the method (POST stays POST)
304 Not ModifiedCached copy still validConditional GET with If-None-Match matching current ETag
400 Bad RequestMalformed requestUnparseable body, invalid field types — client must change the request
401 UnauthorizedNot authenticatedMissing/expired credentials ("who are you?")
403 ForbiddenNot authorizedValid identity, insufficient permission ("I know who you are; no")
404 Not FoundNo such resourceAlso use for resources the caller may not know exist (avoid leaking via 403)
409 ConflictState conflictDuplicate unique field, state-machine violation ("can't refund a voided payment"), idempotency-key reuse with different body
412 Precondition FailedOptimistic-lock failureIf-Match ETag stale — someone updated the resource first
422 Unprocessable EntitySemantically invalidWell-formed JSON, business validation failed (amount ≤ 0, currency unsupported)
429 Too Many RequestsRate limitedInclude Retry-After and rate-limit headers (below)
500 Internal Server ErrorServer bugUnhandled failure; never leak stack traces in the body
503 Service UnavailableTemporarily down/overloadedLoad shedding, maintenance; include Retry-After — tells clients "back off, not your fault"

The 4xx/5xx split is a contract about blame and retry behavior: 4xx means "your request is wrong, retrying the same bytes won't help"; 5xx means "we failed, retry with backoff is reasonable." Clients build retry logic on this — mislabeling a validation error as 500 triggers pointless retry storms.

Richardson maturity, briefly

  • Level 0: one URL, POST everything (SOAP-style tunnel).
  • Level 1: resources get their own URLs.
  • Level 2: HTTP verbs + status codes used correctly. This is where 95% of good production APIs live — Stripe, GitHub, Twilio.
  • Level 3 (HATEOAS): responses embed hypermedia links driving state transitions. Rarely worth it for machine clients; a links object on paginated responses is the pragmatic subset everyone actually ships.
Real-world analogy

A REST API is a well-run post office. Resources are numbered PO boxes (stable addresses), verbs are the standard forms everyone already knows (deposit, collect, forward), and status codes are the official stamps ("delivered", "addressee unknown", "postage due"). Nobody needs to read a manual per branch — the conventions are the interface. An RPC-style API is a village where you must personally know the postmaster's dialect to mail anything.

REST vs GraphQL vs gRPC vs WebSockets

These are not competitors on one axis — they optimize different edges of the system. Staff-level answer: pick per edge, and it's normal to run all four in one platform.

RESTGraphQLgRPCWebSocket
Latency profileGood; 1 round trip per resource, N+1 across resources1 round trip for arbitrary shape; resolver fan-out server-sideBest: HTTP/2 multiplexing, binary protobuf, ~5–10× smaller payloads than JSONLowest per-message after handshake (~1 RTT setup, then push)
TypingOptional (OpenAPI schema)Strong, schema-first, introspectableStrong, contract-first (.proto), codegen everywhereNone inherent — you define frames
CachingExcellent: HTTP/CDN caching for free on GETHard: everything is POST /graphql; needs persisted queries or client cache (Apollo)None at HTTP layer; build app-levelN/A (stateful stream)
Browser supportNativeNative (plain HTTP)Not native — needs gRPC-Web + proxy (Envoy)Native
StreamingNo (SSE bolt-on)Subscriptions (over WebSocket)First-class: server/client/bidi streamingFirst-class, bidirectional
Versioning storyURL/header + additive changesSingle evolving schema, @deprecated fieldsField numbers make additive evolution safeVersion in subprotocol/frame envelope
Wins whenPublic APIs, partner integrations, anything cache-heavy, webhooksOne gateway aggregating many services for varied clients (mobile vs web need different shapes)Internal service-to-service: high QPS, low latency, polyglot teamsLive prices, chat, collaborative editing, order-book feeds
  • Typical fintech layout: REST for the public/partner API (Stripe-style), gRPC between internal services (ledger ↔ payments ↔ risk), GraphQL at the BFF layer feeding mobile + web dashboards, WebSockets for live transaction feeds.
  • GraphQL's tax: you inherit query-cost analysis, depth limiting, and the N+1 resolver problem (DataLoader batching). Don't pay it unless client shape diversity is real.
  • gRPC's tax: load balancing needs L7 awareness (long-lived HTTP/2 connections pin to one backend — see ch6), and debugging binary frames needs tooling (grpcurl).
Real-time push to clients? WebSocket / SSE yes no Internal service-to- service, perf-critical? gRPC proto + HTTP/2 yes no Many client types needing different data shapes? GraphQL BFF / aggregation yes no REST public APIs, caching, simplicity

Idempotency keys: the payment-grade deep dive

The problem: POST /payments succeeds server-side, but the response is lost — client timeout, LB reset, mobile network blip. The client cannot distinguish "never happened" from "happened, ack lost." Its only rational move is retry — and a naive retry charges the customer twice. GET/PUT/DELETE are naturally idempotent; POST is where money gets duplicated.

  • The fix: client generates a unique key (UUIDv4) per logical operation — not per HTTP attempt — and sends it as Idempotency-Key: 3f2a… on every retry of that operation.
  • Server-side dedup store: keyed by (api_key, idempotency_key), three states — in-flight, completed (with stored response), absent. Completed → replay the stored status + body byte-for-byte. In-flight → return 409 (or block briefly): a concurrent duplicate must not execute twice.
  • Fingerprint the body: hash the request payload and store it. Same key + different body = client bug → 422. Never silently replay a response for a different request.
  • TTL: Stripe keeps keys 24h; that bounds store size while covering realistic retry windows. Persist in Redis (with fallback to DB for audit) or the primary DB inside the same transaction as the side effect — the truly bulletproof version writes the idempotency record and the payment row atomically.
  • Reserve first, execute second: insert the in-flight record with a unique constraint before doing the work. The unique-violation on concurrent insert is your mutex.
Client Payment API Idempotency store Redis / DB, TTL 24h 1 POST /payments Idempotency-Key: K1 2 reserve K1 (in-flight) 3 charge card, write ledger 4 store 201 + body under K1 5 201 response LOST (timeout) 6 RETRY: same body, same key K1 7 replay stored 201 — charge ran once
// Idempotency middleware (Express-style) with a typed pluggable store.
import { createHash } from "node:crypto";

type IdempotencyState =
  | { status: "in_flight"; fingerprint: string }
  | { status: "completed"; fingerprint: string; httpStatus: number; body: string };

interface IdempotencyStore {
  /** Atomically claim the key. Returns existing record if already present. */
  reserve(key: string, fingerprint: string, ttlSec: number): Promise<
    { claimed: true } | { claimed: false; existing: IdempotencyState }
  >;
  complete(key: string, rec: Extract<IdempotencyState, { status: "completed" }>): Promise<void>;
  release(key: string): Promise<void>;          // free the slot if handler crashed
}

const fingerprintOf = (method: string, path: string, body: unknown): string =>
  createHash("sha256").update(`${method} ${path} ${JSON.stringify(body)}`).digest("hex");

export function idempotency(store: IdempotencyStore, ttlSec = 86_400) {
  return async (req: Req, res: Res, next: Next): Promise<void> => {
    if (req.method !== "POST") return next();          // GET/PUT/DELETE already idempotent
    const rawKey = req.header("Idempotency-Key");
    if (!rawKey) return next();                        // or 400 if your API mandates keys
    const key = `${req.apiKeyId}:${rawKey}`;           // scope per caller — never global!
    const fp = fingerprintOf(req.method, req.path, req.body);

    const claim = await store.reserve(key, fp, ttlSec);
    if (!claim.claimed) {
      const ex = claim.existing;
      if (ex.fingerprint !== fp)                       // same key, different payload => bug
        return res.status(422).json({ type: "idempotency_key_reuse",
          detail: "Idempotency-Key was used with a different request body." });
      if (ex.status === "in_flight")                   // concurrent duplicate racing us
        return res.status(409).set("Retry-After", "1")
          .json({ type: "request_in_flight", detail: "Original request still processing." });
      // Completed: replay the recorded response byte-for-byte.
      return res.status(ex.httpStatus).set("Idempotent-Replayed", "true").send(ex.body);
    }

    // We own the slot: capture the response the handler produces.
    const send = res.send.bind(res);
    res.send = (body: string) => {
      const done = res.statusCode < 500
        ? store.complete(key, { status: "completed", fingerprint: fp,
                                httpStatus: res.statusCode, body })
        : store.release(key);                          // 5xx: let the client retry for real
      void done.catch(() => { /* log; replay degrades to re-execution */ });
      return send(body);
    };
    try { next(); } catch (e) { await store.release(key); throw e; }
  };
}
  • Why 5xx releases the key: if the handler failed before committing, replaying a stored 500 forever would brick the operation. Release lets the retry actually re-execute. If the failure happened after the charge committed, your handler itself must be idempotent internally (unique constraint on payment_intent_id) — defense in depth.
  • Redis reserve is SET key value NX EX ttl; DB reserve is INSERT … ON CONFLICT DO NOTHING RETURNING. Both give you the atomic claim.
Real-world analogy

An idempotency key is the numbered ticket at a dry cleaner. Hand over the same ticket twice and you get the same suit back — they don't clean (or charge) it again. Without the ticket, "one grey suit, please" after a noisy phone line might get you two cleanings and two charges. The ticket number identifies the job, not the visit.

Pagination: offset vs cursor

  • Offset (?limit=20&offset=10000): simple, supports "jump to page 47." But the DB must scan and discard 10,000 rows per query — O(offset) cost, so p99 degrades with depth. Worse, it's unstable: a row inserted (or deleted) before your offset while you paginate shifts everything — you see duplicates or skip rows. On an append-heavy transactions table this is not an edge case, it's every request.
  • Cursor / keyset (?limit=20&cursor=eyJ…): the cursor encodes the sort position of the last item — WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 20. Index seek, O(limit) regardless of depth, and stable under concurrent inserts (new rows land before your cursor, never inside your next page). Cost: no random page jumps, and the cursor must include a unique tiebreaker (id) or equal timestamps cause skips.
  • Rule of thumb: cursor for any feed/list that grows (transactions, events, logs); offset only for small, mostly-static admin tables where "page 3 of 9" UX matters.
OFFSET: page 2 = skip 3, take 3 row A (page 1) row NEW — inserted! row B (page 1) row C (page 1) row D offset 3 now lands on C C appears on BOTH pages CURSOR: page 2 = WHERE key < cursor(C) row NEW — inserted! row A (page 1) row B (page 1) row C (page 1) ← cursor row D (page 2) seek past C: page 2 starts at D no duplicates, no skips
// Opaque keyset cursor: encode the sort position, verify it on the way back in.
interface TxnCursor { createdAt: string; id: string }   // (sort key, unique tiebreaker)

const encodeCursor = (c: TxnCursor): string =>
  Buffer.from(JSON.stringify(c)).toString("base64url");  // opaque to clients — the shape
                                                         // is NOT part of your API contract
const decodeCursor = (raw: string): TxnCursor => {
  const c: unknown = JSON.parse(Buffer.from(raw, "base64url").toString());
  if (typeof c !== "object" || c === null ||
      typeof (c as TxnCursor).createdAt !== "string" ||
      typeof (c as TxnCursor).id !== "string")
    throw new ApiError(400, "invalid_cursor");           // never trust decoded input
  return c as TxnCursor;
};

async function listTransactions(accountId: string, limit: number, raw?: string) {
  const cur = raw ? decodeCursor(raw) : undefined;
  const rows = await db.query<TxnRow>(
    `SELECT id, created_at, amount_minor, currency
       FROM transactions
      WHERE account_id = $1
        AND ($2::timestamptz IS NULL OR (created_at, id) < ($2, $3))
      ORDER BY created_at DESC, id DESC
      LIMIT $4`,                                          // fetch limit+1 to detect hasMore
    [accountId, cur?.createdAt ?? null, cur?.id ?? null, limit + 1]);

  const page = rows.slice(0, limit);
  const last = page.at(-1);
  return {
    data: page,
    hasMore: rows.length > limit,
    nextCursor: last ? encodeCursor({ createdAt: last.created_at, id: last.id }) : null,
  };
}

Filtering, sorting, versioning, errors, rate limits

Filtering & sorting conventions

  • Flat params for equality: ?status=settled&currency=USD. Bracket or suffix operators for ranges: ?amount[gte]=1000 or ?created_after=… — pick one style and never mix.
  • Sorting: ?sort=-created_at,amount (leading - = descending). Whitelist sortable fields — every sort you allow is an index you must maintain.
  • Reject unknown query params with 400 rather than silently ignoring them; a typo in staus= that returns everything is a data-leak class of bug.

Versioning

  • URL (/v2/payments): explicit, cache-friendly, easy to route/deprecate. Coarse-grained; forks docs and SDKs.
  • Header (Stripe-Version: 2026-06-01): date-pinned versions per account, gateway translates old shapes to the current internal model. Most operable at scale; invisible in logs unless you add it.
  • Field evolution (no versions): only additive changes, forever. Works if you enforce the compat rules below with tooling.
  • Pragmatic default: URL major version (rarely bumped, v2 is a big deal) + additive evolution within it.

Backwards-compatibility rules (additive-only)

  • Safe: adding optional request fields, adding response fields, adding enum values only if clients were told to treat unknown values as a default, adding endpoints.
  • Breaking: removing/renaming fields, changing types or semantics (major: switching amount from float dollars to integer cents), tightening validation, changing error codes clients branch on, reordering meaning of defaults.
  • Clients must be tolerant readers: ignore unknown fields, never exhaustively match enums from the wire.

Error contract: problem+json style

// RFC 9457 (problem+json) flavored, with a machine-readable discriminant.
// Clients branch on `type` (stable), humans read `detail` (free to change).
interface ApiProblem {
  type: string;          // stable code/URI: "https://api.orbitx.dev/errors/insufficient-funds"
  title: string;         // short human summary, stable per type
  status: number;        // mirrors HTTP status
  detail?: string;       // instance-specific explanation — NEVER parse this
  instance?: string;     // path or trace id for support: "/payments/pay_9f3"
  errors?: Array<{ field: string; code: string; message: string }>; // 422 field errors
}

type PaymentError =
  | { type: "insufficient_funds";  status: 422; balanceMinor: number }
  | { type: "card_declined";       status: 402; declineCode: string }
  | { type: "idempotency_key_reuse"; status: 422 }
  | { type: "rate_limited";        status: 429; retryAfterSec: number };

// Discriminated union => exhaustive, compiler-checked client handling:
function handle(e: PaymentError): ClientAction {
  switch (e.type) {
    case "insufficient_funds":    return promptTopUp(e.balanceMinor);
    case "card_declined":         return showDecline(e.declineCode);
    case "idempotency_key_reuse": return reportClientBug();
    case "rate_limited":          return backoff(e.retryAfterSec);
  } // adding a variant breaks compilation here — that is the point
}

Rate-limit headers

  • Return on every response: RateLimit-Limit: 100, RateLimit-Remaining: 12, RateLimit-Reset: 30 (IETF draft standard; legacy X-RateLimit-* still common). On 429 add Retry-After.
  • Well-behaved SDKs throttle before hitting 429 by watching Remaining — publishing headers turns your limits from a wall into a signal. Algorithms (token bucket etc.) are in ch12.

OpenAPI-first workflow

  • Spec is the source of truth, reviewed in PRs before code: design review happens on the contract, not the implementation.
  • Generate TS types + clients (openapi-typescript, orval), validate requests at runtime against the same spec (zod schemas generated from it), and run contract diff in CI (oasdiff) to mechanically block breaking changes.
  • Same spec drives docs, mocks (Prism), and SDK generation — one artifact, five consumers.

Webhooks are your API in reverse — you become the client calling consumer-owned endpoints, and idempotency, retries, ordering, and signature verification all flip direction. Full treatment in ch15; the one rule that belongs here: every webhook delivery carries an event id, and consumers dedupe on it — exactly the idempotency-key pattern with roles swapped.

AWS mapping
  • API Gateway REST API: the full-featured option — usage plans + API keys, request/response transformation, WAF integration, caching, canary deploys. Pick for public/partner APIs where you monetize or throttle per client.
  • API Gateway HTTP API: ~70% cheaper, lower latency, JWT auth built in, but no usage plans, no caching, no request validation. Default for internal or simple Lambda-backed APIs.
  • AppSync: managed GraphQL — resolvers to DynamoDB/Lambda/RDS, subscriptions over WebSocket, offline sync. Pick when you want GraphQL without running Apollo Server; skip if you need full resolver control.
  • ALB: when you just need HTTP routing to containers without gateway features — cheapest at high, steady throughput.
  • gRPC: ALB and VPC Lattice support HTTP/2/gRPC natively; API Gateway does not — internal gRPC usually rides service mesh or ALB.
  • Build custom (Envoy/Kong on EKS) only when you need plugins AWS gateways lack (custom auth protocols, per-route idempotency middleware at the edge).
What interviewers probe
  • Q: "Client timeout on POST /payments — what happens on retry?" A: Without idempotency keys, double charge. With them: key scoped per caller, atomic reserve (Redis SETNX / DB unique constraint), fingerprint check, replay stored response, TTL ~24h, release on 5xx. Mention the in-flight state — most candidates forget concurrent duplicates.
  • Q: "Why does offset pagination break?" A: O(offset) scan cost at depth, and concurrent inserts shift rows across page boundaries causing dupes/skips. Keyset seeks on an indexed (sort key, id) tuple — O(limit) and stable.
  • Q: "GraphQL or REST for our public API?" A: REST — HTTP caching, simpler auth/rate-limiting per endpoint, no query-cost analysis burden on partners. GraphQL earns its keep internally at the BFF layer where client shape diversity is yours to manage.
  • Q: "How do you version without breaking clients?" A: Additive-only within a major version, tolerant-reader clients, contract-diff in CI, date-pinned header versions (Stripe model) if per-account migration is needed. A v2 is an event, not a habit.
  • Q: "409 vs 412 vs 422?" A: 409 = state conflict (duplicate, invalid transition), 412 = precondition/ETag mismatch on conditional requests, 422 = well-formed but semantically invalid input. Consistency matters more than the exact choice — document and stick.
  • Q: "Why is PUT idempotent but PATCH not necessarily?" A: PUT replaces full state (applying twice = same state); PATCH can be relative ("increment balance by 10") — applying twice differs. Absolute PATCH documents are idempotent; say which yours is.
  • Q: "Where does the idempotency record get written relative to the business transaction?" A: Ideally same DB transaction as the side effect — atomic. If the store is Redis, pair it with an internal unique constraint (intent id) so a Redis flush can't cause double execution.
Pitfalls
  • Idempotency keys scoped globally instead of per API key — attacker replays another tenant's key and reads their stored response.
  • Replaying stored responses without fingerprint-checking the body — same key, new amount, old response: client believes the wrong charge succeeded.
  • Caching an idempotent replay of a 500 forever (never storing 5xx) or, inversely, releasing the key after the charge committed — pick per failure point, and back it with a DB unique constraint.
  • Cursors built on created_at alone — equal timestamps (bulk inserts) cause skipped rows. Always add the unique tiebreaker.
  • Exposing internal cursor structure (raw WHERE values, unsigned) — clients start crafting cursors and your query internals become API surface. Base64url + validate; sign/encrypt if the values are sensitive.
  • Returning 200 with {"error": …} in the body — breaks every HTTP client, retry library, cache, and monitor built on status codes.
  • New required request field in a "minor" release — additive means optional-additive on requests.
Production best practice
  • Mandate Idempotency-Key on all mutating money endpoints (400 without it); generate SDKs that supply it automatically.
  • Contract-diff every PR against the deployed OpenAPI spec; breaking diff fails CI, no human vigilance required.
  • Return Request-Id (trace id) on every response and log it — turns "a customer says a payment failed" into a one-query investigation.
  • Deprecation runway: Deprecation + Sunset headers, usage metrics per client per deprecated field, direct outreach before shutoff.
  • Publish rate limits in headers and document retry guidance (backoff + jitter, honor Retry-After) in your SDKs — you shape client behavior, or client behavior shapes your outages.
References
Part III · Data

8. Caching Everywhere

Caching is the highest-leverage performance tool in system design and the source of its subtlest bugs. Interviews reward candidates who go beyond "add Redis": name the layer, pick the write strategy, choose eviction, and — the real differentiator — pre-empt the failure modes (stampede, hot key, penetration, avalanche) and the cache/DB consistency races. This chapter gives you the full hierarchy, working TypeScript for the patterns, and the mitigation playbook.

The cache hierarchy

Every serious system is a stack of caches; a request is a ball dropping through layers until one can answer. Each layer trades freshness and capacity for latency. Know the numbers — interviewers listen for them.

  • Browser cache (~0 ms): Cache-Control, ETags, service workers. Free capacity you control only via headers.
  • CDN edge (~5–30 ms): static assets, and increasingly API GETs with short TTLs. Absorbs the geographic tax.
  • API gateway / edge cache (~1–5 ms in-region): whole-response caching keyed on path + params.
  • Service in-memory (~100 ns–1 µs): per-instance Map/LRU. Fastest possible, but N instances = N stale copies — good for config, feature flags, hot reference data.
  • Distributed cache — Redis/Memcached (~0.3–1 ms in-VPC): the shared workhorse. One coherent copy, network hop per read.
  • DB buffer pool (~0.1–1 ms for a cached page): Postgres shared_buffers / InnoDB pool. You get this for free — a "cache miss" that hits a warm buffer pool is far cheaper than a cold disk read (~1–10 ms SSD).
Browser ~0 ms CDN edge 5–30 ms Gateway 1–5 ms VPC Service in-mem: ~1 µs Redis 0.3–1 ms Database buffer pool ~0.5 ms SSD 1–10 ms miss each layer answers what it can; misses fall through to the right
Real-world analogy

The hierarchy is how you handle facts you need: memory (in-process), the notebook on your desk (Redis), the filing cabinet down the hall (DB buffer pool), and the off-site archive (disk). You'd never drive to the archive for something written on your hand — and equally, you can't fit the archive on your hand. Every layer exists because the one below it is 10–100× slower.

Write/read strategies

StrategyRead pathWrite pathConsistency riskUse when
Cache-aside (lazy)App checks cache; on miss, loads DB and populates cacheApp writes DB, then invalidates cacheStale window between write and invalidation; races (below)Default. General reads, app controls everything
Read-throughCache itself loads from DB on miss (loader plugged into cache)Same as cache-asideSame, but dedup/loading logic centralizedLibrary/sidecar handles misses; simpler app code
Write-throughCache always warm for written keysWrite cache + DB synchronously, both must ackStrongest cache/DB agreement; write latency = slower of the twoRead-heavy data that must be fresh right after write
Write-behind (write-back)Cache warmWrite cache, ack, flush to DB async in batchesData loss if cache dies pre-flushExtreme write throughput, loss-tolerant (metrics, counters). Never ledgers
Refresh-aheadCache proactively refreshes hot keys before TTL expiryn/aWasted refreshes on cold keysPredictable hot set, latency-critical reads
CACHE-ASIDE (read miss) App Cache DB 1 GET k 2 miss 3 SELECT … WHERE id = k 4 SET k, TTL App owns both hops; cache never talks to DB WRITE-THROUGH (write) App Cache DB 1 SET k=v 2 UPDATE … (sync) 3 ack 4 ack Ack only after BOTH commit: cache and DB agree, write pays latency

Eviction policies

  • LRU — evict least recently used. Default choice; exploits temporal locality. Weakness: one big scan (batch job reading everything once) flushes the hot set.
  • LFU — evict least frequently used. Scan-resistant, but slow to forget yesterday's celebrity; production LFU decays counts over time (Redis allkeys-lfu uses a probabilistic 8-bit counter with decay).
  • FIFO — evict oldest inserted. Simple, ignores access patterns; fine for uniform access.
  • TTL — time-based expiry. Not really eviction (capacity) but freshness control; usually combined with LRU/LFU.
  • Random — surprisingly close to LRU on skewed workloads, O(1), no metadata; Redis offers allkeys-random.

The classic LRU trick: a JS/TS Map preserves insertion order, so delete + re-insert moves a key to the "most recent" end — giving O(1) LRU without hand-rolling a linked list.

// O(1) LRU cache exploiting Map's insertion-order iteration.
// Oldest key = first key in iteration order; "touch" = delete + re-set.
export class LruCache<K, V> {
  private readonly map = new Map<K, { value: V; expiresAt: number }>();

  constructor(
    private readonly capacity: number,
    private readonly defaultTtlMs = Infinity,
  ) {
    if (capacity < 1) throw new RangeError("capacity must be >= 1");
  }

  get(key: K): V | undefined {
    const entry = this.map.get(key);
    if (!entry) return undefined;
    if (entry.expiresAt <= Date.now()) {         // lazy TTL expiry on read
      this.map.delete(key);
      return undefined;
    }
    this.map.delete(key);                         // touch: move to MRU end
    this.map.set(key, entry);
    return entry.value;
  }

  set(key: K, value: V, ttlMs = this.defaultTtlMs): void {
    if (this.map.has(key)) this.map.delete(key);  // re-insert at MRU end
    else if (this.map.size >= this.capacity) {
      const lru = this.map.keys().next();         // first key = least recent
      if (!lru.done) this.map.delete(lru.value);
    }
    this.map.set(key, { value, expiresAt: Date.now() + ttlMs });
  }

  delete(key: K): boolean { return this.map.delete(key); }
  get size(): number { return this.map.size; }
}

// Interview note: the textbook variant is HashMap + doubly-linked list —
// the DLL gives O(1) "unlink node, splice to head" explicitly, which is
// what Map is doing for you under the hood. Be ready to code the DLL
// version by hand: node {key, value, prev, next} + head/tail sentinels.
// LFU sketch: Map<key, node> + Map<freq, DLL of nodes> + minFreq pointer;
// on access, move node from freq bucket f to f+1 — O(1) (Leetcode 460).

Cache invalidation

"There are only two hard things in Computer Science: cache invalidation and naming things" — Phil Karlton. The joke survives because invalidation is a distributed consistency problem wearing a performance costume.

  • TTL: bound staleness by time. Simplest, no infrastructure, and the fallback safety net under every other strategy. Pick TTL = maximum staleness the business tolerates, not a random 300.
  • Event-driven: DB change → CDC stream (Debezium, DynamoDB Streams) or pub/sub → consumers delete/refresh affected keys. Near-real-time freshness; cost is a pipeline plus handling event loss/reorder (which is why you still keep a TTL).
  • Versioned keys: never mutate — write user:42:v7, store the current version pointer, let old versions age out via TTL/eviction. Sidesteps races entirely at the price of extra memory and a pointer lookup; also the trick behind cache-busting asset URLs.

Consistency between cache and DB

  • Invalidate, don't update, and do it after the DB write. Write-then-invalidate (a.k.a. the "Scaling Memcache at Facebook" pattern) beats write-then-update because two racing writers can update the cache out of order and strand a stale value forever; a delete is order-insensitive.
  • The read-modify race (why even invalidation isn't airtight): reader misses → reads DB (old value) → pauses; writer updates DB → invalidates; reader wakes and sets the cache to the old value, which now lives until TTL. Rare (needs a miss interleaved with a write) but real at scale.
  • Mitigations: short TTL as backstop; compare-and-set with a version/timestamp in the cached value; or delayed double delete — delete on write, then delete again after ~500 ms–1 s to sweep up any stale set from an in-flight reader.
  • Read replicas widen the race: the reader may fetch from a lagging replica after invalidation. Either invalidate off the replica's CDC stream or read-your-writes from primary for cache fills triggered by writes.
Real-world analogy

Invalidation is updating every whiteboard in the office when the meeting room changes. TTL is "all whiteboards get wiped nightly" (bounded staleness, zero coordination). Event-driven is an intern running around erasing boards the moment the booking changes (fresh, but the intern can miss one). Versioned keys is never erasing — you write "Room booking v8" on a fresh board and tell everyone the current version number. The read-modify race is a colleague who photographed the old board just before the intern wiped it, then re-writes the stale photo onto the clean board.

The four classic failure modes

1. Cache stampede / thundering herd

  • A hot key expires; 10,000 concurrent requests all miss and all hit the DB with the same query. The DB, sized for a 99% hit rate, receives 100× load and folds — which slows the rebuild, which prolongs the stampede.
  • Mitigations: (a) single-flight locking — one caller rebuilds, everyone else awaits the same promise (per-instance) or a Redis SET NX lock (cross-fleet), serving stale meanwhile; (b) probabilistic early refresh (XFetch, Vattani et al.) — each reader independently decides to refresh slightly before expiry with probability rising as expiry nears, so one reader statistically refreshes and the key never publicly expires; (c) never letting hot keys expire at all — background refresh loop.

2. Hot key

  • One key (celebrity profile, BTC-USD price) takes 500k reads/s. A single Redis shard caps out around 100k–1M simple ops/s — one key can saturate one node while the rest of the cluster idles, since a key lives on exactly one shard.
  • Mitigations: per-instance local cache in front of Redis with a tiny TTL (1–5 s) — absorbs almost all reads at the cost of seconds of staleness; key splitting — write N replicas price:btc:0..N-1, readers pick one at random, spreading load across shards; read replicas of the cache tier.

3. Cache penetration

  • Requests for keys that don't exist in the DB either (misspelled ids, or an attacker enumerating ids). Every request misses the cache and misses the DB, so nothing ever gets cached — the cache is bypassed entirely.
  • Mitigations: negative caching — cache the "not found" result with a short TTL (30–60 s); a Bloom filter of all valid ids in front of the cache — "definitely absent" answers never touch cache or DB (false positives just fall through; internals in ch16); input validation to reject impossible ids before any lookup.

4. Cache avalanche

  • Many keys expire simultaneously — a deploy warmed a million keys with identical TTLs, or the cache node restarts cold — and the whole read load lands on the DB at once. A stampede is one key; an avalanche is the entire keyspace.
  • Mitigations: jitter every TTL (one-liner below) so expiries spread; warm caches before shifting traffic after deploys/restarts; Redis HA (replica promotion) so "node died" isn't "cache is cold"; circuit breakers + load shedding on the DB path as the last line.
// Jittered TTL — the avalanche one-liner (±spread around the base TTL):
export const jitteredTtl = (baseMs: number, spread = 0.1): number =>
  Math.round(baseMs * (1 - spread + Math.random() * 2 * spread));

// XFetch: probabilistic early refresh (Vattani, Chierichetti & Lowenstein,
// "Optimal Probabilistic Cache Stampede Prevention", VLDB 2015).
// Recompute early with probability that rises as expiry approaches:
// refresh when  now - delta * beta * ln(rand())  >=  expiresAt
interface XEntry<V> { value: V; deltaMs: number; expiresAt: number }
//                              ^ deltaMs = how long the last recompute took

export function xfetchShouldRefresh<V>(e: XEntry<V>, beta = 1.0): boolean {
  // -ln(U) is Exp(1)-distributed; beta > 1 refreshes earlier/more eagerly.
  return Date.now() - e.deltaMs * beta * Math.log(Math.random()) >= e.expiresAt;
}

export async function xfetchGet<V>(
  cache: Map<string, XEntry<V>>,
  key: string,
  ttlMs: number,
  recompute: () => Promise<V>,
): Promise<V> {
  const hit = cache.get(key);
  if (hit && !xfetchShouldRefresh(hit)) return hit.value;   // fresh enough

  const started = Date.now();
  const value = await recompute();                           // one lucky reader pays
  cache.set(key, { value, deltaMs: Date.now() - started,
                   expiresAt: Date.now() + jitteredTtl(ttlMs) });
  return value;
}
// Generic cache-aside helper with single-flight dedup: concurrent callers
// for the same key share ONE loader call instead of stampeding the DB.
interface RemoteCache {
  get(key: string): Promise<string | null>;
  set(key: string, value: string, ttlMs: number): Promise<void>;
}

const inFlight = new Map<string, Promise<unknown>>();  // per-instance dedup

export async function cacheAside<T>(
  cache: RemoteCache,
  key: string,
  ttlMs: number,
  loader: () => Promise<T>,          // the expensive DB/API call
): Promise<T> {
  const cached = await cache.get(key);
  if (cached !== null) return JSON.parse(cached) as T;   // hit

  const pending = inFlight.get(key);                     // someone already loading?
  if (pending) return pending as Promise<T>;             // piggyback on their flight

  const flight = (async (): Promise<T> => {
    try {
      const value = await loader();
      // Jitter here too — uniform TTLs are how avalanches are born.
      await cache.set(key, JSON.stringify(value), jitteredTtl(ttlMs));
      return value;
    } finally {
      inFlight.delete(key);          // ALWAYS clear, or a thrown loader
    }                                // poisons the key until process restart
  })();

  inFlight.set(key, flight);
  return flight;
}
// Cross-fleet version: swap the in-memory map for a short-TTL Redis lock
// (SET lock:key token NX PX 3000) and serve the stale value while locked.

Redis vs Memcached — and what not to cache

RedisMemcached
Data modelStrings, hashes, lists, sets, sorted sets, streams, geo, pub/sub, LuaStrings/blobs only
ThreadingMostly single-threaded command loop (I/O threads ≥ 6.0)Fully multithreaded — scales vertically on big instances
Persistence / HARDB snapshots, AOF, replication, Sentinel/Cluster failoverNone — a restart is a cold cache, by design
Eviction8 policies (LRU/LFU/TTL/random, volatile/allkeys)Slab-based LRU
Use whenDefault. Rate limiters, leaderboards, locks, queues, sessions — anything beyond get/setPure ephemeral get/set at max throughput-per-dollar, simple ops model

What NOT to cache:

  • Account balances and anything money-authoritative — the ledger is the source of truth; a stale cached balance authorizing a withdrawal is an incident, not a bug. Cache derived displays of it with an explicit staleness label if you must.
  • Data read once (no reuse = pure overhead + a copy to secure), write-heavy data with read-back-immediately semantics (hit rate near zero, race surface maximal).
  • Secrets, tokens, PII in shared caches without encryption/scoping — the cache rarely inherits the DB's access controls, encryption or audit logging.
  • Results of queries whose inputs are unbounded/user-crafted (cache-key cardinality explosion evicts your real hot set).
  • Anything where serving stale violates correctness rather than just freshness: inventory holds, auth entitlements right after revocation, idempotency records.

Which caching strategy? (decision flow)

Tolerate any staleness at all? Don't cache read DB / write-through no yes Write-heavy and loss-tolerant? Write-behind counters, metrics yes no Must be fresh immediately after every write? Write-through + event invalidation yes no Cache-aside + TTL (jittered) single-flight; add refresh-ahead for hot keys
AWS mapping
  • ElastiCache for Redis (incl. Serverless): the default distributed cache — replication, Multi-AZ failover, cluster-mode sharding, data structures. Pick for sessions, rate limiting, locks, leaderboards.
  • ElastiCache Memcached: pure ephemeral get/set, multithreaded nodes, no HA — pick only for max simple-op throughput where cold restarts are fine.
  • DAX: write-through/read-through cache purpose-built for DynamoDB — microsecond reads, zero app changes (same SDK API). Only caches DynamoDB; eventually consistent reads only.
  • CloudFront: the CDN/browser layers — static assets plus API GET caching at the edge with Cache-Control/origin policies.
  • API Gateway caching (REST APIs only): per-stage whole-response cache, 0.5–237 GB — quick win for read-heavy public endpoints; not available on HTTP APIs.
  • Build custom (in-process LRU, sidecar cache) when the network hop to ElastiCache is itself the latency budget — config, flags, hot reference data.
What interviewers probe
  • Q: "How do you keep the cache consistent with the DB?" — the classic probe, expect follow-ups. A: Cache-aside with write-then-invalidate (delete, not update — deletes are race-immune to write reordering); TTL as the backstop for missed invalidations; acknowledge the read-modify race (miss → read old → writer updates+invalidates → stale set) and name mitigations: short TTL, versioned/CAS values, delayed double delete, CDC-driven invalidation. Say the honest sentence: "with an external cache it's eventual consistency — I bound the staleness window and keep money reads on the DB."
  • Q: "A hot key expires under 50k RPS — what happens?" A: Stampede. Single-flight (promise dedup in-process, Redis NX lock cross-fleet, serve stale while rebuilding), or XFetch probabilistic early refresh so the key never publicly expires.
  • Q: "Update the cache or delete it on write?" A: Delete. Two racing updates can apply out of order and pin a stale value until TTL; deletes commute. Facebook's memcache paper is the canonical citation.
  • Q: "Attacker requests random non-existent user ids?" A: Penetration. Negative caching with short TTL + Bloom filter of valid ids in front + input validation.
  • Q: "Redis restarts cold at peak — then what?" A: Avalanche. Replica failover so it doesn't restart cold, jittered TTLs, cache warming before traffic shift, DB-side load shedding/circuit breaker as the backstop.
  • Q: "Where would you NOT add a cache?" A: Authoritative balances/entitlements, read-once data, unbounded key cardinality — show you weigh staleness cost against latency win.
  • Q: "Implement an LRU cache." A: HashMap + doubly-linked list, O(1) get/put; in TS a Map's insertion order gives it directly. Follow-up is usually LFU — know the freq-bucket structure.
Pitfalls
  • Updating the cache on write instead of deleting — the concurrent-writer reorder pins stale data until TTL, and nobody notices until reconciliation does.
  • Uniform TTLs across a warmed keyset — you've scheduled your own avalanche for exactly TTL after deploy.
  • No TTL at all because "we invalidate on every write" — one dropped event and the entry is immortal. TTL is the seatbelt, always.
  • Caching before measuring: adding Redis in front of a query the DB buffer pool already serves in 0.5 ms buys ~0 and costs a consistency problem.
  • Single-flight maps that never clear entries on loader failure — one exception poisons the key for the life of the process.
  • Treating Redis as durable storage (sessions are fine to lose; a write-behind ledger is not — write-behind + money never mix).
  • Ignoring serialization cost: caching a 2 MB JSON blob can make the "hit" slower than the DB query it replaced.
Production best practice
  • Instrument hit rate, p99 latency and evictions per cache and per key-prefix — a falling hit rate is your earliest warning of key-cardinality explosions or TTL misconfiguration.
  • Namespace + version keys (v3:user:42:profile) so schema changes are a version bump, not a flush.
  • Design for cache death: the system must survive (degraded) with a cold cache — load-test that path; it's your real DB capacity requirement.
  • Standardize one cacheAside() helper (single-flight + jitter + negative caching baked in) so every team doesn't re-discover the failure modes in prod.
  • Set explicit maxmemory + eviction policy; the default of "grow until the box dies" is not a policy.
References
Part III · Data

9. Database Internals — How Databases Actually Work

Most candidates can name databases; staff engineers explain what happens between EXECUTE and the disk. This chapter goes under the hood: pages and buffer pools, WAL and crash recovery, B-trees vs LSM-trees, how indexes really pay for themselves, the three join algorithms every optimizer chooses between, MVCC, and quick internals tours of InnoDB, MongoDB, and Redis. Once you can reason from first principles about why a query is slow at 100M rows, every "pick a database" question in an interview becomes easy.

9.1 Anatomy of a database

Every serious disk-based database — Postgres, MySQL, Oracle, SQL Server — shares the same skeleton. Internalize it once and the rest of the chapter is variations on a theme.

  • Pages are the atom of I/O. Tables and indexes are arrays of fixed-size pages — 16 KB in InnoDB, 8 KB in Postgres. The database never reads "a row"; it reads the whole page containing it. Row layout, free space, and a small header live inside each page.
  • Heap files. In Postgres, table rows live in an unordered heap; indexes point at (page, offset) tuples (TIDs). InnoDB is different — the table is a B+tree ordered by primary key (see 9.6).
  • Buffer pool. A big in-RAM cache of pages (often 60–80% of DB host memory). Reads hit RAM if the page is cached; writes dirty the in-memory page first. An LRU-ish policy (with midpoint insertion to survive scans) decides eviction. A cold buffer pool is why a freshly restarted replica is slow.
  • WAL / redo log. Before any dirty page is written back, the change is appended to the write-ahead log and fsynced. Appends are sequential I/O — hundreds of MB/s even on modest disks and friendly to SSD internals — whereas flushing dirty pages is random I/O. So commit latency is bounded by one sequential append, not by rewriting scattered pages.
  • Checkpointing. Periodically the DB flushes dirty pages and records "everything up to LSN X is on disk", letting old WAL segments be recycled. Aggressive checkpoints cause write spikes; lazy ones lengthen recovery.
  • Crash recovery. On restart: replay WAL from the last checkpoint (redo), then roll back uncommitted transactions (undo). Durability comes from the log, not from data files being current.
Real-world analogy

WAL is writing the receipt before rearranging the warehouse. When a sale happens, the clerk scribbles one line in a ledger at the front desk (fast, sequential, one pen stroke) and only later walks the aisles to move boxes around (slow, scattered work, done in batches). If the power dies mid-restock, the ledger is truth: replay it and the warehouse ends up correct. That is exactly redo recovery — and why databases can acknowledge a commit long before the data files reflect it.

Database server process Parser Planner / optimizer Executor Buffer pool hot pages in RAM WAL / redo log sequential append + fsync Heap + index pages random I/O, flushed lazily plan read / dirty 1 log first 2 checkpoint cache-miss read

9.2 Storage engines: B-tree vs LSM-tree

B+tree — the read-optimized default

  • Structure: a balanced tree where internal nodes hold only keys (routing) and leaves hold data (or row pointers), with leaves linked for range scans. Each node is one page.
  • Fan-out is the magic. A 16 KB page holds ~400–1000 keys. Fan-out 500 means 500³ = 125 million rows in 3 levels, ~62 billion in 4. And the top two levels are always in the buffer pool — a point lookup on a billion-row table is typically 1 disk read.
  • Page splits: inserting into a full leaf splits it in two and pushes a separator key up — this is why random-key inserts (UUIDv4 PKs) cause fragmentation and write amplification, while sequential keys append to the rightmost leaf.
  • Update in place: writes dirty existing pages → random I/O, but reads are predictable. Used by InnoDB, Postgres, SQL Server, Oracle.

LSM-tree — the write-optimized challenger

  • Write path: append to WAL → insert into an in-RAM sorted memtable (skip list). When the memtable fills (~64 MB), it's flushed as an immutable sorted file — an SSTable. All disk writes are sequential.
  • Read path: check memtable, then SSTables newest→oldest. Each SSTable carries a Bloom filter (skip files that definitely lack the key) and a sparse index (find the block within the file). Deletes are tombstone writes.
  • Compaction merges overlapping SSTables, drops overwritten values and expired tombstones. Leveled compaction (RocksDB default) keeps each level 10× larger with non-overlapping files — better read/space amplification, more compaction I/O. Size-tiered (Cassandra default) merges similar-sized files — cheaper writes, worse space amplification (same key in many files).
  • The three amplifications: LSM trades read amplification (check many files) for low write amplification on ingest; compaction adds background write amplification; size-tiered adds space amplification. Tuning an LSM store is choosing which one to pay.
Real-world analogy

LSM = inbox → sorted binders. New paperwork lands in an inbox on your desk (memtable — fast, unsorted arrival, sorted as you slot it in). When the inbox fills, you file it as one new sorted binder on the shelf (SSTable flush). To find a document you check the inbox, then binders newest-first — the label on each binder's spine tells you instantly if it can't contain what you want (Bloom filter). Every few weeks you merge overlapping binders and shred superseded pages (compaction). Filing is always fast; searching costs more the longer you postpone re-filing.

DimensionB+treeLSM-tree
Point readExcellent — O(log n), ~1 I/O with warm cacheGood — Bloom filters help, but may touch several SSTables
Range scanExcellent — linked leaves in key orderOK — must merge across runs
Write throughputGood; random page writes, splits under random keysExcellent — sequential-only ingest
Write amplificationWhole page rewritten per changed rowLow at ingest; compaction re-writes data ~10–30× over its life
Space amplification~1.5× (page slack, fragmentation)Higher in size-tiered; low in leveled
Used byMySQL/InnoDB, Postgres, SQL Server, OracleRocksDB, LevelDB, Cassandra, ScyllaDB, HBase, InfluxDB
Pick whenRead-heavy OLTP, transactions, range queriesWrite-heavy ingest: events, metrics, logs, feeds
// Toy LSM tree: WAL + memtable + SSTable flush + Bloom-ish skip + compaction.
type Value = string | null;                        // null = tombstone (delete marker)
interface Entry { key: string; value: Value; }

/** Immutable sorted run on disk. Real SSTables add a sparse index + true Bloom filter. */
class SSTable {
  private readonly keySet: Set<string>;      // stand-in for a Bloom filter
  constructor(readonly entries: Entry[]) {         // entries pre-sorted by key
    this.keySet = new Set(entries.map(e => e.key));
  }
  mightContain(key: string): boolean { return this.keySet.has(key); }
  get(key: string): Value | undefined {            // binary search: sorted file, O(log n)
    let lo = 0, hi = this.entries.length - 1;
    while (lo <= hi) {
      const mid = (lo + hi) >> 1;
      if (this.entries[mid].key === key) return this.entries[mid].value;
      this.entries[mid].key < key ? (lo = mid + 1) : (hi = mid - 1);
    }
    return undefined;
  }
}

class LsmTree {
  private memtable = new Map<string, Value>(); // real impl: skip list, kept sorted
  private wal: string[] = [];                      // sequential append-only log
  private sstables: SSTable[] = [];                // newest first
  constructor(private readonly memtableLimit = 4) {}

  put(key: string, value: Value): void {
    this.wal.push(JSON.stringify({ key, value })); // 1) durability: WAL append first
    this.memtable.set(key, value);                 // 2) then the in-RAM structure
    if (this.memtable.size >= this.memtableLimit) this.flush();
  }
  delete(key: string): void { this.put(key, null); } // deletes are just writes

  get(key: string): Value | undefined {
    if (this.memtable.has(key)) return this.memtable.get(key); // newest wins
    for (const t of this.sstables) {               // newest → oldest
      if (!t.mightContain(key)) continue;          // Bloom filter skips most files
      const v = t.get(key);
      if (v !== undefined) return v;               // may be null (tombstone) = deleted
    }
    return undefined;
  }

  /** Memtable full → one immutable sorted run. Sequential disk I/O only. */
  private flush(): void {
    const run = [...this.memtable.entries()]
      .sort(([a], [b]) => a.localeCompare(b))
      .map(([key, value]) => ({ key, value }));
    this.sstables.unshift(new SSTable(run));
    this.memtable.clear();
    this.wal = [];                                 // flushed data no longer needs the WAL
    if (this.sstables.length > 4) this.compact();  // too many runs → reads degrade
  }

  /** Merge all runs; newest value per key wins; tombstones finally dropped. */
  private compact(): void {
    const merged = new Map<string, Value>();
    for (const t of [...this.sstables].reverse())  // oldest first so newer overwrites
      for (const e of t.entries) merged.set(e.key, e.value);
    const live = [...merged.entries()]
      .filter(([, v]) => v !== null)               // physical delete happens HERE
      .sort(([a], [b]) => a.localeCompare(b))
      .map(([key, value]) => ({ key, value }));
    this.sstables = [new SSTable(live)];           // size-tiered-style full merge
  }
}
RAM Disk (all writes sequential) Writer Memtable sorted skip list WAL L0 SSTables small, overlapping L1 10× larger L2 … Bloom filter per SSTable append first (durability) flush compact Write-heavy ingest? LSM: Cassandra / RocksDB yes no Transactions, joins, relational integrity? Postgres / MySQL (B-tree) yes no Flexible nested documents per entity? MongoDB / DocumentDB yes no Sub-ms lookups, cache / counters / leaderboards? Redis / ElastiCache yes no Default: Postgres. Boring choice, best ecosystem; add others later

9.3 Indexes — the fine print

  • Clustered vs secondary. A clustered index is the table (InnoDB: rows live in the PK B+tree). Secondary indexes store the indexed columns plus the PK; a secondary lookup therefore costs two B-tree descents — index → PK → row. Fat PKs (36-byte UUID strings) bloat every secondary index. Postgres has no clustered index; all indexes point into the heap.
  • Composite indexes & leftmost prefix. An index on (user_id, status, created_at) serves WHERE user_id=?, user_id=? AND status=?, and the full triple — but is nearly useless for WHERE status=? alone. Order columns: equality filters first, then the range/sort column. A range on a middle column stops use of columns after it.
  • Covering indexes / index-only scans. If every selected column is in the index, the engine never touches the table — often a 10× win. In Postgres INCLUDE (col) adds payload columns without making them key columns.
  • Partial indexes: CREATE INDEX ... WHERE status = 'PENDING' — tiny, hot, perfect for queue-like tables where 99% of rows are terminal.
  • When indexes hurt: every index is another B-tree updated per write (write amplification), and low-selectivity indexes (e.g. on a boolean) are ignored by the planner anyway — a full scan is cheaper than millions of random heap fetches.
Real-world analogy

Indexes are the library card catalog. The books (rows) sit on shelves in acquisition order (heap) or sorted by ISBN (clustered PK). The catalog card gives you the shelf location — one more walk to fetch the book (the hidden PK/heap lookup). A card sorted by author, then year is useless if you only know the year (leftmost prefix). And if the card itself contains the summary you needed, you never walk to the shelf at all (covering index).

EXPLAIN ANALYZE
SELECT o.id, o.total_cents
FROM   orders o
WHERE  o.user_id = 42 AND o.status = 'PAID'
ORDER  BY o.created_at DESC
LIMIT  20;

-- GOOD plan, with index (user_id, status, created_at DESC):
--   Limit (actual rows=20)
--     -> Index Scan using idx_orders_user_status_created on orders
--          Index Cond: (user_id = 42 AND status = 'PAID')
--   No Sort node: the index already emits rows in ORDER BY order,
--   so LIMIT 20 stops after reading 20 index entries. O(20), not O(rows).

-- RED FLAGS to hunt in any plan:
--   Seq Scan on a large table          → missing or unusable index
--   Rows Removed by Filter: 2,481,904  → index not selective / wrong column order
--   Sort Method: external merge Disk   → ORDER BY not covered; spilling to disk
--   Nested Loop, outer rows=1.2M       → stale statistics; run ANALYZE
--   estimated rows=1 vs actual=500000  → planner misestimate → wrong join choice

9.4 Joins — how the optimizer executes them

There are only three join algorithms. The planner picks per-join based on row estimates, available indexes, sort order, and memory — which is why stale statistics silently flip a fast plan into a disaster.

type Row = Record<string, unknown>;

/** NESTED LOOP: for each outer row, find matches in inner.
 *  Naive O(N·M); with an index on the inner key, O(N·log M).
 *  Planner picks it when the outer side is SMALL and the inner side is indexed. */
function nestedLoopJoin(outer: Row[], inner: Row[],
                        match: (o: Row, i: Row) => boolean): Row[] {
  const out: Row[] = [];
  for (const o of outer)                 // small driving table
    for (const i of inner)               // real DBs: index probe, not a scan
      if (match(o, i)) out.push({ ...o, ...i });
  return out;
}

/** HASH JOIN: build a hash table on the smaller input, probe with the larger.
 *  O(N + M) time, O(min(N, M)) memory; spills to disk partitions if too big.
 *  Planner's choice for large unsorted equijoins. Equality predicates ONLY. */
function hashJoin(build: Row[], probe: Row[], key: string): Row[] {
  const table = new Map<unknown, Row[]>();
  for (const b of build) {                          // phase 1: build (small side)
    const bucket = table.get(b[key]) ?? [];
    bucket.push(b); table.set(b[key], bucket);
  }
  const out: Row[] = [];
  for (const p of probe)                            // phase 2: probe (big side)
    for (const b of table.get(p[key]) ?? []) out.push({ ...b, ...p });
  return out;
}

/** SORT-MERGE JOIN: both inputs sorted on the key → one interleaved pass, O(N + M).
 *  Wins when inputs are ALREADY sorted (index order) or must be sorted anyway. */
function mergeJoin(left: Row[], right: Row[], key: string): Row[] {
  const out: Row[] = []; let i = 0, j = 0;
  const lt = (a: unknown, b: unknown) => String(a) < String(b);
  while (i < left.length && j < right.length) {
    if (lt(left[i][key], right[j][key])) i++;
    else if (lt(right[j][key], left[i][key])) j++;
    else {                                          // equal keys: emit cross-group
      const k = left[i][key]; const jStart = j;
      for (; i < left.length && left[i][key] === k; i++)
        for (j = jStart; j < right.length && right[j][key] === k; j++)
          out.push({ ...left[i], ...right[j] });
    }
  }
  return out;
}
AlgorithmCostMemoryPlanner picks it when…
Nested loop (+ index)O(N·log M)TinySmall driving side, indexed inner side; also the only option for non-equi predicates
Hash joinO(N + M)Hash table of smaller side (spills if over work_mem)Large unsorted equijoins — the OLAP workhorse
Sort-merge joinO(N + M) (+ sort if needed)ModerateBoth inputs already sorted on the key, or output must be sorted anyway

Distributed joins: when tables live on different shards, the engine either broadcasts the small table to every node holding the big one (cheap if it fits in RAM everywhere) or shuffles both tables across the network partitioned by join key so matching rows land on the same node (expensive but scales). Data warehouses (Redshift, Spark, BigQuery) spend most of their optimizer effort avoiding shuffles — and co-locating tables on the same distribution key eliminates the network step entirely.

9.5 Concurrency: MVCC, isolation, locks

  • MVCC core idea: writers create new versions instead of overwriting; readers pick the version visible to their snapshot. Result: readers never block writers and writers never block readers — no read locks at all.
  • Postgres: an UPDATE writes a whole new row version in the heap; old versions stay until VACUUM reclaims them (hence table bloat on hot-update tables). Each tuple carries xmin/xmax transaction IDs.
  • InnoDB: updates in place, but stashes the before-image in the undo log; readers reconstruct old versions by walking undo chains. Long-running transactions pin undo history — the InnoDB analog of bloat.
  • Locks still exist for write-write conflicts: shared (S) vs exclusive (X); row-level vs table-level; intention locks to make them compatible. InnoDB adds gap locks under REPEATABLE READ to lock the space between index entries and stop phantom inserts.
  • Deadlocks arise from acquiring rows in different orders; engines detect cycles in the waits-for graph and kill one victim. Fix: touch rows in a consistent order, keep transactions short, retry on deadlock errors (they are normal, not bugs).
/** Simplified Postgres-style tuple visibility — the heart of MVCC.
 *  Every row version carries the txid that created it (xmin) and,
 *  if superseded/deleted, the txid that did so (xmax). An UPDATE is
 *  "set xmax on old version + insert new version with fresh xmin". */
interface TupleVersion<T> {
  xmin: number;               // creator transaction id
  xmax: number | null;        // deleter/updater txid, null = still live
  data: T;
}
interface Snapshot {
  xid: number;                // my transaction id
  inProgress: Set<number>;    // txids still running when snapshot was taken
}

function isVisible<T>(v: TupleVersion<T>, s: Snapshot): boolean {
  // A txid's effects are visible if it's me, or it committed before my snapshot.
  const sees = (xid: number) =>
    xid === s.xid || (xid < s.xid && !s.inProgress.has(xid));
  if (!sees(v.xmin)) return false;                 // creator invisible → row not born yet
  if (v.xmax !== null && sees(v.xmax)) return false; // visible tx deleted it → gone
  return true;   // decided with pure arithmetic — no lock was ever taken to read
}
// Old versions where xmax is globally visible are garbage → VACUUM reclaims them.
Isolation levelDirty readNon-repeatable readPhantomWrite skew
READ UNCOMMITTEDPossiblePossiblePossiblePossible
READ COMMITTED (PG default)NoPossiblePossiblePossible
REPEATABLE READ (InnoDB default)NoNoStandard allows; InnoDB blocks via gap locks, PG blocks via snapshotPossible
SERIALIZABLENoNoNoNo (PG uses SSI — optimistic, aborts on conflict)

9.6 MySQL / InnoDB mini-tour

  • The table is the PK B+tree (clustered): rows physically ordered by primary key. Sequential PKs (auto-increment, ULID, UUIDv7) append to the last page; random UUIDv4 keys splatter inserts across the tree, causing page splits, half-empty pages, and buffer-pool churn.
  • Redo log (WAL) gives durability; undo log gives MVCC and rollback; the doublewrite buffer protects against torn 16 KB pages on 4 KB-atomic disks.
  • Buffer pool with midpoint-insertion LRU so a full table scan can't evict the hot working set.
  • Adaptive hash index: InnoDB watches access patterns and builds an in-RAM hash over hot B-tree pages, turning repeated point lookups into O(1).
  • Binlog is a separate logical log (row events) used for replication and CDC (Debezium tails it); internally each commit is a two-phase commit between the redo log and the binlog so replicas never diverge from crash-recovered state.

9.7 MongoDB mini-tour

  • BSON documents — binary JSON with real types (dates, decimals, binary), 16 MB cap per document.
  • WiredTiger engine: document-level optimistic concurrency (no page or collection locks on writes), MVCC snapshots for reads, periodic checkpoints plus a journal (WAL) between them, and on-disk compression (snappy/zstd) by default.
  • _id / ObjectId: 12 bytes = 4-byte timestamp + 5-byte random + 3-byte counter — roughly time-ordered, so inserts append rather than splatter (the same sequential-key argument as InnoDB).
  • Secondary indexes are B-trees, same leftmost-prefix rules as SQL; multikey indexes fan out array fields into multiple entries.
  • Replica sets: single primary, followers tail the oplog (an idempotent logical change stream, itself a capped collection); automatic failover by election. Tunable writeConcern/readConcern trade latency vs durability per operation.
  • Embed vs reference rule of thumb: embed what you always load together and that grows boundedly (order + its line items); reference what is shared, unbounded, or independently queried (user ↔ orders). The document model wins when your access pattern is "fetch the whole aggregate by key"; relational wins when you query the same data along many different axes.

9.8 Redis mini-tour — why is Redis so fast?

The interview classic. Four compounding reasons, none of which is "it's written in C":

  • Everything lives in RAM. A memory access costs ~100 ns vs ~100 µs for an SSD read — a ~1000× floor advantage before any cleverness. Persistence is asynchronous and off the hot path.
  • Single-threaded command execution. One thread runs all commands, so there are zero locks, no context switches, no cache-line ping-pong. At in-memory speeds the CPU is rarely the bottleneck — the network is.
  • I/O multiplexing (epoll/kqueue): one event loop watches tens of thousands of sockets, servicing whichever is ready — the same trick as Nginx/Node.
  • Data structures engineered per size class: O(1) hash-table core; SDS strings (length-prefixed, no strlen scans, binary-safe); small collections stored as listpacks (one contiguous cache-friendly blob) that transparently upgrade to hash tables / skip lists when they grow; sorted sets = hash table + skip list, giving O(1) score lookup and O(log n) rank/range ops; sets of small integers use packed intsets.
Real-world analogy

One extremely fast chef with prepped stations beats a kitchen full of chefs who queue for the same fridge. Multi-threaded servers spend real time on locks and coordination — chefs waiting for the fridge door (mutex) and bumping elbows (cache contention). Redis is a single chef with every ingredient pre-chopped within arm's reach (RAM, purpose-built encodings) and a ticket rail that never blocks (epoll event loop). No coordination cost, because there is nobody to coordinate with.

  • Modern threading: since Redis 6, I/O threads parse and write socket buffers in parallel — but command execution is still one thread, preserving the no-locks model. (Background threads have long handled fsync, big-key frees, etc.)
  • Clustering: Redis Cluster hashes keys into 16384 slots (CRC16 mod 16384) spread across primaries; multi-key ops must share a slot (hash tags {user:42}). Sentinel provides monitoring + automatic failover for non-clustered setups.
  • What Redis is NOT: a primary system of record (even AOF everysec can lose ~1s), a relational store, or a place for datasets that exceed RAM economics. Treat it as a cache/coordination layer whose loss degrades latency, not correctness.
Persistence modeMechanismDurabilityCost
RDB snapshotFork + copy-on-write dump every N minutesLose minutes on crashCheap; fast restarts; fork latency spike on huge datasets
AOF (everysec)Append every write op, fsync per secondLose ≤ ~1sLarger files, background rewrite needed
Hybrid RDB+AOF (recommended)RDB preamble + AOF tail≤ ~1sBest restart speed + near-best durability
NonePure cacheAll data lost on restartFastest; fine when it's truly a cache
$ redis-cli
127.0.0.1:6379> RPUSH tiny a b c
127.0.0.1:6379> OBJECT ENCODING tiny     # "listpack"  — small list packed in one blob
127.0.0.1:6379> ZADD board 100 alice 90 bob
127.0.0.1:6379> OBJECT ENCODING board    # "listpack"  — while under 128 entries...
#   ...past zset-max-listpack-entries it upgrades to "skiplist" for O(log n) ranges
127.0.0.1:6379> SET n 12345
127.0.0.1:6379> OBJECT ENCODING n        # "int"       — stored as a machine integer
127.0.0.1:6379> DEBUG SLEEP 5            # NEVER in prod: blocks the ONE thread —
#   every client stalls. Same reason KEYS * and huge LRANGEs are dangerous.
AWS mapping
  • RDS MySQL / RDS PostgreSQL: managed versions of exactly the engines above — same InnoDB/Postgres internals, AWS handles backups, patching, failover.
  • Aurora: keeps the MySQL/Postgres front end but replaces the storage engine's bottom half: the instance ships only WAL records to a purpose-built, log-structured storage service that 6-way replicates across 3 AZs (quorum 4/6 write, 3/6 read) and materializes pages from the log at the storage layer. No full-page writes, no checkpoint I/O from the writer — hence ~3–5× claimed throughput and sub-15s failover. It is the WAL-is-the-database idea taken to its logical conclusion.
  • DocumentDB: MongoDB-compatible API on Aurora-style shared storage (not real mongod — check driver/feature compatibility).
  • ElastiCache (Redis/Valkey): managed cache semantics; MemoryDB when you need Redis speed with a multi-AZ durable transaction log (usable as a primary store).
  • RDS Proxy: managed connection pooler — essential in front of Lambda or any spiky fleet, since Postgres/MySQL degrade badly past a few thousand connections (per-connection processes/threads + memory).
  • Build custom only at the extremes: embedded RocksDB inside a stateful service, or bespoke storage à la Aurora when you are a cloud provider. Otherwise buy.
Pitfalls
  • SELECT * defeats covering indexes, drags TOAST/off-page blobs across the wire, and breaks when columns are added. Select what you use.
  • Unbounded IN (...) lists (10k IDs from the app) blow up parse/plan time and can flip plans. Use a temp table / ANY(array) / join.
  • OFFSET pagination on big tables: OFFSET 500000 reads and discards 500k rows every page. Use keyset pagination: WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20.
  • Wrong composite order: index (created_at, user_id) cannot serve WHERE user_id = ? — leftmost prefix rule. Equality columns first, range/sort last.
  • Over-indexing: 12 indexes on a hot write table means 12 extra B-tree updates per insert plus a bigger WAL. Audit with pg_stat_user_indexes / sys.schema_unused_indexes and drop the dead ones.
  • Random UUIDv4 primary keys on clustered engines: page splits, fragmentation, cold-cache inserts. Prefer UUIDv7/ULID or auto-increment.
  • Long-running transactions pin undo/old versions → InnoDB history-list growth, Postgres bloat, replication lag. Keep transactions milliseconds-short.
Production best practice
  • EXPLAIN in CI: run EXPLAIN on hot queries against a prod-like dataset in CI; fail the build if a Seq Scan or plan regression appears on a critical path.
  • Slow query log always on (e.g. long_query_time=200ms, pg_stat_statements); review top offenders weekly — the top 5 queries are usually 80% of load.
  • Connection pooling is non-negotiable: databases hate 10k connections. PgBouncer / ProxySQL / RDS Proxy in front; size pools near (cores × 2) + spindles, not "one per app thread".
  • ANALYZE after bulk loads and monitor estimated-vs-actual rows; most "the optimizer is broken" incidents are stale statistics.
  • Test crash recovery and restore paths — a backup you have never restored is a hypothesis, not a backup.
What interviewers probe
  • Q: Why is my query slow at 100M rows but fine at 1M? A: Working set no longer fits the buffer pool, so logical reads became random disk I/O; and the planner may have flipped strategies (index scan → seq scan, nested loop → hash) as row estimates crossed thresholds. Diagnose with EXPLAIN ANALYZE: look for Seq Scans, disk sorts, and estimate-vs-actual gaps — then fix with a covering/composite index, keyset pagination, or partitioning.
  • Q: B-tree vs LSM — when each? A: B-tree: read-heavy OLTP, range scans, predictable point-read latency (~1 I/O via fan-out). LSM: write-heavy ingest — sequential-only writes, deferred organization via compaction; you pay read amplification, mitigated by Bloom filters. Frame it as trading read vs write vs space amplification.
  • Q: How does MVCC avoid locks? A: Writers create new row versions; each reader gets a snapshot and applies pure visibility arithmetic (xmin/xmax vs snapshot) — no read locks, so readers and writers never block each other. Locks remain only for write-write conflicts. Mention the garbage cost: VACUUM / undo purge.
  • Q: Why sequential UUIDs/ULIDs for primary keys? A: On clustered storage the PK dictates physical placement — sequential keys append to the rightmost hot page; random ones cause page splits, fragmentation, and buffer-pool churn, plus fatter secondary indexes. UUIDv7/ULID keep global uniqueness while restoring locality.
  • Q: Explain a hash join. A: Build an in-memory hash table keyed on the join key from the smaller input, then stream the larger input probing it — O(N+M), equality-only, spills to partitioned files when the build side exceeds memory (Grace hash join).
  • Q: Isolation level for a fintech ledger? A: Default READ COMMITTED + explicit invariants: balance checks via SELECT ... FOR UPDATE or serializable for the transfer path only; know that REPEATABLE READ still permits write skew and that PG SERIALIZABLE (SSI) requires retry logic.
  • Q: Why is Redis fast, really? A: RAM-only data path, single-threaded lock-free execution, epoll multiplexing, and size-adaptive data structures (listpack→skiplist, SDS, intset). Since v6, I/O threads parallelize socket work but commands still execute on one thread — so one slow command (KEYS *) still stalls everyone.
References
Part III · Data

10. ACID, BASE & Distributed Transactions

Transactions are where fintech interviews get serious: everyone can recite ACID, few can show a two-transaction interleaving that produces write skew, and fewer still can design a saga with compensations that survive partial failure. This chapter covers single-node correctness (isolation anomalies, optimistic vs pessimistic locking) and then the distributed story — why 2PC lost, why sagas won, and why idempotency is the glue that holds all of it together.

ACID — what each letter actually guarantees

ACID is a per-database, per-transaction contract. Each property is narrower than most people think:

  • Atomicity — all-or-nothing on abort: if the transaction fails midway, partial writes are rolled back. Misreading: it says nothing about concurrency. Two atomic transactions can still interleave badly — that is Isolation's job.
  • Consistency — the database moves from one valid state to another with respect to declared constraints (FKs, checks, uniqueness). Misreading: the "C" is mostly the application's responsibility; it is not CAP-consistency, and the DB cannot enforce invariants you never declared (e.g. "sum of ledger entries = 0" unless you write it as a constraint or check it in the txn).
  • Isolation — concurrent transactions behave as if serial — but only at SERIALIZABLE. Misreading: most databases default to weaker levels (Postgres: READ COMMITTED, MySQL/InnoDB: REPEATABLE READ), so out of the box you are exposed to a specific menu of anomalies (next section).
  • Durability — once committed, the write survives a crash, via WAL/fsync. Misreading: durable on one node. If the disk dies before replication catches up, "durable" data is gone — durability across nodes is a replication setting (ch11), not an ACID given.

BASE — and why it exists

BASE (Basically Available, Soft state, Eventually consistent) is not the opposite of ACID — it is what you accept when you shard/replicate for scale and refuse to pay cross-node coordination costs on every write. Reads may be stale, state converges over time, and the system stays up through partitions.

Real-world analogy

ACID is a bank vault: one door, a ledger updated under lock, every entry double-checked before the door reopens — slow, but nobody ever disputes the balance. BASE is a neighborhood tab: the bartender scribbles "Sedhu owes 2 beers", the co-owner's notebook syncs tomorrow morning; for an hour the two notebooks disagree, and that's fine — the tab converges, the bar never closes, and the cost of being briefly wrong is a beer, not a wire transfer. Pick the vault for money movement, the tab for likes and view counts.

Isolation anomalies — concrete interleavings

Staff-level answers show the interleaving, not the definition. T1/T2 are concurrent transactions; time flows down.

-- DIRTY READ (blocked at READ COMMITTED and above)
T1: UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
T2:                     SELECT balance FROM accounts WHERE id = 'A';  -- sees uncommitted -100
T1: ROLLBACK;           -- T2 acted on money that never moved

-- NON-REPEATABLE READ (possible at READ COMMITTED)
T1: SELECT balance FROM accounts WHERE id = 'A';   -- 500
T2: UPDATE accounts SET balance = 300 WHERE id = 'A'; COMMIT;
T1: SELECT balance FROM accounts WHERE id = 'A';   -- 300: same row, two answers in one txn

-- PHANTOM READ (possible below SERIALIZABLE; range re-query changes)
T1: SELECT count(*) FROM payments WHERE user_id = 42 AND day = today;  -- 3
T2: INSERT INTO payments (user_id, ...) VALUES (42, ...); COMMIT;
T1: SELECT count(*) FROM payments WHERE user_id = 42 AND day = today;  -- 4: a phantom row appeared

-- LOST UPDATE (read-modify-write race)
T1: SELECT balance FROM accounts WHERE id = 'A';   -- 500
T2: SELECT balance FROM accounts WHERE id = 'A';   -- 500
T1: UPDATE accounts SET balance = 600 WHERE id = 'A'; COMMIT;  -- +100
T2: UPDATE accounts SET balance = 550 WHERE id = 'A'; COMMIT;  -- +50, silently erases T1's +100

-- WRITE SKEW (survives SNAPSHOT ISOLATION; needs SERIALIZABLE or explicit locks)
-- Invariant: at least one on-call doctor. Both read a consistent snapshot, write DIFFERENT rows.
T1: SELECT count(*) FROM doctors WHERE on_call;    -- 2, "safe for me to leave"
T2: SELECT count(*) FROM doctors WHERE on_call;    -- 2, "safe for me to leave"
T1: UPDATE doctors SET on_call = false WHERE id = 'alice'; COMMIT;
T2: UPDATE doctors SET on_call = false WHERE id = 'bob';   COMMIT;  -- invariant broken: 0 on call
Isolation levelDirty readNon-repeatable readPhantomLost updateWrite skew
READ UNCOMMITTEDpossiblepossiblepossiblepossiblepossible
READ COMMITTEDblockedpossiblepossiblepossiblepossible
REPEATABLE READ / SNAPSHOTblockedblockedmostly blocked (MVCC)varies by enginepossible
SERIALIZABLEblockedblockedblockedblockedblocked
Real-world analogy

Write skew is two spouses checking the joint account from two phones. Both see $500, both know the rule "keep $200 minimum", both withdraw $300 from different ATMs. Each decision was locally valid against a consistent snapshot; together they broke the invariant. No dirty data was ever read — that's why snapshot isolation can't catch it.

Optimistic vs pessimistic concurrency control

  • Pessimistic: take the lock before touching the data — SELECT ... FOR UPDATE. Correct by construction, but locks are held for the transaction's lifetime: under contention you get queueing, and across services it's a deadlock factory. Use when conflict is likely (hot balance row) and the critical section is short.
  • Optimistic: read freely, then commit with a compare-and-swap on a version column; on conflict, retry. No locks held during "think time" — ideal when conflict is rare (a user's own profile, most account rows), and the only option when the read happens in one HTTP request and the write in another.
  • SELECT ... FOR UPDATE also has NOWAIT (fail fast) and SKIP LOCKED (the standard trick for building a job queue on Postgres: workers grab unlocked rows without blocking each other).
// Optimistic locking with a version column: compare-and-swap UPDATE + retry loop.
interface Account {
  id: string;
  balanceMinor: bigint;   // integer minor units -- never floats for money
  version: number;        // bumped on every successful write
}

class OptimisticLockError extends Error {}

class AccountRepo {
  constructor(private readonly db: Pool) {}

  async load(id: string): Promise<Account> {
    const { rows } = await this.db.query(
      'SELECT id, balance_minor, version FROM accounts WHERE id = $1', [id]);
    if (rows.length === 0) throw new Error(`account ${id} not found`);
    return { id: rows[0].id, balanceMinor: BigInt(rows[0].balance_minor), version: rows[0].version };
  }

  // Compare-and-swap: succeeds only if nobody else committed since our read.
  async save(a: Account, expectedVersion: number): Promise<void> {
    const res = await this.db.query(
      `UPDATE accounts
          SET balance_minor = $1, version = version + 1
        WHERE id = $2 AND version = $3`,          // <-- the CAS predicate
      [a.balanceMinor.toString(), a.id, expectedVersion]);
    if (res.rowCount === 0) throw new OptimisticLockError('version moved; retry');
  }
}

// Generic retry wrapper: reload -> mutate -> CAS, with jittered exponential backoff.
async function withOptimisticRetry<T>(
  attempt: () => Promise<T>, maxRetries = 5,
): Promise<T> {
  for (let i = 0; ; i++) {
    try {
      return await attempt();
    } catch (err) {
      if (!(err instanceof OptimisticLockError) || i >= maxRetries) throw err;
      const backoffMs = Math.min(200, 10 * 2 ** i) * (0.5 + Math.random()); // full jitter
      await new Promise((r) => setTimeout(r, backoffMs));
    }
  }
}

// Usage: debit an account. Under contention this retries instead of blocking.
async function debit(repo: AccountRepo, id: string, amountMinor: bigint): Promise<void> {
  await withOptimisticRetry(async () => {
    const acct = await repo.load(id);
    if (acct.balanceMinor < amountMinor) throw new Error('insufficient funds'); // not retryable
    acct.balanceMinor -= amountMinor;
    await repo.save(acct, acct.version);
  });
}

Distributed transactions: 2PC and why it blocks

Once the debit and the credit live in different databases (two services), a single ACID transaction no longer exists. Two-Phase Commit is the classic answer: a coordinator asks every participant to prepare (write redo/undo to disk, hold locks, vote), and only if all vote yes does it broadcast commit.

Coordinator Payments DB Ledger DB 1 PREPARE (write undo/redo, lock rows) 2 VOTE yes / yes Danger window: participants are PREPARED, holding locks. If the coordinator dies HERE, they can neither commit nor abort safely -- they block. 3 COMMIT (only if all voted yes)
  • Why it blocks: a prepared participant has promised "I can commit" and must hold row locks until it learns the outcome. If the coordinator crashes after collecting votes, participants are stuck in doubt — aborting could contradict a commit the coordinator already logged. Locks pile up; throughput collapses to zero on hot rows.
  • Other costs: latency of the slowest participant × 2 round trips on every transaction; coordinator is a single point of failure; heterogeneous participants (Kafka? a REST API? Stripe?) don't speak XA at all.
  • 3PC adds a pre-commit phase to remove blocking under crash-stop failures, but it assumes bounded network delay (no partitions) — an assumption real networks violate — so it's a mention, not a tool. Modern systems that need atomic cross-partition commit (Spanner, CockroachDB) run 2PC over Paxos/Raft groups, making each "participant" itself fault-tolerant.
Real-world analogy

2PC is a wedding officiant. "Do you take..." (prepare) — both say "I do" (vote) — "I now pronounce you..." (commit). If the officiant faints after hearing both "I do"s, the couple is stuck at the altar: neither married nor free to leave, and the venue (locks) stays occupied until someone finds the officiant's notebook.

Sagas: the industry's answer

A saga replaces one distributed transaction with a sequence of local transactions, each atomically committed in its own service, plus a compensating transaction for each step to semantically undo it if a later step fails. You trade isolation for availability and loose coupling.

ChoreographyOrchestration
MechanismEach service reacts to the previous service's event (event chain)Central orchestrator invokes each step and decides what's next
CouplingLow; no central brainServices stay dumb; orchestrator knows the flow
VisibilityPoor — the workflow exists only implicitly, across topicsExcellent — one place shows state of every payment
Failure handlingCompensation logic smeared across services; cyclic-dependency riskCompensation policy centralized and testable
Best for2–3 steps, naturally event-driven flowsMoney movement, 4+ steps, anything auditors will ask about
// Orchestrated saga: reserve funds -> capture -> ledger write, with typed compensations.
type StepName = 'reserve' | 'capture' | 'ledger';

interface PaymentCtx {
  paymentId: string;          // doubles as the idempotency key for every step
  fromAccount: string;
  toAccount: string;
  amountMinor: bigint;
  reservationId?: string;     // filled in by the reserve step
  captureId?: string;
}

interface SagaStep {
  name: StepName;
  // Both calls MUST be idempotent: retries after timeouts will replay them.
  execute(ctx: PaymentCtx): Promise<void>;
  compensate(ctx: PaymentCtx): Promise<void>;
}

type SagaResult =
  | { status: 'completed' }
  | { status: 'compensated'; failedStep: StepName; cause: Error }
  | { status: 'compensation_failed'; failedStep: StepName; stuckStep: StepName }; // page a human

const steps: SagaStep[] = [
  {
    name: 'reserve',
    async execute(ctx) {                       // local txn in the funds service
      ctx.reservationId = await fundsSvc.reserve({
        idempotencyKey: `${ctx.paymentId}:reserve`,
        account: ctx.fromAccount, amountMinor: ctx.amountMinor,
      });
    },
    async compensate(ctx) {                    // semantic undo: release the hold
      if (ctx.reservationId) await fundsSvc.release(ctx.reservationId);
    },
  },
  {
    name: 'capture',
    async execute(ctx) {
      ctx.captureId = await fundsSvc.capture(ctx.reservationId!,
        { idempotencyKey: `${ctx.paymentId}:capture` });
    },
    async compensate(ctx) {                    // money already moved: undo = refund
      if (ctx.captureId) await fundsSvc.refund(ctx.captureId);
    },
  },
  {
    name: 'ledger',
    async execute(ctx) {                       // append double-entry rows; unique on paymentId
      await ledgerSvc.postEntries({
        idempotencyKey: ctx.paymentId,
        debit: ctx.fromAccount, credit: ctx.toAccount, amountMinor: ctx.amountMinor,
      });
    },
    async compensate() { /* last step: nothing after it can fail */ },
  },
];

async function runPaymentSaga(ctx: PaymentCtx): Promise<SagaResult> {
  const done: SagaStep[] = [];
  for (const step of steps) {
    try {
      await sagaLog.record(ctx.paymentId, step.name, 'started'); // durable state machine
      await step.execute(ctx);
      await sagaLog.record(ctx.paymentId, step.name, 'done');
      done.push(step);
    } catch (cause) {
      // Unwind completed steps in REVERSE order; compensations must be retried, not skipped.
      for (const prev of done.reverse()) {
        try {
          await withOptimisticRetry(() => prev.compensate(ctx));
          await sagaLog.record(ctx.paymentId, prev.name, 'compensated');
        } catch {
          await sagaLog.record(ctx.paymentId, prev.name, 'compensation_failed');
          return { status: 'compensation_failed', failedStep: step.name, stuckStep: prev.name };
        }
      }
      return { status: 'compensated', failedStep: step.name, cause: cause as Error };
    }
  }
  return { status: 'completed' };
}

Saga pitfalls you must name

  • Compensations can fail too. A refund API can be down. Compensations must be idempotent + retried from durable state; when retries exhaust, escalate to a manual-ops queue. There is no "rollback of the rollback".
  • Semantic vs system rollback. You cannot un-send an email or un-settle a wire; you send a correction / issue a refund / post a reversing ledger entry. Compensation restores the business invariant, not the exact prior bytes.
  • Isolation is gone. Between step 1 and step 3, other transactions can observe the intermediate state (funds reserved but ledger empty) — a saga-level dirty read. Countermeasures: semantic locks (mark the payment PENDING and make readers treat pending specially), commutative operations, and re-reading state before each step.
  • The saga log itself must be durable and the trigger for each step must be atomic with the local write — which is exactly the transactional outbox pattern: write the business row and the "next step" event in one local transaction, then relay it. Full treatment in ch14.
  • Idempotency is the glue. Every step and compensation will eventually be delivered twice (timeout → retry). Key every side effect on paymentId:step and make handlers no-ops on replay. Without this, a saga is a duplicate-payment generator.
AWS mapping
  • Step Functions — managed saga orchestrator: standard workflows give exactly-once, durable state transitions, per-step retry/backoff/catch, and a visual audit trail; model compensations as Catch routes to undo states. Pick this over hand-rolled orchestration unless step latency must be <25 ms or volume makes per-transition pricing hostile.
  • DynamoDB transactionsTransactWriteItems: ACID across up to 100 items/tables within DynamoDB (it runs 2PC internally, ~2× WCU cost). Great for "reserve + write status" in one service; not a cross-service transaction.
  • RDS/Aurora — full single-node ACID; keep each saga step's local transaction here. Aurora Postgres supports SERIALIZABLE (SSI) when you need write-skew protection.
  • SQS/SNS/EventBridge — choreography transport; pair with the outbox pattern (ch14) and DLQs for poison compensations.
  • Build custom (DB-backed state machine) only when you need sub-ms transitions or exotic control flow; you will re-implement retries, timeouts, and audit — badly, at first.
What interviewers probe
  • Q: How do you do a money transfer across two services? A: Never 2PC across services. Orchestrated saga: reserve (hold) on the debit side → capture → credit/ledger write, each a local ACID transaction, each idempotent on a payment ID, compensations (release/refund) for failure paths, saga state persisted (Step Functions or an outbox-driven state machine), and reconciliation as the final safety net.
  • Q: Why not 2PC? A: Blocking on coordinator failure while holding locks, availability coupled to every participant, 2 RTT latency floor, and heterogeneous participants (external PSPs, Kafka) don't speak XA. It survives inside single systems (DynamoDB transactions, Spanner over Raft groups), not between services.
  • Q: Your saga fails at step 2 of 3 — what happens? A: Compensate completed steps in reverse order, retried from durable state; if a compensation exhausts retries, park it in a manual-remediation queue and alert — never silently drop.
  • Q: What's write skew and which level stops it? A: Two txns read overlapping data, write disjoint rows, jointly break an invariant; snapshot isolation misses it; you need SERIALIZABLE or a materialized lock (SELECT FOR UPDATE on a witness row).
  • Q: Optimistic or pessimistic locking for account balances? A: Contention-dependent: optimistic with retry for typical accounts (conflicts rare), pessimistic or single-writer serialization (queue per account) for hot merchant accounts where optimistic would retry-storm.
  • Q: How do you prevent double-charging on retry? A: Idempotency keys stored with the response, unique constraint on (payment_id, step), and the outbox pattern so "write DB + emit event" can't half-happen.
Pitfalls
  • Assuming your DB default is SERIALIZABLE. Postgres defaults to READ COMMITTED — check-then-write logic (limits, uniqueness by SELECT) is racy without explicit locking.
  • Read-modify-write in application code without a version column or FOR UPDATE — the classic lost-update bug that appears only under load.
  • Retrying non-retryable errors: "insufficient funds" retried 5 times is 5 declined-payment latencies; classify errors before retrying (see the OptimisticLockError filter above).
  • Sagas without idempotency keys: any timeout + retry becomes a duplicate side effect.
  • Treating compensation as guaranteed: a compensation that can fail with no alert is a silent money leak.
  • Floats for money. Use integer minor units (bigint) or a decimal type, always.
Production best practice
  • Keep transactions short: no network calls (HTTP, Stripe, S3) inside an open DB transaction — locks held across a 2 s external call will melt a hot table.
  • Money movement: append-only double-entry ledger, unique constraint on the idempotency key, and a daily reconciliation job comparing internal ledger vs provider reports — reconciliation catches what sagas miss.
  • Version-stamp every mutable aggregate; make optimistic retry a shared library with jittered backoff and a retry budget.
  • Set lock_timeout / statement_timeout so a stuck lock fails fast instead of cascading into a connection-pool exhaustion outage.
References
Part III · Data

11. Replication, Sharding & Partitioning

Every "design X at scale" interview lands here within ten minutes: one database can't hold the data (shard it) or survive alone (replicate it). The staff-level signal is knowing the failure modes — split brain, replication lag anomalies, hot partitions — and the standard machinery: quorums, consistent hashing, and a defensible shard-key choice you can justify under follow-up fire.

Why replicate

  • High availability — a node dies (and it will: disks, AZs, deploys), a replica takes over. This is the non-negotiable reason.
  • Read scale — most workloads are 10:1 to 100:1 read-heavy; fan reads out to followers.
  • Geo-latency — put copies near users: Sydney reads from Sydney (~1 ms) instead of Virginia (~200 ms RTT). Physics is not negotiable.

Leader–follower replication

One node (leader) accepts writes and streams its change log to followers. The key dial is when the leader acknowledges the client:

ModeAck whenDurabilityWrite latencyFailure behavior
SynchronousAll (or a fixed) follower(s) confirmedStrong — no committed write lost on leader deathWorst of leader + slowest sync follower; one slow follower stalls all writesSync follower down ⇒ writes block
AsynchronousLeader's local commit onlyWeak — leader dies ⇒ un-replicated tail is lostLowestFully available, silently loses recent writes on failover
Semi-syncLeader + at least one followerGood — one durable copy survives leader loss+ one fast-follower RTTThe production default (MySQL semi-sync, PG synchronous_standby_names ANY 1)

Failover dangers

  • Split brain: the old leader wasn't dead — just partitioned or GC-pausing. A new leader is promoted; now two nodes accept writes and the dataset forks. Guard rails: quorum-based leader election (Raft), fencing tokens (STONITH — "shoot the other node in the head"), and generation numbers on every write.
  • Lost writes: with async replication, the promoted follower may be seconds behind; the old leader's tail of acknowledged writes vanishes. GitHub's 2018 incident is the canonical war story — MySQL failover across a 43-second partition left inconsistent writes on both sides.
  • Failover timing: too aggressive a health check ⇒ flapping and unnecessary failovers during load spikes; too lazy ⇒ minutes of downtime. There is no clean answer, only tuned timeouts.
Real-world analogy

Split brain is two managers who both think they're running the shift. The store owner (health check) couldn't reach the manager for five minutes and phoned the assistant: "you're in charge." Then the manager walks back from the bathroom. Both now approve conflicting discounts (writes), and tomorrow's inventory (the dataset) reconciles to nonsense. The fix is a rule everyone respects: you're only in charge if you hold the single physical key to the register (quorum lease / fencing token).

Multi-leader and leaderless

  • Multi-leader (each region accepts writes): great write latency, but concurrent writes to the same key will conflict. Last-write-wins (LWW) resolution is dangerous: clock skew decides the winner and the "loser" is silently deleted — data loss by design (Cassandra's default). Alternatives: keep siblings and make the app merge (Riak), or use CRDTs — data types (counters, sets, maps) whose merge operation is mathematically commutative/associative/idempotent, so replicas converge without coordination regardless of delivery order. Redis Enterprise CRDBs and collaborative editors use them; they're the one paragraph you should be able to say fluently, not implement.
  • Leaderless / quorum (Dynamo-style: Cassandra, Riak): any replica accepts writes. With N replicas, write to W, read from R; if W + R > N the read set intersects the write set, so at least one replica in every read has the latest value (typical: N=3, W=2, R=2). Stale replicas get fixed by read repair and anti-entropy (Merkle-tree sync). Sloppy quorum: during a partition, writes land on any W reachable nodes — even non-home nodes — which hold the data with a "hinted handoff" note and forward it when the home node returns. Availability up, but W+R>N no longer guarantees reading your write.

Replication lag and the guarantees to name-drop

Async followers run 10 ms – seconds behind. Three user-visible anomalies, each with a named guarantee that fixes it:

  • Read-your-writes: user updates their profile, refreshes, sees the old value — reads route to a lagging follower. Mitigations: pin the user's reads to the leader for N seconds after their own write (sticky-after-write), route by timestamp ("read from a replica only if it has applied LSN ≥ my last write"), or read-own-data always from leader.
  • Monotonic reads: two successive reads hit different replicas and time appears to go backwards (comment visible, then gone). Mitigation: hash each user to a fixed replica.
  • Consistent prefix: observer sees the answer before the question because two causally-related writes replicated through different partitions out of order. Mitigation: write causally related data to the same partition, or use causal-consistency machinery.
  • Lag-aware routing: production readers export replica lag as a metric and the routing tier drops replicas whose lag exceeds a threshold (e.g., 500 ms) from the read pool.
Real-world analogy

Replication lag is a newspaper syndicate. The newsroom (leader) knows the story now; regional printing presses (followers) print it hours later. Read-your-writes is the reporter demanding to see her own article — she reads the newsroom copy, not the regional edition. Monotonic reads means you always buy from the same newsstand, so you never read Tuesday's paper after Wednesday's.

Partitioning / sharding

Replication copies the whole dataset; partitioning splits it so each shard holds a subset — needed when data or write throughput exceeds one machine. Two families:

Key-range partitioningHash partitioning
AssignmentContiguous ranges (A–F, G–M, …) — HBase, early Bigtablehash(key) → partition — Cassandra, DynamoDB
Range scansEfficient — one shard serves ts BETWEEN a AND bLost — keys scatter; range query = scatter-gather
Hot-spot riskHigh — sequential keys (timestamps!) hammer the tail shardLow — uniform spread by construction
RebalancingSplit/merge ranges dynamicallyConsistent hashing or fixed partition count
Use whenTime-series scans, lexicographic locality mattersPoint lookups dominate (most OLTP)

Consistent hashing — the algorithm to whiteboard

Naive hash(key) mod N is a trap: adding one node changes N and remaps ~all keys — a full-cluster cache stampede. Consistent hashing maps both nodes and keys onto a ring; a key belongs to the first node clockwise. Adding/removing a node only moves ~1/N of keys. Virtual nodes (each physical node gets 100–200 ring positions) smooth out variance and let heterogeneous nodes take proportional load.

A1 B1 A2 B2 C1 C2 k key "user:42" hashes here, walks clockwise → owned by B1 Legend A1, A2 = vnodes of node A B1, B2 = vnodes of node B C1, C2 = vnodes of node C Remove node B: only B1/B2 arcs remap, to A2 and C2 — ~1/3 of keys. mod-N would remap ~all keys (cache stampede). More vnodes (100–200/node) ⇒ smoother load spread.
// Consistent hash ring with virtual nodes.
// Ring = sorted array of vnode hash positions; lookup = binary search clockwise.
import { createHash } from 'crypto';

interface VNode { position: number; nodeId: string; }

function hashToUint32(input: string): number {
  // First 4 bytes of md5 as an unsigned 32-bit ring position (fine for placement,
  // NOT for security). Production: murmur3/xxhash for speed.
  return createHash('md5').update(input).digest().readUInt32BE(0);
}

export class ConsistentHashRing {
  private ring: VNode[] = [];              // kept sorted by position
  private readonly nodes = new Set<string>();

  constructor(private readonly vnodesPerNode = 150) {}

  addNode(nodeId: string): void {
    if (this.nodes.has(nodeId)) return;
    this.nodes.add(nodeId);
    for (let i = 0; i < this.vnodesPerNode; i++) {
      this.ring.push({ position: hashToUint32(`${nodeId}#vn${i}`), nodeId });
    }
    this.ring.sort((a, b) => a.position - b.position);
  }

  removeNode(nodeId: string): void {
    this.nodes.delete(nodeId);
    this.ring = this.ring.filter((v) => v.nodeId !== nodeId);
    // Keys on the removed vnodes' arcs now fall through to the next
    // clockwise vnode automatically -- only ~1/N of keys move.
  }

  /** Owner of a key: first vnode clockwise from hash(key). */
  lookup(key: string): string {
    if (this.ring.length === 0) throw new Error('empty ring');
    const h = hashToUint32(key);
    let lo = 0, hi = this.ring.length - 1, idx = 0; // binary search: first pos >= h
    if (h > this.ring[hi].position) {
      idx = 0;                                      // wrap past 12 o'clock
    } else {
      while (lo <= hi) {
        const mid = (lo + hi) >> 1;
        if (this.ring[mid].position >= h) { idx = mid; hi = mid - 1; }
        else lo = mid + 1;
      }
    }
    return this.ring[idx].nodeId;
  }

  /** N distinct nodes clockwise -- the replica set for a key (Dynamo-style). */
  lookupN(key: string, n: number): string[] {
    const out: string[] = [];
    if (this.ring.length === 0) return out;
    let i = this.ring.findIndex((v) => v.position >= hashToUint32(key));
    if (i === -1) i = 0;
    while (out.length < Math.min(n, this.nodes.size)) {
      const id = this.ring[i % this.ring.length].nodeId;
      if (!out.includes(id)) out.push(id);          // skip vnodes of already-chosen nodes
      i++;
    }
    return out;
  }
}

Resharding strategies

  • Fixed partition count (Kafka, Cassandra, Elasticsearch): create far more partitions than nodes up front (e.g., 1,024); rebalancing moves whole partitions between nodes — keys never re-hash. Simple and predictable; the cost is choosing the count right, since Kafka can't split a partition later without breaking key ordering.
  • Dynamic splitting (HBase, DynamoDB, MongoDB ranges): a partition that exceeds a size/throughput threshold splits in two. Adapts to skew automatically; the cost is split/migration churn and brief hot moments during splits.

Hot partitions & the celebrity problem

  • Hashing spreads keys, not load: if one key is Taylor Swift's timeline, its whole partition melts while others idle.
  • Key salting: split the hot key into key#0..key#15, writes pick a random suffix, reads fan out and merge over 16 sub-keys. Trade: write scale × read amplification.
  • Dedicated shards: detect celebrities (top-K by traffic) and route them to isolated, over-provisioned capacity with their own cache tier.
  • Request coalescing: thousands of identical concurrent reads for the hot key collapse into one backend fetch (singleflight) + aggressive edge caching — often the read-side celebrity fix.

Secondary indexes on sharded data

  • Local (document-partitioned) index: each shard indexes only its own rows. Writes stay single-shard (fast); a query by the indexed field must scatter-gather every shard — latency is the p99 of the slowest shard, and cost grows with shard count.
  • Global (term-partitioned) index: the index itself is sharded by the indexed value; reads hit exactly one index shard, but a single row write now touches multiple shards ⇒ the index is updated async (DynamoDB GSIs are eventually consistent for exactly this reason).
  • Rule of thumb: local index for write-heavy + rare cross-shard queries; global index for read-heavy lookups on that field.

Shard routing tier

  • Someone must answer "which shard owns key K": (1) shard-aware clients pulling the ring/partition map (Cassandra drivers), (2) a routing proxy (Redis Cluster MOVED redirects, mongos, Vitess vtgate), or (3) a coordination service holding the map (ZooKeeper/etcd) that routers watch.
  • The map changes during rebalancing — routers cache it and must handle "wrong shard, here's the new owner" redirects gracefully.

Choosing a shard key

High-cardinality key that most queries include? no Redesign or composite key yes Need range scans on that key? yes Range partition + split hot ranges no Skewed access? (celebrity keys) yes Hash + salt hot keys / dedicated shards no Hash partition on it (consistent hashing) Examples: payments → hash(account_id); chat → hash(conversation_id); IoT metrics → (device_id, day) composite.
AWS mapping
  • Aurora — up to 15 read replicas off shared storage (typically <20 ms lag), reader endpoint for lag-tolerant reads; Aurora Global Database for cross-region (~<1 s lag, RPO seconds on region failover). Pick when you want SQL + managed replication and your write volume fits one writer.
  • DynamoDB — partitioning is fully managed: items hash-partitioned on the partition key, splits are automatic, adaptive capacity shifts throughput toward hot partitions — but a single item is still capped (~3,000 RCU / 1,000 WCU per key), so a true celebrity key still throttles: salt it or front it with DAX/ElastiCache.
  • ElastiCache (Redis) cluster mode — 16,384 hash slots across shards (fixed-partition-count model); resharding moves slots online.
  • Build custom sharding (app-level over RDS, Vitess-style) only when you need SQL semantics and beyond-one-writer scale — expect to own routing, resharding, and cross-shard queries.
What interviewers probe
  • Q: What happens when you add a node? A: With mod-N, ~(N-1)/N of keys remap — cache stampede / mass migration. With consistent hashing, only ~1/N of keys (the arcs the new node's vnodes claim) move, streamed from clockwise successors while the ring keeps serving.
  • Q: Design for a celebrity user. A: Detect top-K hot keys; salt their writes across sub-keys, coalesce + cache their reads at the edge, optionally pin them to dedicated over-provisioned shards; never let one key share fate with normal tenants.
  • Q: Sync or async replication? A: Semi-sync (ANY 1) as default: one guaranteed durable copy without paying slowest-follower latency; pure sync for regulatory zero-RPO, pure async only for disposable data or cross-region where sync latency is physically absurd.
  • Q: A user updates their profile and doesn't see it — why? A: Read hit a lagging follower; fix with read-your-writes: pin the user to the leader for N seconds after a write, or gate reads on replica LSN ≥ the user's last-write LSN.
  • Q: W=2, R=2, N=3 — is that strong consistency? A: Overlapping quorums guarantee you touch the latest write, but it's not linearizable — sloppy quorums, partial write failures, and concurrent-write conflict resolution (LWW) all break it. Say "quorum consistency, not linearizability."
  • Q: Why is a timestamp a terrible shard key? A: All current writes land on the newest range — one hot shard; fix with hash-prefix or composite (tenant_id, ts) keys.
Pitfalls
  • Treating replicas as free consistency: every follower read is a potentially stale read — decide per-endpoint whether that's acceptable.
  • LWW conflict resolution silently discards concurrent writes; with skewed clocks it can discard the newer one.
  • Auto-failover without fencing = split brain; the old leader must be provably dead or fenced before promotion.
  • Low-cardinality shard key (country, status): you can never have more shards than distinct values, and "US" is one giant shard.
  • Forgetting scatter-gather cost: a local secondary index query at 100 shards has the p99 of the worst shard — cross-shard queries get slower as you scale out.
  • Sizing Kafka partition counts for today's traffic: repartitioning breaks key ordering; over-provision partitions up front.
Production best practice
  • Alert on replication lag as a first-class SLO; auto-eject replicas beyond threshold from the read pool.
  • Run failover drills (game days) — an untested failover path is a future outage; measure real RTO/RPO.
  • Emit per-partition traffic metrics and a top-K hot-key sampler from day one; hot partitions are invisible in cluster-level averages.
  • Choose the shard key before the schema ossifies — resharding a live system is a quarter-long project (see Vitess/Notion migrations).
References
Part III · Data

12. CAP, PACELC & Consistency Models

CAP is the most-cited and most-misquoted theorem in interviews. The staff-level move is to state it precisely, immediately upgrade to PACELC (because latency, not partitions, drives daily design), place real systems on the consistency spectrum, and then — the part that actually wins the loop — choose a consistency level per feature, not per system, and defend it.

CAP, stated precisely

  • The theorem: when a network partition (P) occurs, a distributed system must choose between consistency (every read sees the latest write — linearizability) and availability (every request to a live node gets a non-error response). You cannot have both during the partition.
  • P is not a choice. Networks partition — switch failures, AZ isolation, GC pauses that look like partitions. So "CA distributed system" is not a real option; a single-node Postgres is "CA" only in the trivial sense that it isn't distributed.
  • CP means: during a partition, the minority side refuses reads/writes (errors or blocks) to avoid serving stale/conflicting data. AP means: both sides keep serving and you reconcile divergence later.
  • Common misconceptions to preempt: (1) CAP applies only during partitions, not in normal operation; (2) C and A are binary in the theorem but graded in real systems — most systems are "mostly-C" or "tunably-A"; (3) CAP's C is linearizability, not ACID's C; (4) systems aren't globally CP or AP — individual operations can differ (DynamoDB offers both read modes on the same table).
Choice under partitionSystemsBehavior when partitioned
CPetcd, ZooKeeper, Consul (Raft/ZAB); Spanner-style DBs (CockroachDB); HBase; DynamoDB strong readsMinority partition rejects writes (no quorum) — correctness over uptime; used for configs, locks, leader election, money
APCassandra, Riak, Dynamo lineage; DNS; CouchDB; DynamoDB eventual readsEvery reachable replica answers; divergent writes reconciled via LWW/vector clocks/CRDTs — uptime over freshness

PACELC — the more useful framing

Partitions are rare; latency is every request. PACELC (Abadi): if Partition, choose A or C; Else, choose Latency or Consistency. The "else" clause is the daily trade-off: synchronously coordinating replicas on every write buys consistency at the price of latency — the reason you'd run Cassandra at ONE even on a healthy network.

SystemPACELCReading
DynamoDB (default), Cassandra, RiakPA/ELAvailable under partition; low latency preferred normally
MongoDB (majority writes)PC/EC-ishPrimary steps down without majority; consistency favored
Spanner, CockroachDBPC/ECConsistent always; pays coordination (and TrueTime wait) latency
etcd / ZooKeeperPC/ECQuorum on every decision; that's the whole point
MySQL/Postgres async replicasPC/ELFailover pauses writes (C under P), but async replication trades freshness for latency normally
Real-world analogy

CAP is the disaster plan; PACELC is the commute. CAP asks "when the bridge is out, do you stop deliveries (CP) or deliver possibly-outdated catalogs from the local warehouse (AP)?" PACELC adds the everyday question: even with the bridge fine, do you drive downtown to check head office before every delivery (consistency) or trust your local list and go fast (latency)? You make the second choice a thousand times a day.

The consistency spectrum

STRONGER — more coordination, higher latency, lower availability Linearizability one bank teller: every op sees the single latest state, real-time order Sequential everyone sees the same movie, maybe delayed — one agreed order, not real-time Causal replies never appear before the message they answer; unrelated events may reorder Read-your-writes you always see your own edits; others may lag (session guarantee) Monotonic reads time never runs backwards: once you've seen v5 you never see v4 Eventual gossip reaching the village: stop writing and all replicas converge... eventually WEAKER — less coordination, lower latency, higher availability
  • Linearizability is what CAP's "C" means and what Raft/Paxos-backed reads give you; it is the model you pay for with quorum round trips.
  • Session guarantees (read-your-writes, monotonic reads) are the pragmatic middle: cheap to implement, and they fix 90% of user-visible weirdness (ch11 covers mitigations).
  • Tunable consistency: Cassandra lets each query pick ONE / QUORUM / ALL (with W+R > N for overlap); DynamoDB strongly-consistent reads read from the leader and cost 2× the RCUs of eventually-consistent reads and can't be served from a global table's remote regions — consistency is literally a line item on the bill.

Choosing consistency per feature

Never answer "what consistency does your system need?" with one word — decompose by feature:

  • Money movement / ledger / inventory decrement: strong (linearizable or single-leader serialized). A stale read here is a double-spend.
  • Social likes, view counters, analytics: eventual. Nobody sues over a like-count that's 3 seconds behind; coordination would cost more than the data is worth.
  • Shopping cart / user session / drafts: causal or session consistency — a user must see their own actions in order; other users don't need to.
  • Config, feature flags, leader election: strong (that's why etcd/ZooKeeper exist).
Wrong stale read costs money or safety? yes Strong / linearizable (ledger, inventory) no Must users see their own writes instantly? yes Session / RYW (profile, cart) no Does order between related events matter? yes Causal (threads, comments) no Eventual (likes, counters, feeds) Then ask: can the datastore express this cheaply? (DynamoDB ConsistentRead, Cassandra QUORUM, read-from-leader)
// Session consistency ("sticky after write"): route a user's reads to the
// leader for a window after their own write; otherwise use cheap replicas.
type ReadPreference = 'leader' | 'replica';

interface StickyStore {
  // e.g. Redis with TTL, or a signed lastWriteAt claim inside the session cookie
  // (cookie survives multi-node API tiers with no shared state).
  getLastWriteAt(userId: string): Promise<number | null>;
  setLastWriteAt(userId: string, epochMs: number): Promise<void>;
}

export class SessionConsistentRouter {
  constructor(
    private readonly sticky: StickyStore,
    private readonly leader: DbClient,
    private readonly replicas: DbClient[],
    private readonly stickyWindowMs = 5_000,  // > p99 replication lag, with margin
  ) {}

  /** Call after every successful write for this user. */
  async recordWrite(userId: string): Promise<void> {
    await this.sticky.setLastWriteAt(userId, Date.now());
  }

  private async prefer(userId: string): Promise<ReadPreference> {
    const last = await this.sticky.getLastWriteAt(userId);
    const recentlyWrote = last !== null && Date.now() - last < this.stickyWindowMs;
    return recentlyWrote ? 'leader' : 'replica';
  }

  /** Reads see the user's own writes; everyone else enjoys replica latency. */
  async read<T>(userId: string, q: Query<T>): Promise<T> {
    const pref = await this.prefer(userId);
    if (pref === 'leader') return this.leader.query(q);
    const replica = this.replicas[Math.floor(Math.random() * this.replicas.length)];
    return replica.query(q);
    // Hardening: fall back to leader if the chosen replica reports lag > threshold.
  }
}

SQL vs NoSQL — an access-pattern decision, not a religion

The question is never "SQL or NoSQL?"; it's four questions: (1) do you need multi-row ACID transactions and joins? (2) do you know your access patterns up front? (3) what scale of writes/data? (4) how flexible must ad-hoc querying be? Relational wins on 1 and 4; key-value/wide-column wins on 2 and 3.

StoreData modelConsistencyScaling modelQuery flexibilityTypical use
Aurora / RDSRelationalStrong (single-writer ACID)Vertical writes + read replicasFull SQL, joins, ad-hocLedgers, orders, anything transactional/relational
DynamoDBKey-value / wide-columnEventual default; strong reads & txns opt-inHorizontal, managed, effectively unboundedKey + GSI patterns only — model queries up frontSessions, carts, high-scale OLTP with known patterns
MongoDBDocumentTunable (majority read/write concerns)Horizontal via shardingRich document queries, secondary indexesFlexible/evolving schemas, content, catalogs
CassandraWide-columnTunable per query (ONE→QUORUM→ALL)Leaderless, linear write scale, multi-DCPartition-key-driven; no joins, no ad-hocWrite-heavy time-series, feeds, always-on multi-region
RedisIn-memory structuresWeak (async replication)Cluster mode (16k hash slots)Data-structure ops, LuaCache, rate limiting, leaderboards, ephemeral state
OpenSearchInverted index / documentsEventual (near-real-time refresh)Horizontal via index shardsFull-text, aggregations, fuzzySearch, log analytics — as a projection, never source of truth

Polyglot persistence

Real systems map each feature to the store whose model fits, connected by CDC/events (ch14) — one logical system, many stores, each a projection of the source of truth:

E-commerce platform Orders & payments svc Aurora (ACID, joins) Cart & session svc DynamoDB (scale, KV) Product search OpenSearch (full-text) Pricing cache, rate limits Redis (in-memory) CDC / events keep projections in sync (ch14) — Aurora stays the source of truth
AWS mapping
  • DynamoDB — per-request consistency dial: ConsistentRead: true (2× RCU, leader-only, not on GSIs), plus transactions when you need multi-item atomicity. Default eventual reads are the PA/EL posture.
  • Aurora — strong on the writer; replica reads are eventually consistent (~10–20 ms). Aurora Global Database = cross-region async: design region-local reads as stale-tolerant.
  • ElastiCache / MemoryDB — ElastiCache Redis can lose acked writes on failover (async replication); MemoryDB adds a durable multi-AZ transaction log when the cache must not forget.
  • S3 — strong read-after-write consistency since Dec 2020; stop citing the old eventual-consistency caveat in interviews.
  • Rule: Aurora/RDS for the transactional core; DynamoDB when access patterns are known and scale is the constraint; OpenSearch/Redis strictly as derived projections.
What interviewers probe
  • Q: Is DynamoDB AP or CP? A: Per-operation, not per-system: default eventually-consistent reads are the AP/EL posture; ConsistentRead and transactions give CP behavior at 2× cost and reduced availability (leader-only, no GSI, not cross-region on global tables). Saying "it's both, per request" is the staff answer.
  • Q: When would you accept eventual consistency in a payment system? A: Never on the ledger or balance checks — that's double-spend territory. Fine on everything derived: notifications, analytics dashboards, search projections, transaction-history feeds — stale by seconds, reconciled by CDC, harmless.
  • Q: Why is a "CA" system not a thing? A: Choosing CA means assuming partitions never happen; in a distributed system that's not a design, it's a wish. Single-node systems dodge P by not being distributed — and forfeit availability when that node dies.
  • Q: What does linearizable actually mean? A: Every operation appears to take effect atomically at some point between its start and end, consistent with real time — once any client sees a write, all later reads see it. Strictly stronger than "reads hit a quorum".
  • Q: How do you give users read-your-writes on top of async replicas? A: Sticky-after-write routing (leader reads for N s after the user's write, N > p99 lag) or LSN-gated replica reads; state that it's a session guarantee, not global consistency.
  • Q: SQL or NoSQL for service X? A: Interrogate access patterns first: multi-entity transactions/joins/ad-hoc queries → relational; known key-based patterns at high scale → DynamoDB/Cassandra; then name the consistency each feature needs. Choosing the store before the access patterns is the junior tell.
Pitfalls
  • Quoting CAP as "pick 2 of 3" — P isn't optional, and C/A only trade off during partitions. Interviewers use this to smoke-test precision.
  • Classifying whole systems: "MongoDB is CP" ignores read preferences and write concerns; consistency is a per-operation configuration in most modern stores.
  • Choosing eventual consistency, then writing read-modify-write logic on the eventually-consistent read — a check against a stale balance is a race with an invoice attached.
  • Paying for strong consistency everywhere "to be safe": 2× DynamoDB cost and quorum latency on features (feeds, counters) where staleness is invisible.
  • Polyglot sprawl without an owner: every extra store is an extra failure mode, backup policy, and sync pipeline — add stores when an access pattern demands it, not per team fashion.
  • Letting a search index or cache drift into "source of truth" status — projections must be rebuildable from the system of record.
Production best practice
  • Write consistency requirements per endpoint into the design doc ("GET /balance: linearizable; GET /history: ≤5 s stale") — it forces the conversation before the outage does.
  • Make staleness observable: version/LSN stamps on responses, replica-lag SLOs, and alerts when projections fall behind the source of truth.
  • Default to session consistency for user-facing reads (cheap, fixes most complaints); escalate to linearizable only where the flowchart says money/safety.
  • Chaos-test partitions (Jepsen-style thinking): verify your "CP" store actually rejects minority writes and your "AP" reconciliation doesn't drop data.
References
Part IV · Movement of Data

13. Message Queues & Asynchronous Processing

Almost every system design answer at scale contains the sentence "put a queue in front of it." This chapter is the why and the how: queue vs stream, delivery semantics (and why "exactly-once" is a marketing term), ordering, backpressure, DLQs, and the concrete AWS/Kafka menu. Interviewers use this topic to separate people who have shipped async systems from people who have read about them.

Why async at all

Synchronous request/response couples your availability, latency, and capacity to every downstream dependency. A queue breaks that coupling.

  • Decouple availability: the producer succeeds even when the consumer is down, deploying, or slow. Payment API accepts the order; the email service being on fire is not the customer's problem.
  • Absorb bursts: traffic is spiky (flash sale: 50k writes/s for 3 minutes), workers are steady (2k/s). The queue is the buffer; depth grows, then drains. You provision workers for the average, not the peak.
  • Smooth / shape load: protect a fragile downstream (legacy core-banking API rate-limited to 100 TPS) by letting the queue meter delivery.
  • Retry as a first-class citizen: failures become redeliveries instead of user-facing 500s.
  • The cost: eventual consistency, duplicate deliveries, no ordering guarantees by default, and an ops surface (lag monitoring, DLQ triage) you now own.
Real-world analogy

A busy restaurant doesn't have waiters shout orders directly at the chef and stand there waiting (synchronous RPC — the waiter is blocked, and if the chef drops a pan, the order is lost). They clip an order ticket to the rail. The chef pulls tickets at their own pace, the rail absorbs the 7pm rush, a dropped dish means re-cooking the ticket — not re-asking the customer — and a ticket that keeps failing ("we're out of salmon") gets pulled aside for the manager (the DLQ).

Queue vs stream: the key mental model

This distinction drives every technology choice in this chapter. Get it wrong and you pick the wrong tool for years.

  • Queue (SQS, RabbitMQ): a message is a task. Competing consumers each grab different messages; a consumed+acked message is gone. Scaling out = add workers, throughput rises linearly. Natural fit: jobs, work distribution, decoupling one producer from one logical consumer.
  • Stream (Kafka, Kinesis): a message is a fact in a replayable log. Consumers track their own offset; the data stays for the retention period (hours to forever). Multiple independent consumer groups each read everything. Natural fit: event distribution, fan-out to many teams, replay, analytics, CDC.
  • Litmus test: "Could a second team want to consume the same data next quarter?" or "Would I ever want to re-process history?" → stream. "This is a job to be done once by whoever's free" → queue.
QUEUE — competing consumers: each message delivered to ONE worker, then deleted Producer Queue m5 | m4 | m3 Worker A Worker B m1 m2 STREAM — replayable log: every consumer group reads ALL messages at its own offset Producer Log (partition) 0 | 1 | 2 | 3 | 4 | 5 ... retained Billing @ offset 5 Analytics @ offset 2

Delivery semantics: at-most / at-least / "exactly" once

  • At-most-once: fire and forget; ack before processing. Message may be lost, never duplicated. OK for metrics ticks, presence pings — things where a gap is cheaper than a dup.
  • At-least-once: ack only after processing succeeds. Crash between processing and ack → redelivery → duplicates are guaranteed to happen eventually. This is the default of every serious system (SQS standard, Kafka default, Kinesis).
  • Exactly-once: impossible as a transport guarantee across arbitrary systems (Two Generals). What everyone actually ships is effectively-once = at-least-once delivery + idempotent consumer. Dedup on a stable message/event id, or make the operation naturally idempotent (SET balance = 500 vs ADD 100).
  • Kafka's "exactly-once" caveat: Kafka transactions (idempotent producer + read_committed consumers) give exactly-once within a Kafka-to-Kafka pipeline (e.g., Kafka Streams). The moment your consumer touches an external system — Postgres, Stripe, an email API — you are back to at-least-once and need idempotency there. Saying this unprompted is a staff-level signal.
Real-world analogy

At-least-once is certified mail with a confusing receipt process: if the courier isn't sure you signed, they deliver the letter again. "Exactly-once" is you, the recipient, checking the tracking number against a list of letters you've already opened before acting on it. The postal service never promises to knock exactly once; you make double-knocks harmless.

Ordering & consumer groups

  • Global ordering doesn't scale. One totally-ordered sequence implies one writer/one lane — a serial bottleneck. Every scalable system offers ordering only within a partition (Kafka/Kinesis) or message group (SQS FIFO).
  • Partition by the entity that needs ordering: key = accountId means all events for one account are ordered relative to each other; different accounts interleave freely. That is almost always all the ordering you actually need.
  • Consumer groups: Kafka assigns each partition to exactly one consumer in a group. Max parallelism = partition count — pick partition count for target throughput and future consumer scaling (repartitioning later breaks key→partition mapping). More consumers than partitions → idle consumers.
  • Rebalancing: when a consumer joins/dies, partitions are reassigned. Classic "stop-the-world" (eager) rebalances pause the whole group; cooperative/incremental rebalancing (Kafka ≥2.4) moves only affected partitions. A crash-looping consumer causes rebalance storms — lag spikes with no traffic increase is the tell.
  • Hot partition hazard: keying by merchantId when one merchant is 40% of traffic serializes that merchant onto one partition/consumer. Mitigate with composite keys where per-entity ordering can be relaxed.

Backpressure, DLQs & poison pills

  • Backpressure = telling upstream to slow down instead of drowning. Unbounded queues turn overload into a latency disaster (a 10M-deep queue means hours of staleness) and eventually OOM/em disk-full. Bound everything.
  • Slow-consumer strategies: (1) scale consumers on queue depth/age (SQS depth → auto scaling; Kafka lag → KEDA); (2) shed load — reject or sample low-value messages when depth exceeds a threshold; (3) push back — block or 429 producers; (4) degrade — skip enrichment steps under load.
  • Poison pill: a message that deterministically crashes the consumer (bad schema, edge-case payload). Under at-least-once it redelivers forever, wedging the queue — for FIFO groups it blocks everything behind it.
  • Dead-letter queue (DLQ): after maxReceiveCount attempts (SQS redrive policy, typically 3–5), the broker moves the message to a DLQ. Alert on DLQ depth > 0, keep payloads for forensics, and build a redrive path to replay after the bug fix (SQS has one-click DLQ redrive).

Visibility timeout (SQS) vs offset commit (Kafka)

AspectSQS visibility timeoutKafka offset commit
Ack modelPer-message: receive hides the message for N seconds; explicit delete = ackPer-partition watermark: commit offset K = "everything ≤ K is done"
Consumer crashTimeout expires → message reappears for another workerRebalance → new consumer resumes from last committed offset, replays the gap
Slow processingMust heartbeat via ChangeMessageVisibility or message redelivers while still being processed (classic dup source)No per-message timer, but max.poll.interval.ms exceeded → kicked from group → rebalance
Selective retryNatural — one bad message redelivers aloneAwkward — can't skip a middle message without committing past it (park it in a retry topic)
Failure semanticsAt-least-once per messageAt-least-once per contiguous range; commit-before-process flips it to at-most-once

Retries done right: exponential backoff + full jitter

Naive fixed-interval retries synchronize failing clients into thundering herds that re-kill the recovering dependency. AWS's own analysis shows full jitter (uniform random between 0 and the exponential cap) gives the best contention profile.

interface RetryPolicy {
  maxAttempts: number;   // total tries including the first
  baseDelayMs: number;   // e.g. 100
  maxDelayMs: number;    // cap, e.g. 30_000
}

/** Full-jitter exponential backoff: sleep U(0, min(cap, base * 2^attempt)). */
function backoffDelay(attempt: number, p: RetryPolicy): number {
  const exp = Math.min(p.maxDelayMs, p.baseDelayMs * 2 ** attempt);
  return Math.floor(Math.random() * exp);
}

async function withRetry<T>(
  fn: () => Promise<T>,
  p: RetryPolicy,
  isRetryable: (e: unknown) => boolean = () => true,
): Promise<T> {
  let lastErr: unknown;
  for (let attempt = 0; attempt < p.maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (e) {
      lastErr = e;
      // Never retry non-retryable errors (validation, 4xx): retrying a
      // deterministic failure just burns budget and delays the DLQ.
      if (!isRetryable(e) || attempt === p.maxAttempts - 1) break;
      await new Promise((r) => setTimeout(r, backoffDelay(attempt, p)));
    }
  }
  throw lastErr;
}

A typed idempotent consumer (SQS-style)

This is the shape interviewers want on the whiteboard: envelope with a stable id, dedup store checked before work and recorded atomically with (or after) work, bounded retries, DLQ handoff.

/** Envelope every producer must wrap payloads in. messageId is the dedup key. */
interface Envelope<T> {
  messageId: string;        // UUID minted by the ORIGINAL producer (survives redelivery)
  type: string;             // e.g. "payment.captured"
  occurredAt: string;       // ISO-8601
  payload: T;
}

interface QueueMessage { receiptHandle: string; body: string; approxReceiveCount: number; }

interface QueueClient {
  receive(max: number): Promise<QueueMessage[]>;
  delete(receiptHandle: string): Promise<void>;          // ack
  sendToDlq(body: string, reason: string): Promise<void>;
}

/** Dedup store: DynamoDB with conditional put + TTL, or Redis SETNX + EXPIRE. */
interface ProcessedStore {
  /** Atomically record id; returns false if already present (duplicate). */
  markIfNew(id: string, ttlSeconds: number): Promise<boolean>;
  unmark(id: string): Promise<void>;                     // roll back claim on failure
}

class IdempotentConsumer<T> {
  constructor(
    private queue: QueueClient,
    private processed: ProcessedStore,
    private handler: (e: Envelope<T>) => Promise<void>,
    private opts = { maxReceives: 5, dedupTtlSec: 7 * 24 * 3600 },
  ) {}

  async poll(): Promise<void> {
    for (const msg of await this.queue.receive(10)) {
      let env: Envelope<T>;
      try {
        env = JSON.parse(msg.body) as Envelope<T>;
        if (!env.messageId || !env.type) throw new Error("bad envelope");
      } catch {
        // Poison pill: unparseable — retrying will never help. Straight to DLQ.
        await this.queue.sendToDlq(msg.body, "malformed");
        await this.queue.delete(msg.receiptHandle);
        continue;
      }

      // Idempotency gate: claim the id; a duplicate delivery just gets acked.
      if (!(await this.processed.markIfNew(env.messageId, this.opts.dedupTtlSec))) {
        await this.queue.delete(msg.receiptHandle);
        continue;
      }

      try {
        await withRetry(() => this.handler(env), {
          maxAttempts: 3, baseDelayMs: 200, maxDelayMs: 5_000,
        });
        await this.queue.delete(msg.receiptHandle);   // ack ONLY after success
      } catch (err) {
        await this.processed.unmark(env.messageId);   // release claim for redelivery
        if (msg.approxReceiveCount >= this.opts.maxReceives) {
          await this.queue.sendToDlq(msg.body, String(err));
          await this.queue.delete(msg.receiptHandle);
        }
        // else: do nothing — visibility timeout expires, broker redelivers.
      }
    }
  }
}

Bounded in-memory queue with backpressure

type OverflowPolicy = "block" | "reject" | "drop-oldest";

/** Bounded queue: the producer FEELS the pressure instead of hiding it. */
class BoundedQueue<T> {
  private items: T[] = [];
  private waiters: ((v: T) => void)[] = [];

  constructor(private capacity: number, private policy: OverflowPolicy) {}

  /** Returns false when rejected — caller must shed/retry/429 upstream. */
  async offer(item: T): Promise<boolean> {
    const waiter = this.waiters.shift();
    if (waiter) { waiter(item); return true; }        // hand directly to a blocked consumer
    if (this.items.length >= this.capacity) {
      switch (this.policy) {
        case "reject":      return false;             // shed load explicitly
        case "drop-oldest": this.items.shift(); break; // keep freshest (metrics-style)
        case "block":                                  // simple spin-wait for slot
          while (this.items.length >= this.capacity) await new Promise(r => setTimeout(r, 5));
      }
    }
    this.items.push(item);
    return true;
  }

  async take(): Promise<T> {
    const head = this.items.shift();
    if (head !== undefined) return head;
    return new Promise<T>((resolve) => this.waiters.push(resolve)); // park consumer
  }

  get depth(): number { return this.items.length; }   // export as a metric + alarm
}

Choosing the technology

SystemThroughputOrderingFan-outReplayRetentionPick when
SQS standardEffectively unlimitedBest-effort onlyNo (1 consumer pool)No≤14 days, deleted on ackDefault job queue; decoupling; retries + DLQ with zero ops
SQS FIFO300 msg/s per group hot path; ~70k/s with batching+HT modeStrict per message-groupNoNo≤14 daysNeed ordering + exactly-once-ish dedup (5-min dedup window) without Kafka ops
SNSVery highNo (FIFO topics: per-group)Yes — push to many SQS/Lambda/HTTPNoNone (router, not store)Fan-out one event to N teams' queues (SNS→SQS pattern)
Kinesis Data Streams1MB/s in, 2MB/s out per shard (on-demand scales)Per shard/partition keyYes — multiple apps; EFO for dedicated 2MB/s eachYes24h default, ≤365 daysAWS-native streaming: analytics, CDC fan-in, Lambda consumers
Kafka / MSKMillions msg/s per clusterPer partitionYes — unlimited consumer groupsYesConfigurable, incl. forever/compactedEvent backbone, log compaction, ecosystem (Connect, Streams), multi-team scale
RabbitMQTens of thousands msg/s typicalPer queue (single consumer)Yes — exchanges (topic/fanout/headers) routingNo (classic; streams plugin adds it)Until consumedComplex routing keys, per-message priority, low-latency task queues, on-prem
EventBridgeModerate (soft limits ~10k/s regional)NoYes — content-based rules to 20+ target typesYes (archive + replay)Archive: configurableSaaS/AWS service events, cross-account buses, schema-registry, low volume
Replay / many consumer groups? no yes Route by content to many targets? yes EventBridge no Strict ordering per key? yes SQS FIFO (group = entity id) no SQS standard default job queue if >300/s per group: rethink key AWS-native, low ops preferred? yes Kinesis huge scale / ecosystem / compaction needed Kafka / MSK event backbone
AWS mapping
  • SQS standard: the default answer for "job queue". Unlimited throughput, visibility timeout, redrive-to-DLQ built in, pay-per-request. Pair with Lambda (event source mapping handles polling, batching, partial-batch failures) or ECS workers scaled on ApproximateNumberOfMessagesVisible and ApproximateAgeOfOldestMessage.
  • SQS FIFO: ordering per MessageGroupId + 5-minute content/id dedup window. Throughput ceiling per group is the real design constraint.
  • SNS → SQS fan-out: the canonical "one event, N independent consumers with independent retry" pattern; add filter policies so each queue gets only relevant events.
  • Kinesis: shard-based stream; choose when you want replay + Lambda/Flink consumers without running Kafka. Watch per-shard limits and use enhanced fan-out beyond ~2 consumers.
  • MSK / MSK Serverless: pick over Kinesis when you need Kafka ecosystem (Connect, Streams, Debezium), compacted topics, or >365-day retention. Budget real ops time even "managed".
  • EventBridge: event bus with content-based routing, archive/replay, 3rd-party SaaS partner sources. Great control plane; not a high-throughput data plane.
  • Build custom? Almost never. A Redis list as a queue loses messages on crash (no ack/redelivery) — say this if offered as a "simple" option; Redis Streams with consumer groups is the minimum viable DIY.
Pitfalls
  • Visibility timeout < worst-case processing time: message redelivers while worker A is still on it → duplicate side effects. Set timeout ≥ 6× p99 processing, or heartbeat-extend.
  • Deleting/acking before processing: silently converts at-least-once into at-most-once; crash = lost message. Ack last.
  • No DLQ: one poison pill retries forever, consuming a worker slot and (in FIFO) blocking its whole group.
  • Dedup only via SQS FIFO's 5-minute window: a retry from an upstream job 20 minutes later sails through. Idempotency belongs in the consumer, not the broker.
  • Assuming queue order: SQS standard is best-effort; retries reorder everything. If code depends on order, it's already broken.
  • Kafka: too few partitions capping consumers forever, or keying by a hot entity creating one lagging partition while others idle.
  • Monitoring depth but not age: a steady depth of 100 can hide messages stuck for hours. Alarm on oldest-message age / consumer lag.
Production best practice
  • Wrap every payload in a versioned envelope (messageId, type, occurredAt, schemaVersion) from day one — retrofitting ids is painful.
  • Make every consumer idempotent by default; treat duplicate delivery as a normal event, not an error.
  • Alarm on: DLQ depth > 0, oldest message age, consumer lag per partition, redelivery rate. Autoscale workers on queue age, not just depth.
  • Retries: full-jitter exponential backoff, classify errors (retryable vs terminal), cap attempts, then DLQ with the error attached. Never retry 4xx-class failures.
  • Load-test the drain: after a 30-minute outage, how long to burn down the backlog? If the answer is "never at current worker count", you need burst-scale plans.
  • The next chapter's transactional outbox is how the producing side avoids losing messages — queues only solve delivery, not atomic publish.
What interviewers probe
  • Q: How do you guarantee a message is processed exactly once? A: You can't at the transport layer — I design for at-least-once delivery plus an idempotent consumer: stable message id minted at the producer, dedup check via conditional write (DynamoDB conditional put / INSERT ... ON CONFLICT DO NOTHING) ideally in the same transaction as the side effect. Kafka transactions only cover Kafka-to-Kafka topologies.
  • Q: What happens if the consumer crashes mid-message? A: No ack is sent; SQS's visibility timeout expires and the message reappears, or Kafka rebalances and the new assignee resumes from the last committed offset. Either way it re-processes — which is exactly why idempotency is non-negotiable.
  • Q: How do you keep ordering while scaling consumers? A: Partition by the entity that needs ordering (account id) so order holds per key while keys parallelize; global order is a deliberate non-goal because it serializes the system. In SQS FIFO that's the message group id, in Kafka the partition key — and I watch for hot keys.
  • Q: Queue backing up — what do you do? A: Diagnose first: throughput problem (scale consumers on age, raise batch size) vs poison pill (DLQ it) vs downstream slowness (backpressure/shed, don't just add workers that pile onto the dying dependency).
  • Q: SQS or Kafka? A: Disposable tasks for one consumer pool → SQS (near-zero ops). Replayable facts for multiple independent consumers, CDC, or event backbone → Kafka/Kinesis. I default to the boring managed option and upgrade when fan-out/replay demands appear.
  • Q: Why jitter in retries? A: Synchronized failures retry in lockstep and re-kill the recovering service; full jitter decorrelates clients and empirically minimizes contention (AWS Architecture Blog analysis).
References
Part IV · Movement of Data

14. Event-Driven Architecture

Queues move messages; event-driven architecture decides what those messages mean and how a system of services stays correct around them. The flagship idea here is the transactional outbox — the answer to the single most common distributed-systems interview trap: "how do you update the database AND publish an event atomically?" Add event sourcing, CQRS, and schema evolution and you have the full Part IV vocabulary.

Events vs commands vs queries

Mixing these up is the root cause of "event soup". The grammar matters:

CommandEventQuery
Meaning"Do this" — a request that can be rejected"This happened" — an immutable past fact"Tell me" — no state change
NamingImperative: PlaceOrder, CapturePaymentPast tense: OrderPlaced, PaymentCapturedGetOrder, ListPayments
AudienceExactly one handler (the owner of the entity)Zero-to-many subscribers, unknown to the producerOne responder
CouplingSender knows the receiver and its contractProducer doesn't know or care who listensCaller knows the read model
Can fail?Yes — validation, business rulesNo — you can't reject historyYes (not found, etc.)
  • The tell in a design review: a topic named SendWelcomeEmail is a command wearing an event costume — the producer is secretly orchestrating the consumer. Publish UserRegistered; let the email service decide to react.
  • Events are immutable facts. If a fact was wrong, you emit a compensating fact (OrderCancelled), you don't edit history.

Pub/sub and fan-out

One producer, one topic, N independent consumers — each with its own queue, retries, DLQ, and pace. The producer's blast radius stops at the topic.

Order service producer Topic order.placed OrderPlaced Email service own queue + DLQ Inventory service own queue + DLQ Analytics own queue + DLQ Adding consumer #4 requires zero producer changes

Choreography vs orchestration

Same question as sagas (ch. 8), seen from the event angle: who owns the workflow?

Choreography (events)Orchestration (commands)
ControlDistributed — each service reacts to events and emits its ownCentral orchestrator (Step Functions, Temporal) issues commands
CouplingLoose; producer ignorant of consumersOrchestrator knows every step and contract
VisibilityPoor — the workflow exists only implicitly, across servicesExcellent — one state machine, one place to look
Failure handlingEach service does its own compensations; hard to reason globallyCentral retries, timeouts, compensation logic
Sweet spot2–4 steps, genuinely independent reactions (notify, index, count)≥4 steps, money movement, SLAs, needs audit ("where is order 123 stuck?")
  • Staff-level answer: they compose. Orchestrate the core transactional flow (payment → reserve → ship); choreograph the periphery (emails, analytics, search indexing) off the events the flow emits.

The transactional outbox — flagship pattern

The dual-write problem

Your handler must (a) commit the order to Postgres and (b) publish OrderPlaced to Kafka. These are two systems with no shared transaction. Whatever order you pick, there's a crash window:

  • Commit → crash → publish never happens: order exists, downstream never hears. Inventory never reserved. Silent data loss — the worst kind.
  • Publish → crash → commit never happens: downstream reacts to an order that doesn't exist. Ghost email, ghost reservation.
  • 2PC across DB and broker is the theoretical fix and a practical non-starter (Kafka/SNS aren't XA participants; and see ch. 8 on why 2PC is avoided anyway).
DUAL WRITE — crash between the two writes loses the event Service Postgres Broker 1 COMMIT ok 2 CRASH — publish lost OUTBOX — event row commits atomically with the order; relay publishes later Service Postgres orders + outbox rows ONE transaction Relay poll or CDC Broker 1 2 3 Crash anywhere after step 1: the event row survives in the DB and will be published — at-least-once

The fix: make the event part of the local transaction

  • Write the business row and an outbox row in the same DB transaction — atomicity comes free from the database you already have.
  • A relay gets events out: either a poller (SELECT unsent rows, publish, mark sent — simple, adds ~poll-interval latency) or CDC — Debezium tailing the WAL/binlog and pushing outbox rows to Kafka (lower latency, no polling load, more moving parts).
  • Resulting guarantee: at-least-once publish. The relay can crash after publishing but before marking sent → duplicate publish. Consumers must be idempotent — which they must be anyway (ch. 13).
// -- Outbox schema (Postgres) -------------------------------------------
// CREATE TABLE outbox (
//   id uuid PRIMARY KEY, aggregate_type text, aggregate_id text,
//   event_type text, payload jsonb, created_at timestamptz DEFAULT now(),
//   sent_at timestamptz NULL);

interface OutboxRow {
  id: string;               // event id — consumers dedup on this
  aggregateType: "order";
  aggregateId: string;
  eventType: "OrderPlaced";
  payload: OrderPlacedEvent;
}

interface OrderPlacedEvent {
  orderId: string; customerId: string;
  totalCents: number; currency: "USD" | "EUR" | "INR";
}

/** Business write + event write in ONE transaction. This is the whole trick. */
async function placeOrderWithOutbox(db: Db, cmd: PlaceOrderCommand): Promise<string> {
  return db.transaction(async (tx) => {
    const orderId = crypto.randomUUID();
    await tx.query(
      `INSERT INTO orders (id, customer_id, total_cents, status)
       VALUES ($1, $2, $3, 'PLACED')`,
      [orderId, cmd.customerId, cmd.totalCents],
    );
    const event: OutboxRow = {
      id: crypto.randomUUID(), aggregateType: "order", aggregateId: orderId,
      eventType: "OrderPlaced",
      payload: { orderId, customerId: cmd.customerId,
                 totalCents: cmd.totalCents, currency: cmd.currency },
    };
    await tx.query(
      `INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload)
       VALUES ($1, $2, $3, $4, $5)`,
      [event.id, event.aggregateType, event.aggregateId, event.eventType,
       JSON.stringify(event.payload)],
    );
    return orderId; // both rows commit or neither does
  });
}

/** Relay: poll unsent rows, publish, mark sent. Run as a single instance or
 *  use FOR UPDATE SKIP LOCKED so multiple relays don't double-claim rows. */
async function relayLoop(db: Db, bus: EventBus): Promise<never> {
  for (;;) {
    const rows = await db.query<OutboxRow>(
      `SELECT * FROM outbox WHERE sent_at IS NULL
       ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED`,
    );
    for (const row of rows) {
      // Publish keyed by aggregateId so per-order ordering holds downstream.
      await bus.publish(row.eventType, row.aggregateId, row.payload);
      await db.query(`UPDATE outbox SET sent_at = now() WHERE id = $1`, [row.id]);
      // Crash between publish and UPDATE => duplicate publish on restart.
      // That's fine: delivery is at-least-once; consumers dedup on row.id.
    }
    if (rows.length === 0) await sleep(200);
  }
}
Real-world analogy

The outbox is literally an office outbox tray. You don't run to the post office mid-meeting hoping you don't get hit by a bus between signing the contract and mailing the copy — you sign the contract and drop the envelope in the tray as one motion at your desk. The mailroom clerk (relay) empties the tray on a schedule. If the clerk is sick today, the envelopes wait safely; nothing is ever half-done.

Idempotent consumers close the loop

  • Every event carries the outbox row's id. Consumers keep a processed_events(event_id PRIMARY KEY) table and insert into it in the same transaction as their side effect — duplicate arrives, insert conflicts, transaction no-ops. This is effectively-once end to end, built from two local transactions and at-least-once glue.

Event sourcing

Instead of storing current state and emitting events as a byproduct, make the event log the source of truth: state = fold(apply, initial, events). Current state is a cache you can throw away and rebuild.

  • Wins: perfect audit trail (regulators love it — this is why ledgers are the canonical fintech example), temporal queries ("what was the balance on March 3?"), rebuildable projections, natural fit with CQRS.
  • Costs: schema evolution forever (you must be able to read year-old events), replay time (mitigated by snapshots: persist state every N events, replay only the tail), eventual consistency of read models, unfamiliar mental model for most teams.
  • When NOT to event-source: CRUD apps with no audit need, teams new to the pattern, anywhere you'd immediately ask "can I just update the row?" It's a per-aggregate decision — event-source the ledger, CRUD the user-profile table.
/** Discriminated union of everything that can happen to an account. */
type AccountEvent =
  | { type: "AccountOpened";  id: string; ownerId: string; at: string }
  | { type: "MoneyDeposited"; id: string; amountCents: number; at: string }
  | { type: "MoneyWithdrawn"; id: string; amountCents: number; at: string }
  | { type: "AccountFrozen";  id: string; reason: string; at: string };

interface AccountState {
  ownerId: string; balanceCents: number; frozen: boolean; version: number;
}

/** Pure transition function: no I/O, trivially testable. */
function apply(state: AccountState, event: AccountEvent): AccountState {
  switch (event.type) {
    case "AccountOpened":
      return { ownerId: event.ownerId, balanceCents: 0, frozen: false, version: 1 };
    case "MoneyDeposited":
      return { ...state, balanceCents: state.balanceCents + event.amountCents,
               version: state.version + 1 };
    case "MoneyWithdrawn":
      return { ...state, balanceCents: state.balanceCents - event.amountCents,
               version: state.version + 1 };
    case "AccountFrozen":
      return { ...state, frozen: true, version: state.version + 1 };
  }
}

/** State is a fold over history. Snapshot = persisted (state, version);
 *  rehydrate = load snapshot, replay only events with version > snapshot. */
function rehydrate(events: AccountEvent[], snapshot?: AccountState): AccountState {
  return events.reduce(apply, snapshot ?? ({} as AccountState));
}

// Command handling = validate against current state, append new fact:
function withdraw(state: AccountState, amountCents: number): AccountEvent {
  if (state.frozen) throw new Error("account frozen");
  if (state.balanceCents < amountCents) throw new Error("insufficient funds");
  return { type: "MoneyWithdrawn", id: crypto.randomUUID(),
           amountCents, at: new Date().toISOString() };
  // Append with optimistic concurrency: expectedVersion = state.version,
  // so two concurrent withdrawals can't both pass the balance check.
}

CQRS: separate write and read models

  • Write side: normalized, validated, optimized for commands and invariants. Read side: denormalized projections shaped exactly like each screen/query — a DynamoDB table per access pattern, an Elasticsearch index for search, a Redis leaderboard.
  • Projections are built by consuming the event stream. They are eventually consistent — typically tens of milliseconds behind, occasionally seconds. Design the UX for it (optimistic UI, read-your-writes via routing the author's read to the write side or a version-check).
  • Killer feature: rebuild a projection from scratch by replaying events into a fresh table, flip a pointer, delete the old one. Bugs in read models become non-events.
  • CQRS doesn't require event sourcing (CDC from a normal DB works), but they pair naturally.
Client cmd + query Write model validate, event store Event stream Projector fold events Read model(s) per query shape commands queries (eventually consistent)

Event schema evolution & event styles

  • Additive-only is the golden rule: add optional fields, never rename/remove/retype. Consumers must tolerate unknown fields.
  • Version the schema (schemaVersion in the envelope). For breaking changes: upcasters — pure functions v1→v2→v3 applied at read time so handlers only ever see the latest shape. Essential for event sourcing, where 2019 events must still parse in 2026.
  • Schema registry (Confluent, AWS Glue, EventBridge Schema Registry) enforces compatibility at publish time — a broken producer fails CI, not production consumers at 3am.
  • Notification events vs event-carried state transfer (ECST): a thin OrderPlaced {orderId} forces every consumer to call back to the order service (re-coupling, thundering reads); a fat event carrying the full order snapshot lets consumers act autonomously at the cost of size and staleness. Default: carry what most consumers need, keep a fetch path for the rest.
AWS mapping
  • EventBridge: the default event bus — content-based rules route to Lambda/SQS/Step Functions/API destinations; archive + replay gives you projection rebuilds without Kafka; schema registry included. Pick for service-to-service and SaaS events at moderate volume.
  • SNS + SQS fan-out: higher throughput, simpler, cheaper than EventBridge for pure fan-out; each consumer gets its own SQS queue (isolation, retries, DLQ) with filter policies.
  • DynamoDB Streams: CDC built into the table — a poor-man's outbox: write the item, the stream is the atomic event feed, Lambda/EventBridge Pipes forward it. 24h retention, so consumers must keep up.
  • Kinesis / MSK: the event backbone when volume or retention outgrows EventBridge; MSK + Debezium is the standard CDC-outbox stack.
  • Step Functions: the orchestrator when you choose orchestration over choreography.
  • Build custom? The outbox relay is one of the few things worth hand-rolling (50 lines, above); an event-store product (EventStoreDB) or DynamoDB-as-event-store only if you truly commit to event sourcing.
Pitfalls
  • Dual write "just this once": every DB-commit-then-publish without an outbox is a slow leak of lost events that surfaces as un-reconcilable data months later.
  • Event soup / implicit coupling: 40 services reacting to each other's events with no map — the workflow exists nowhere and everywhere. Nobody can answer "what happens when an order is placed?" Fix: document flows, or orchestrate the core path.
  • Events as request-response: publish PriceRequested, wait for PriceCalculated, correlate, timeout... you've rebuilt RPC with worse latency and debuggability. If you need an answer now, make a call.
  • Giant events: serializing the whole aggregate (2MB of order + customer + history) into every event — broker limits, PII sprawl, consumers coupling to fields they shouldn't know.
  • Assuming in-order delivery: OrderShipped can arrive before OrderPlaced (different partitions, retries). Handle with version/sequence numbers per aggregate: buffer or ignore stale, upsert idempotently.
  • Event-sourcing everything because it's elegant — then drowning in upcasters and replay time for tables that just needed CRUD.
Production best practice
  • Standard envelope on every event: eventId, type, schemaVersion, aggregateId, sequence, occurredAt, correlationId (trace the whole flow), causationId (which event caused this one).
  • Outbox by default for any service that both persists and publishes. Poller first; graduate to Debezium CDC when latency or DB load demands.
  • Keep a registry of who consumes what (schema registry + consumer contracts) — it's the antidote to event soup.
  • Monitor projection lag as a first-class SLO; expose it so product can reason about staleness.
  • Prune the outbox (delete/archive sent rows) — an unbounded outbox table is a slow-motion incident.
What interviewers probe
  • Q: How do you atomically update the DB and publish an event? A: You can't span the two systems, so I don't try — transactional outbox: event row committed in the same local transaction as the business row, then a relay (poller with FOR UPDATE SKIP LOCKED, or Debezium CDC) publishes at-least-once, and consumers dedup on event id.
  • Q: How do you rebuild a read model? A: Replay the event stream (or archive/CDC history) into a fresh projection table with a new consumer starting at offset 0, verify counts/checksums against the old one, flip the read pointer, drop the old. Zero downtime; this is the payoff of treating projections as disposable.
  • Q: How do you handle out-of-order events? A: First minimize: partition by aggregate id so per-aggregate order holds. Then defend: sequence number per aggregate in the envelope — consumers ignore stale versions or buffer gaps; design handlers as idempotent upserts keyed on (aggregateId, sequence).
  • Q: Choreography or orchestration? A: Orchestrate the money path (visibility, compensation, audit), choreograph the side effects. Pure choreography beyond ~4 steps is where "nobody knows the workflow" incidents come from.
  • Q: Event sourcing — when would you actually use it? A: Ledger-like aggregates where audit and temporal queries are requirements (payments, balances), not app-wide. I'd pair with snapshots every N events and a schema-evolution plan (upcasters) from day one.
  • Q: Thin events or fat events? A: ECST with the fields most consumers need — avoids callback storms — plus a versioned fetch API for outliers; never the entire aggregate.
References
Part IV · Movement of Data

15. Webhooks, WebSockets, SSE & Polling

Request/response only covers "client asks". This chapter covers "server tells": four ways to push data to clients (short polling, long polling, SSE, WebSockets) plus webhooks for server-to-server. The flagship is designing a production webhook system — the Stripe-style provider side — because "how does Stripe deliver webhooks reliably" is a top-five interview question for fintech candidates.

The five delivery mechanisms

MechanismLatencyOverheadDirectionProxy/LB friendlinessUse when
Short pollingPoll interval (seconds)High — mostly empty responses, full HTTP each timeClient pullsPerfect — plain HTTPLow-frequency checks, dumb-simple infra, batch job status
Long pollingNear-real-timeMedium — held connections, reconnect churnClient pulls, server delaysGood; watch idle timeouts (ALB default 60s)Real-time-ish where WS/SSE is blocked; legacy clients
SSEReal-timeLow — one long-lived HTTP responseServer → client onlyGood — plain HTTP/1.1+; buffering proxies can break itOne-way feeds: notifications, prices, LLM token streams, live dashboards
WebSocketReal-timeLowest per message (~2–14B frame overhead)Full duplexNeeds Upgrade support end-to-end; stateful — hardest to operateBidirectional + frequent: chat, games, collab editing, trading
WebhookReal-time-ish (retry delays)Low — one POST per eventServer → serverN/A — needs a public consumer endpointCross-organization event notification: payments, CI, CRM syncs
  • Rule of thumb: server→browser one-way → SSE; bidirectional → WebSocket; server→another company's backend → webhook; nothing else justified → polling (never be embarrassed to propose polling for a 30s-freshness requirement — it's the cheapest thing that works).
  • Long polling mechanics: client sends GET, server holds the request until data exists or a ~25s timeout, responds, client immediately re-polls. It's polling where the wait happens server-side — latency of push, statelessness of HTTP. Keep the hold below LB idle timeout.
Real-world analogy

Waiting on a delayed flight: short polling is walking to the gate desk every five minutes ("any news?"); long polling is standing at the desk until the agent has news, then walking off and coming right back; SSE is the announcement PA — you just listen, you can't talk back; a WebSocket is having the agent's number in an open phone call — both sides can speak anytime; a webhook is giving the airline your number so they call your assistant's office when the gate changes — and if the office line is busy, they retry later.

Flagship: a production webhook system (provider side)

You're Stripe/GitHub/OrbitX: customers register URLs; you must deliver events to thousands of endpoints you don't control — some slow, some down, some hostile. The design pillars:

1. Registration & verification

  • Customer registers an HTTPS URL + chosen event types. Verify ownership before sending real data: send a challenge (random token the endpoint must echo back) or require a verification handshake — prevents using your webhook system to spray traffic at arbitrary URLs (SSRF-by-proxy).
  • Issue a per-endpoint signing secret at registration; support secret rotation with an overlap window (sign with both, consumer accepts either).
  • Guard your egress: resolve and reject private/link-local IPs (169.254.x, 10.x, metadata endpoints), and re-check on redirect — classic SSRF hole.

2. Signing: HMAC-SHA256 + timestamp

  • Consumers must be able to prove the POST came from you and isn't a replay. Sign timestamp + "." + body with the shared secret; consumer recomputes, compares in constant time, and rejects stale timestamps (>5 min) to kill replay attacks. This is exactly Stripe's Stripe-Signature scheme.
import { createHmac, timingSafeEqual } from "node:crypto";

interface WebhookHeaders { "x-webhook-timestamp": string; "x-webhook-signature": string; }

/** PROVIDER: sign timestamp.body so neither can be tampered independently. */
function signWebhook(secret: string, rawBody: string, nowMs = Date.now()): WebhookHeaders {
  const ts = Math.floor(nowMs / 1000).toString();
  const sig = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  return { "x-webhook-timestamp": ts, "x-webhook-signature": `v1=${sig}` };
}

/** CONSUMER: verify over the RAW body (before any JSON parsing/re-serialization —
 *  re-stringified JSON reorders keys and breaks the HMAC). */
function verifyWebhook(
  secret: string, rawBody: string, headers: WebhookHeaders,
  toleranceSec = 300,
): boolean {
  const ts = parseInt(headers["x-webhook-timestamp"], 10);
  if (!Number.isFinite(ts)) return false;
  // Replay defense: reject anything outside the tolerance window.
  if (Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false;

  const expected = createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`).digest();
  const given = Buffer.from(
    headers["x-webhook-signature"].replace(/^v1=/, ""), "hex");
  // timingSafeEqual: a byte-by-byte === leaks match length via timing.
  return given.length === expected.length && timingSafeEqual(given, expected);
}

3. Delivery pipeline: fan-out via queue, never inline

  • Never POST to customer endpoints from your request path — a slow endpoint would block your API. Events flow: business event → outbox (ch. 14) → per-delivery jobs on a queue → dispatcher worker pool → customer endpoints.
  • One delivery job per (event, endpoint) so one dead endpoint retries independently without delaying anyone else. Isolate slow endpoints (per-endpoint concurrency caps or sharded queues) so a customer with a 30s handler can't consume your worker pool.
  • Success = any 2xx within a short timeout (5–10s). 3xx/4xx/5xx/timeout = failure → schedule retry.
API service payment captured Outbox same DB tx Delivery queue job per endpoint Dispatcher 1 Dispatcher 2 Dispatcher N Customer endpoints relay signed POST DLQ + customer dashboard retries exhausted

4. Retry policy & giving up

AttemptDelay after failureCumulative
1 → 21 min (+ jitter)~1 min
2 → 35 min~6 min
3 → 430 min~36 min
4 → 52 h~2.6 h
5 → 61 d (then daily)up to ~3 d total
  • Exponential backoff + jitter across attempts spanning days (Stripe retries for up to 3 days). After exhaustion: dead-letter the delivery, surface it in a customer-facing dashboard with payloads, response codes, and a manual "resend" button, alert the customer, and auto-disable endpoints failing for N days (with notice).
  • Ordering is explicitly NOT guaranteed: retries and parallel dispatchers reorder events (a retried invoice.created can land after invoice.paid). Contract: each event carries an id and the object's latest state/sequence; consumers reconcile — treat webhooks as "something changed, here's a snapshot", and fetch-on-receipt when in doubt.

5. Consumer-side contract (put this in your docs)

  • Verify the signature over the raw body; reject stale timestamps.
  • Return 200 immediately, process async — enqueue the event and ack in <1s. Slow handlers cause provider timeouts, which cause retries, which cause duplicates.
  • Idempotency by event id: at-least-once delivery means duplicates will arrive (ch. 13's processed-ids table).
  • Don't trust event payload for money decisions — verify by fetching the object from the provider's API (payload could be stale even when authentic).

WebSockets at scale: connection state is the problem

HTTP servers are stateless and interchangeable; WebSocket servers are neither. User 42's socket lives in the memory of exactly one node — so "send a message to user 42" becomes a routing problem.

  • The core pattern: a connection registry (Redis: userId → set of (nodeId, socketId)) plus pub/sub between nodes. Any node that needs to reach user 42 publishes to a channel; the node actually holding the socket delivers. Alternative: every node subscribes to a broadcast channel and filters locally (simpler, fine until fan-in cost bites).
  • Sticky vs registry: LB stickiness (by cookie/IP) only ensures reconnects land on the same node — it does not solve server-initiated sends to a user connected "somewhere". You need the registry/pub-sub regardless.
  • Heartbeats: ping/pong every 15–30s. Detects half-open connections (mobile network drops without FIN) and keeps NAT/LB idle timeouts from severing quiet sockets.
  • Reconnect protocol: client reconnects with exponential backoff + jitter (a region blip must not become a reconnect stampede), then resyncs — either resume from a last-seen sequence number or refetch state via REST. Design the resync path first; it's the hard part.
  • Backpressure on slow clients: a phone on 2G can't drain your sends; the per-socket buffer grows and OOMs the node. Watch bufferedAmount/socket high-water marks: coalesce updates (send latest state, not every tick), drop intermediate frames, or disconnect with a resume token.
  • Scale math: a tuned node handles ~100k–1M mostly-idle connections (memory-bound: ~10–30KB each with TLS). 10M connections ≈ 20–100 nodes — the hard part isn't sockets, it's deploys (every deploy disconnects everyone → drain gradually + jittered reconnect), registry cleanup on node death (TTL entries), and fan-out amplification (1 celebrity message → 1M deliveries).
Chat service send to user 42 Redis registry + pub/sub WS node A users 1–10k WS node B user 42 here WS node C 1 lookup 42 → node B; publish 2 channel: node-B register on connect TTL heartbeat 3 socket
interface PubSub {
  publish(channel: string, msg: string): Promise<void>;
  subscribe(channel: string, onMsg: (msg: string) => void): Promise<void>;
}
interface Registry {                       // Redis hash/sets with TTL refresh
  register(userId: string, nodeId: string, socketId: string): Promise<void>;
  unregister(userId: string, socketId: string): Promise<void>;
  nodesFor(userId: string): Promise<string[]>;
}

/** Runs on EVERY WS node: local sockets + cross-node routing via pub/sub. */
class ConnectionManager {
  private local = new Map<string, Set<WebSocketLike>>(); // userId → sockets on THIS node

  constructor(private nodeId: string, private registry: Registry, private bus: PubSub) {}

  async start(): Promise<void> {
    // Each node listens on its own channel; peers publish here to reach our sockets.
    await this.bus.subscribe(`node:${this.nodeId}`, (raw) => {
      const { userId, payload } = JSON.parse(raw) as { userId: string; payload: string };
      this.deliverLocal(userId, payload);
    });
  }

  async onConnect(userId: string, ws: WebSocketLike): Promise<void> {
    (this.local.get(userId) ?? this.local.set(userId, new Set()).get(userId)!).add(ws);
    await this.registry.register(userId, this.nodeId, ws.id); // with TTL; heartbeat refreshes
    ws.onClose(() => this.onDisconnect(userId, ws));
  }

  private async onDisconnect(userId: string, ws: WebSocketLike): Promise<void> {
    this.local.get(userId)?.delete(ws);
    await this.registry.unregister(userId, ws.id); // TTL also reaps entries if node dies
  }

  /** Send to a user wherever they're connected — possibly multiple devices/nodes. */
  async sendToUser(userId: string, payload: string): Promise<void> {
    this.deliverLocal(userId, payload);                     // fast path: same node
    for (const nodeId of await this.registry.nodesFor(userId)) {
      if (nodeId !== this.nodeId) {
        await this.bus.publish(`node:${nodeId}`, JSON.stringify({ userId, payload }));
      }
    }
  }

  private deliverLocal(userId: string, payload: string): void {
    for (const ws of this.local.get(userId) ?? []) {
      // Backpressure: don't buffer unbounded for a slow client.
      if (ws.bufferedAmount > 1_000_000) { ws.close(1013, "slow consumer"); continue; }
      ws.send(payload);
    }
  }
}

Server-Sent Events: the underrated one

  • One long-lived HTTP response, Content-Type: text/event-stream, messages framed as data: lines. Works through ordinary HTTP infra, no Upgrade dance.
  • Built-in resilience the browser gives you free: EventSource auto-reconnects and sends Last-Event-ID — set id: on each event and keep a short replay buffer server-side, and clients survive blips without missing events. With WebSockets you hand-build all of that.
  • Limits: server→client only; on HTTP/1.1 browsers cap ~6 connections per origin (HTTP/2 multiplexing fixes it); text-only. This is what LLM token streaming (including the OpenAI/Anthropic APIs) uses.
import type { Request, Response } from "express";

interface SseEvent { id: string; type: string; data: unknown; }
const replayBuffer: SseEvent[] = [];        // ring buffer of recent events

export function sseHandler(req: Request, res: Response): void {
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
    "X-Accel-Buffering": "no",              // tell nginx not to buffer the stream
  });

  // Resume: browser sent Last-Event-ID after auto-reconnect — replay the gap.
  const lastId = req.header("Last-Event-ID");
  const start = lastId ? replayBuffer.findIndex((e) => e.id === lastId) + 1 : 0;
  for (const e of replayBuffer.slice(start)) write(res, e);

  const onEvent = (e: SseEvent) => write(res, e);
  eventEmitter.on("event", onEvent);
  const heartbeat = setInterval(() => res.write(": ping\n\n"), 15_000); // comment frame
  req.on("close", () => { clearInterval(heartbeat); eventEmitter.off("event", onEvent); });
}

function write(res: Response, e: SseEvent): void {
  // id line powers Last-Event-ID resume; event line routes to addEventListener(type).
  res.write(`id: ${e.id}\nevent: ${e.type}\ndata: ${JSON.stringify(e.data)}\n\n`);
}

Choosing

Receiver is a server (B2B)? yes Webhooks signed + retried no Freshness of tens of seconds OK? yes Short polling cheapest that works no Client needs to send too (2-way)? yes WebSocket chat, collab, trading no SSE one-way stream WS/SSE blocked by infra? Long polling fallback
AWS mapping
  • API Gateway WebSocket APIs: AWS terminates the sockets; you get $connect/$disconnect/message routes to Lambda and push back via the @connections management API. The connection registry becomes "store connectionId in DynamoDB". Caveats: 2h max connection life, 10m idle timeout, per-connection cost — great to a few hundred thousand connections, run your own nodes (ECS + Redis) beyond that or for latency-critical paths.
  • AppSync (GraphQL subscriptions): managed pub/sub over WebSockets when you're already GraphQL — AWS handles fan-out and connection state entirely.
  • EventBridge API Destinations: managed webhook sending — rule targets an external HTTPS endpoint with auth, rate limiting, and retries/DLQ built in. The "buy" option before building the dispatcher fleet above.
  • SNS HTTPS subscriptions: older managed webhook push with confirmation handshake and delivery retry policies.
  • Build custom when: you need Stripe-grade webhook UX (dashboards, resend, per-endpoint secrets) — API Destinations doesn't expose that to your customers; or >500k WebSocket connections where API Gateway pricing and limits stop making sense.
Pitfalls
  • Verifying HMAC on parsed-then-restringified JSON: key order changes, signature never matches. Capture the raw body bytes (Express: express.raw() or the verify hook) before any middleware parses it.
  • Signature without timestamp: an intercepted webhook can be replayed a week later and still verify. Sign timestamp.body, enforce tolerance.
  • Doing real work before acking a webhook: 25s handler → provider timeout → retry storm → duplicate orders. Enqueue, return 200, process async.
  • Assuming webhook ordering: retries guarantee reordering. Consumers keying state transitions off arrival order corrupt state; use event ids + object versions, fetch current state when it matters.
  • Webhook SSRF: letting customers register http://169.254.169.254/ and having your dispatcher fetch it with instance credentials. Validate targets, block private ranges, follow redirects suspiciously.
  • WebSockets without heartbeats: half-open sockets pile up for hours; "online" users who left yesterday. Ping/pong + registry TTLs.
  • Reconnect stampede: a deploy disconnects 1M clients that all reconnect at t+1s. Jittered client backoff + gradual node draining, or the reconnect wave kills you twice.
  • Unbounded per-socket send buffers for slow clients — one bad mobile network OOMs a node. Cap and coalesce or cut.
Production best practice
  • Webhook provider checklist: per-endpoint secret + rotation, HMAC-SHA256 with timestamp, delivery job per (event, endpoint), backoff schedule spanning days, DLQ + customer-visible delivery log with manual resend, auto-disable dead endpoints, document "no ordering, at-least-once" loudly.
  • Emit delivery metrics per endpoint (success rate, latency, retry depth) — your support team will live in this dashboard.
  • WebSocket ops: drain nodes on deploy (stop accepting, let clients migrate), registry entries with TTL refreshed by heartbeat, resume-by-sequence protocol, load-test the reconnect storm — not just steady state.
  • Prefer SSE over WebSocket whenever the flow is one-way — you delete the reconnect/resume code the browser already ships.
  • Always offer polling as the fallback API; every push mechanism eventually fails for some client behind some proxy.
What interviewers probe
  • Q: How does Stripe deliver webhooks reliably? A: Events persisted first (outbox), delivery jobs fanned out per endpoint via queues, HMAC-signed with timestamp, any-2xx-wins with short timeout, exponential retries over ~3 days, then dead-letter with a dashboard + manual resend, endpoints auto-disabled after prolonged failure, ordering explicitly not guaranteed so consumers dedup by event id and fetch fresh state.
  • Q: How do you scale to 10M WebSocket connections? A: ~10–100 nodes (100k–1M conns each, memory-bound), connection registry in Redis with TTL entries, inter-node routing via pub/sub, jittered client reconnect + node draining for deploys, heartbeats for half-open detection, per-socket backpressure caps. The sockets are easy; deploys, resync, and fan-out amplification are the real design.
  • Q: SSE vs WebSocket? A: One-way → SSE: plain HTTP, free auto-reconnect with Last-Event-ID, proxy-friendly. WebSocket only when the client genuinely sends frequently (chat typing, game input) — otherwise you're paying stateful-ops tax for nothing. Client→server occasional messages can just be POSTs alongside SSE.
  • Q: How do you secure a webhook endpoint you consume? A: Verify HMAC over raw body in constant time, enforce timestamp tolerance, dedup by event id, ack fast and process async, and treat the payload as a hint — confirm state via the provider's API before moving money.
  • Q: A consumer's endpoint is down for 6 hours — what happens? A: Retries back off through the schedule so we don't hammer them; deliveries queue independently per endpoint so nobody else is affected; when it recovers, backlog drains in order of retry eligibility; anything past max attempts is in the DLQ, replayable from the dashboard.
  • Q: Why not long polling everywhere? A: It works, but each message costs a full request cycle and held connections churn LB/session resources; at high message rates SSE/WS are strictly cheaper. It survives as the lowest-common-denominator fallback.
References
Part V · Cross-Cutting

16. Bloom Filters & Probabilistic Data Structures

Probabilistic data structures trade a tiny, mathematically bounded error for orders-of-magnitude savings in memory and O(1) operations. They show up in almost every large system's read path — LSM storage engines, CDNs, crawlers, analytics — and interviewers love them because they separate candidates who memorize buzzwords from those who understand the trade being made. This chapter covers Bloom filters in depth, then Count-Min Sketch, HyperLogLog, and quantile sketches.

The core trade: exactness for memory

Exact answers to "have I seen this before?" or "how many distinct things?" require storing every element (a hash set) — O(n) memory. Probabilistic structures answer the same questions in constant or near-constant memory by accepting a small, one-sided error:

  • Bounded memory: you choose the size up front (e.g. 1.2 GB for 1B URLs at 1% error vs ~40+ GB for an exact set).
  • O(1) or O(log) operations: insert and query touch k cells, independent of n.
  • One-sided error: the structure can be wrong in exactly one direction, and you design the system so that direction is cheap to recover from (e.g. a false positive just costs an extra disk read; a false negative would silently lose data — so the structure never produces one).
  • Tunable: the error rate is a knob. More memory → less error, with a closed-form formula.
Real-world analogy

A club bouncer with a fuzzy, compressed guest list. When you give your name, the bouncer can say "you are definitely NOT on the list" with total certainty — go home. Or he says "you might be on the list" — then he radios inside for the expensive full check. He never wrongly turns away a real guest (no false negatives), but occasionally he radios inside for someone who isn't actually invited (a false positive). The fuzzy list fits in his head; the real list is a binder in the back office. That's exactly how a Bloom filter fronts a slow, authoritative store.

Bloom filter deep dive

A Bloom filter is a bit array of m bits plus k independent hash functions.

  • Insert(x): compute k hashes of x, each mapping to a position in [0, m). Set all k bits to 1.
  • Query(x): compute the same k positions. If any bit is 0 → x was definitely never inserted. If all k bits are 1 → x was probably inserted (or you got unlucky and other elements happened to set those bits — a false positive).
  • No false negatives, ever: inserting x sets its bits, and bits are never cleared, so a later query on x always finds them set.
  • No deletes: clearing a bit might also clear it for other elements that share it — that would create false negatives. Deletion requires a counting variant (below) or rebuilding the filter.
  • No enumeration: you can't list members; the filter only answers membership queries. It also can't store values — pair it with the real store.
insert("alice") — k = 3 hashes set 3 bits "alice" h1 → 4 h2 → 7 h3 → 11 0 0 0 0 1 0 0 1 0 0 0 1 0 0 0 4 7 11 13 query("bob") → h1=4 (bit 1), h2=9 (bit 0) any bit 0 → "bob" is DEFINITELY not present

The math you should know cold

With m bits, k hashes, n inserted elements, the false-positive probability is approximately:

  • p ≈ (1 − e−kn/m)k — the chance all k probed bits are already set.
  • Optimal k = (m/n) · ln 2 ≈ 0.693 · m/n — balances "more hashes catch more zeros" against "more hashes fill the array faster".
  • Required m = −n · ln p / (ln 2)² ≈ 1.44 · n · log₂(1/p) — memory needed for n elements at target error p.
  • Rule of thumb: ~10 bits (1.25 bytes) per element buys 1% error, regardless of element size. A 200-byte URL costs the same 10 bits as a 16-byte hash.
Bits per element (m/n)Optimal kFalse-positive rate
64~5.6%
86~2.1%
107~0.8%
139~0.1%
2014~0.007%

TypeScript implementation

Production trick: you don't need k independent hash functions. Double hashingg_i(x) = h1(x) + i·h2(x) mod m — gives k derived hashes from two base hashes with no measurable loss in accuracy (Kirsch & Mitzenmacher, 2006).

/** Bloom filter over strings: Uint8Array bit ops + double hashing. */
class BloomFilter {
  private readonly bits: Uint8Array;
  private readonly m: number;      // total bits
  private readonly k: number;      // number of hash probes
  private count = 0;               // inserts seen (for fpr estimate)

  /** Size for expected n elements at target false-positive rate p. */
  constructor(expectedItems: number, targetFpr: number) {
    const ln2 = Math.LN2;
    this.m = Math.ceil((-expectedItems * Math.log(targetFpr)) / (ln2 * ln2));
    this.k = Math.max(1, Math.round((this.m / expectedItems) * ln2));
    this.bits = new Uint8Array(Math.ceil(this.m / 8)); // zero-filled
  }

  /** FNV-1a — fast, decent-distribution 32-bit hash; seed decorrelates h1/h2. */
  private fnv1a(value: string, seed: number): number {
    let h = (0x811c9dc5 ^ seed) >>> 0;
    for (let i = 0; i < value.length; i++) {
      h ^= value.charCodeAt(i);
      h = Math.imul(h, 0x01000193) >>> 0; // 32-bit FNV prime multiply
    }
    return h >>> 0;
  }

  /** Double hashing: probe i is (h1 + i*h2) mod m — k probes, 2 hashes. */
  private probes(value: string): number[] {
    const h1 = this.fnv1a(value, 0x12345678);
    let h2 = this.fnv1a(value, 0x9e3779b9);
    if (h2 % this.m === 0) h2 += 1;  // h2 must not be ≡ 0 mod m
    const out: number[] = new Array(this.k);
    for (let i = 0; i < this.k; i++) {
      out[i] = (h1 + i * h2) % this.m;
    }
    return out;
  }

  add(value: string): void {
    for (const p of this.probes(value)) {
      this.bits[p >>> 3] |= 1 << (p & 7);  // set bit p
    }
    this.count++;
  }

  /** false → definitely absent. true → probably present. */
  has(value: string): boolean {
    for (const p of this.probes(value)) {
      if ((this.bits[p >>> 3] & (1 << (p & 7))) === 0) return false;
    }
    return true;
  }

  /** Current estimated false-positive rate: (1 - e^(-kn/m))^k. */
  estimatedFpr(): number {
    return Math.pow(1 - Math.exp((-this.k * this.count) / this.m), this.k);
  }
}

// 1M usernames at 1% error → ~1.2 MB instead of tens of MB for a Set
const taken = new BloomFilter(1_000_000, 0.01);
taken.add("sedhu");
taken.has("sedhu");        // true (guaranteed — no false negatives)
taken.has("free_handle");  // false → skip the DB entirely

Where Bloom filters live in real systems

  • LSM engines (RocksDB, Cassandra, HBase, LevelDB): each SSTable carries a Bloom filter; a point read consults filters to skip SSTables that definitely don't contain the key — turning a read that could touch 10 files into 1–2 disk seeks. This is why LSM reads are viable at all (ch09).
  • CDN "cache on second hit": Akamai found ~75% of objects are requested exactly once ("one-hit wonders"). A Bloom filter of recently-seen URLs gates cache admission — only cache on the second sighting, saving huge amounts of cache churn and disk writes.
  • "Username taken" pre-check: answer most availability checks from memory; only hit the DB when the filter says "maybe taken".
  • Web crawler visited-set: 10B URLs at 0.1% error ≈ 15 GB of filter vs terabytes for exact storage; a false positive just means one page is mistakenly skipped.
  • Safe-browsing / malware URL lists: browsers historically shipped a Bloom-style local filter of bad URLs; "maybe bad" triggers a call to the full server-side list.
  • Distributed joins & caches: ship a Bloom filter of join keys to avoid moving non-matching rows; check "does the cache cluster even have this?" before a network hop.

Variants: counting Bloom filters and cuckoo filters

  • Counting Bloom filter: replace each bit with a small counter (4 bits is standard — overflow probability is negligible). Insert increments k counters, delete decrements them, query checks all k are > 0. Costs 4× the memory of a plain filter but supports deletion — this is what Cassandra-style compaction-friendly designs and some caches use when membership churns.
  • Cuckoo filter: stores short fingerprints in a cuckoo hash table (two candidate buckets per item, evict-and-relocate on collision). Supports deletion natively, gives better space efficiency than Bloom below ~3% target error, and has better cache locality (2 memory accesses vs k). The trade: inserts can fail when the table nears capacity (~95% load), so you must size it honestly. Increasingly the modern default where deletes matter.

Count-Min Sketch — "how many times have I seen X?"

A Bloom filter answers membership; a Count-Min Sketch (CMS) answers frequency. It's a d×w matrix of counters with one hash per row. Increment: bump one counter per row. Query: take the minimum across rows — collisions only inflate counters, so the estimate overestimates, never underestimates. Error is bounded: with w = ⌈e/ε⌉ and d = ⌈ln(1/δ)⌉, the estimate exceeds the truth by more than ε·N with probability at most δ.

/** Count-Min Sketch: fixed memory frequency estimation (overestimates only). */
class CountMinSketch {
  private readonly table: Uint32Array;   // d rows × w cols, flattened
  private readonly w: number;
  private readonly d: number;
  private readonly seeds: number[];

  /** epsilon: additive error factor (as fraction of total count N).
   *  delta: probability the error bound is exceeded. */
  constructor(epsilon = 0.001, delta = 0.01) {
    this.w = Math.ceil(Math.E / epsilon);        // e.g. 2719 cols
    this.d = Math.ceil(Math.log(1 / delta));     // e.g. 5 rows
    this.table = new Uint32Array(this.w * this.d);
    this.seeds = Array.from({ length: this.d }, (_, i) => 0x9e3779b9 * (i + 1));
  }

  private hash(value: string, seed: number): number {
    let h = seed >>> 0;
    for (let i = 0; i < value.length; i++) {
      h = (Math.imul(h ^ value.charCodeAt(i), 0x01000193)) >>> 0;
    }
    return h % this.w;
  }

  increment(value: string, by = 1): void {
    for (let row = 0; row < this.d; row++) {
      this.table[row * this.w + this.hash(value, this.seeds[row])] += by;
    }
  }

  /** Estimated count: true count ≤ estimate ≤ true + ε·N (w.p. 1−δ). */
  estimate(value: string): number {
    let min = Number.MAX_SAFE_INTEGER;
    for (let row = 0; row < this.d; row++) {
      min = Math.min(min, this.table[row * this.w + this.hash(value, this.seeds[row])]);
    }
    return min;
  }
}

// Trending hashtags: ~54 KB of counters instead of a per-tag hashmap
const trends = new CountMinSketch(0.0005, 0.01);
trends.increment("#superbowl");
trends.estimate("#superbowl"); // ≥ true count, tightly bounded above
  • Heavy hitters / trending topics: pair a CMS with a small min-heap of the current top-K — the classic "trending hashtags" design.
  • Rate-abuse detection: "has this IP made > 10k requests this hour?" across millions of IPs in fixed memory (overestimation errs on the side of throttling — usually acceptable).
  • Why min works: every row's counter ≥ truth (collisions only add); the least-collided row is the best estimate.

HyperLogLog — "how many DISTINCT things?"

Counting distinct elements exactly requires remembering every element. HyperLogLog (HLL) estimates cardinality at planet scale in ~12 KB with a standard error of ~0.81% (Redis's 16384-register implementation), whether you're counting 100 or 10 billion distinct items.

  • Intuition — the coin-flip trick: hash each element to a uniform random bitstring. Track the longest run of leading zeros seen. Seeing "0001…" is like flipping 3 tails then a head — a 1-in-16 event. If your record run of leading zeros is r, you've probably seen ~2r distinct elements — just as the longest run of heads at a casino table tells you roughly how many rounds were played. Duplicates hash identically, so they never move the record — that's why it counts distinct elements for free.
  • Making it stable: one max is horribly noisy (a single lucky hash doubles the estimate). HLL splits the hash space into m=16384 buckets using the first 14 bits, keeps the max-leading-zero count per bucket, and combines them with a harmonic mean plus bias correction. Standard error = 1.04/√m ≈ 0.81%.
  • Mergeable: union two HLLs by taking the per-bucket max — so each app server keeps a local HLL and you merge for the global count. This is the killer feature for distributed analytics.
  • Redis: PFADD visitors:2026-07-16 user123, PFCOUNT visitors:2026-07-16, PFMERGE weekly d1 d2 ... d7 — daily unique visitors, mergeable into weekly/monthly, 12 KB per key regardless of traffic.
  • Also in: BigQuery/Athena approx_distinct, Druid, Presto, Elasticsearch cardinality aggregation.

Quantile sketches (t-digest) in one paragraph

"What's our p99 latency?" cannot be answered by averaging averages, and storing every sample is prohibitive at millions of requests/sec. t-digest (Dunning) and DDSketch keep a compressed set of centroids — dense near the extreme quantiles where precision matters, sparse in the middle — so you get accurate p99/p999 in a few KB, and sketches from many hosts merge correctly. This is what backs Datadog/Prometheus-style percentile aggregation and Elasticsearch percentile aggregations; the staff-level point is that percentiles don't compose but sketches do — never average per-host p99s (ch on observability).

Choosing the right sketch

StructureQuestion it answersError typeMemoryReal system
Bloom filter"Have I seen X?" (membership)False positives only~10 bits/elem @ 1%RocksDB/Cassandra SSTable skip
Counting BloomMembership with deletesFalse positives only~4× BloomCache admission with eviction
Cuckoo filterMembership with deletesFalse positives only< Bloom at low fprModern LSM / cache filters
Count-Min Sketch"How many times X?" (frequency)Overestimates onlyFixed (KBs)Trending topics, abuse detection
HyperLogLog"How many distinct?" (cardinality)~0.81% relative, two-sided~12 KB fixedRedis PFCOUNT, BigQuery approx
t-digest / DDSketch"What's the p99?" (quantiles)Small relative rank errorFew KBDatadog, Elasticsearch percentiles
AWS mapping
  • ElastiCache / MemoryDB (Redis): HyperLogLog is built in (PFADD/PFCOUNT/PFMERGE); Redis Stack adds BF.* (Bloom), CF.* (cuckoo), CMS.*, and TDIGEST.* modules — reach for these before hand-rolling a distributed sketch.
  • Athena / Redshift: approx_distinct() and approx_percentile() are HLL and quantile sketches under the hood — say this when asked to count uniques over S3-scale data.
  • DynamoDB / Aurora front-ends: a Bloom filter in your service layer (or in ElastiCache) can short-circuit existence checks before paying per-request DynamoDB read costs.
  • CloudFront-style cache admission: AWS doesn't expose it as a knob, but the "cache on second hit" Bloom pattern is the standard answer when you design your own edge cache on EC2/ECS.
  • Build custom when: the filter must live in-process for nanosecond checks (e.g. inside a stream processor on Kinesis/Flink); use Redis modules when multiple services must share one sketch.
Pitfalls
  • Undersizing the filter: false-positive rate degrades non-linearly as n exceeds the design capacity — a filter sized for 1M items holding 3M is near-useless. Track fill ratio; rebuild or use scalable Bloom filters (chained filters with tightening error).
  • Deleting from a plain Bloom filter: instantly creates false negatives. If you need deletes, that's a counting Bloom or cuckoo filter decision made up front.
  • Using it where false positives are harmful: a Bloom filter as a security allow-list means occasionally letting the wrong entity through. Only use it where the "maybe" path falls through to an authoritative check.
  • Weak or correlated hash functions: k probes derived from a bad base hash cluster together and wreck the error bound. Use a well-tested hash (Murmur3, xxHash) + double hashing.
  • Averaging percentiles instead of merging sketches: the mean of ten hosts' p99s is not the fleet p99. Merge t-digests/HLLs, then query.
  • Forgetting sketches are approximate in billing/compliance paths: in fintech, never use HLL/CMS for anything that reconciles money — use them for dashboards and gates, exact stores for ledgers.
Production best practice
  • Size Bloom filters with explicit (n, p) inputs in code — as in the constructor above — and export estimatedFpr() as a metric with an alert threshold.
  • Persist filters as versioned snapshots (they're just byte arrays) and rebuild from the source of truth on a schedule — they drift as data is deleted upstream.
  • Prefer Redis Stack's battle-tested BF/CF/CMS/TDIGEST modules over hand-rolled distributed sketches; hand-roll only in-process hot paths.
  • For distinct counts, standardize on HLL everywhere so counts merge across days/regions/services instead of being incompatible exact/approximate mixes.
What interviewers probe
  • Q: How would you check if a URL was already crawled across 10B URLs? A: Bloom filter: 10B × ~15 bits (0.1% fpr) ≈ 18 GB — fits on one beefy node or shards by URL hash. False positive = skip a page we'd have crawled (acceptable, tunable); false negative impossible, so we never re-crawl indefinitely. Mention partitioned filters per shard and periodic rebuild as the frontier ages out.
  • Q: Count unique visitors with 50 MB of memory? A: HyperLogLog: one 12 KB HLL per (site, day) supports ~4,000 concurrent counters in 50 MB at ~0.81% error, and PFMERGE gives weekly/monthly rollups for free. Exact counting would need GBs per high-traffic day.
  • Q: Why does Cassandra's read path use Bloom filters? A: A key may live in any of many SSTables; without filters a read does one disk seek per candidate SSTable. Each SSTable's in-memory Bloom filter says "definitely not here" for most of them, so reads touch ~1 file. The false positive cost is one wasted seek — cheap and bounded.
  • Q: Bloom filter vs hash set — when is the hash set right? A: When n is small enough to fit comfortably in memory, when you need deletion/enumeration/values, or when any false positive is unacceptable. Sketches are for scale, not a default.
  • Q: How do you handle deletes? A: Counting Bloom (4-bit counters, 4× memory) or cuckoo filter (fingerprints, native delete, better space at low fpr) — or design around it: rebuild the filter periodically from the source of truth.
  • Q: How does HLL work in one minute? A: Hash items uniformly; the longest run of leading zeros estimates log₂ of the distinct count (longest run of heads ≈ how many coin-flip rounds happened). Bucket into 16K registers and harmonic-mean them to kill variance; duplicates never change any register, so it's naturally distinct-counting and mergeable via per-register max.
  • Q: Trending topics over a firehose? A: Count-Min Sketch for frequencies (overestimates only, fixed memory) + a top-K min-heap; shard by topic hash, merge sketches by cell-wise addition.
References
Part V · Cross-Cutting

17. Rate Limiting & Throttling

Every real system limits somebody. Rate limiting protects you from clients, protects clients from each other, and protects your own services from your own retry storms. Interviewers use it as a compact systems problem: four algorithms with sharp trade-offs, a genuinely hard distributed-state question, and an HTTP contract to get right. This chapter gives you working implementations of all four limiters, the Redis+Lua distributed pattern, and the fail-open/fail-closed judgment call.

Why limit at all

  • Protection from abuse: credential stuffing, scraping, OTP/SMS pumping (a fintech classic — attackers burn your SMS budget), brute-force on login and card-check endpoints.
  • Protection from yourself: a buggy client loop, a mobile release that retries without backoff, or a batch job hammering an internal API can take you down as effectively as any attacker. Most "DDoS" incidents are self-inflicted.
  • Fairness: one tenant's burst shouldn't starve everyone else — per-key limits are how multi-tenant SaaS keeps noisy neighbors contained.
  • Cost control: every request has a marginal cost (DynamoDB RCUs, LLM tokens, SMS). Limits turn "unbounded liability" into "bounded budget", and paid tiers into enforceable products (usage plans).
  • Downstream contracts: your payment processor allows 100 TPS; a limiter in front of that call is how you stay a good citizen and avoid being limited by them.

Where to enforce: defense in depth

Enforce at multiple layers, cheapest first. Each layer stops a different class of traffic and protects the layers behind it.

Edge Gateway Services WAF / CDN per-IP, volumetric, bots API gateway per API key / usage plan Service per-user, per-op Redis shared counters blocks floods cheaply enforces the product contract business-aware limits (e.g. 3 OTPs/hour)
  • Edge (WAF/CDN): coarse per-IP rules, blocks volumetric floods before they cost you compute.
  • Gateway: per-API-key limits — this is where "your plan allows 1,000 req/min" lives.
  • Per-service: business-aware limits the gateway can't express — "3 OTP sends per phone number per hour", "5 failed logins per account". These need app context and usually shared state (Redis).

The four algorithms

1. Token bucket — the industry default

A bucket holds up to capacity tokens, refilled at refillRate tokens/sec. Each request takes a token; no token → rejected. Allows bursts up to capacity while enforcing the average rate.

Real-world analogy

Arcade tokens. The arcade drops 10 tokens into your cup every hour, and the cup holds at most 40. If you've been away all afternoon you can walk in and burn 40 games back-to-back (a burst), but over any long stretch you can never average more than 10 games/hour. The cup's size is your burst allowance; the drip rate is your sustained rate — two independent knobs, which is exactly why API Gateway configures "rate" and "burst" separately.

interface RateLimitDecision {
  allowed: boolean;
  remaining: number;       // tokens left after this decision
  retryAfterMs?: number;   // only set when rejected
}

/** Token bucket: burst up to `capacity`, sustained `refillRate`/sec.
 *  Lazy refill: no timers — tokens are computed on access. */
class TokenBucket {
  private tokens: number;
  private lastRefillMs: number;

  constructor(
    private readonly capacity: number,      // burst size
    private readonly refillRate: number,    // tokens per second
    now: number = Date.now(),
  ) {
    this.tokens = capacity;                 // start full
    this.lastRefillMs = now;
  }

  tryConsume(cost = 1, now: number = Date.now()): RateLimitDecision {
    // Refill based on elapsed time, capped at capacity.
    const elapsedSec = (now - this.lastRefillMs) / 1000;
    this.tokens = Math.min(this.capacity, this.tokens + elapsedSec * this.refillRate);
    this.lastRefillMs = now;

    if (this.tokens >= cost) {
      this.tokens -= cost;
      return { allowed: true, remaining: Math.floor(this.tokens) };
    }
    // How long until enough tokens accumulate?
    const deficit = cost - this.tokens;
    return {
      allowed: false,
      remaining: 0,
      retryAfterMs: Math.ceil((deficit / this.refillRate) * 1000),
    };
  }
}

2. Leaky bucket — smoothing the output

Requests enter a FIFO queue (the bucket); a drain processes them at a fixed rate. Overflow is rejected. Where token bucket shapes admission and permits bursts, leaky bucket shapes output — downstream sees a perfectly smooth stream. Use it when the protected resource genuinely can't absorb bursts (a partner API with strict TPS, a hardware writer). Cost: queued requests add latency, and the queue itself needs bounding. In practice most "leaky bucket" implementations (e.g. nginx limit_req) are the meter variant, equivalent to a token bucket with burst=queue size.

3. Fixed window — simple but flawed at the boundary

Count requests per discrete window (e.g. per minute, keyed user:42:12:05); reject above the limit; counter resets each window. One counter, trivially cheap — but it has the boundary-burst flaw:

  • Limit: 100/min. A client sends 100 requests at 12:00:59 and 100 more at 12:01:01.
  • Both windows are individually legal — but that's 200 requests in 2 seconds, twice the intended rate, concentrated exactly where it hurts.
  • Adversaries find window edges deliberately; well-behaved cron jobs find them accidentally (everything fires at :00).

4. Sliding window log & sliding window counter

Sliding log: store a timestamp per request (Redis ZSET), drop entries older than the window, count what remains. Perfectly accurate, but O(limit) memory per key — expensive at high limits. Sliding window counter keeps fixed-window cheapness while killing the boundary burst: keep counts for the current and previous windows and weight the previous one by how much of it still overlaps the sliding window. Cloudflare runs this at edge scale; their analysis showed ~0.003% of requests wrongly allowed/blocked — a fine trade for two counters per key.

/** Sliding window counter: prev-window count weighted by overlap.
 *  estimate = prevCount * overlapFraction + currCount */
class SlidingWindowCounter {
  private windows = new Map<number, number>(); // windowStartMs -> count

  constructor(
    private readonly limit: number,
    private readonly windowMs: number,
  ) {}

  tryConsume(now: number = Date.now()): boolean {
    const currStart = Math.floor(now / this.windowMs) * this.windowMs;
    const prevStart = currStart - this.windowMs;

    const currCount = this.windows.get(currStart) ?? 0;
    const prevCount = this.windows.get(prevStart) ?? 0;

    // Fraction of the previous window still inside the sliding window.
    const overlap = 1 - (now - currStart) / this.windowMs;   // 1 → 0 across the window
    const estimated = prevCount * overlap + currCount;

    if (estimated >= this.limit) return false;

    this.windows.set(currStart, currCount + 1);
    // GC: anything older than prev is dead weight.
    for (const key of this.windows.keys()) {
      if (key < prevStart) this.windows.delete(key);
    }
    return true;
  }
}
AlgorithmMemory / keyBurst handlingAccuracyUse when
Token bucket2 numbersAllows bursts up to capacity (a feature)Exact on average rateDefault for APIs; burst + sustained knobs
Leaky bucket (queue)Queue up to sizeAbsorbs bursts, emits smooth streamExact output rateDownstream can't tolerate bursts
Fixed window1 counterUp to 2× limit at boundariesPoor at edgesRough quotas where 2× spikes are fine
Sliding window logO(limit) timestampsExactPerfectLow limits on precious ops (5 logins/min)
Sliding window counter2 countersNo boundary burst~99.997% (Cloudflare)High-scale general limiting

Distributed rate limiting

The race problem: with N gateway instances behind a load balancer, per-instance limiters multiply the effective limit by N, and naive shared counters race: two instances read count=99 (limit 100), both allow, both write — 101 requests slipped through. Multiply by concurrency and the limit is fiction. Options:

  • Centralized atomic check (Redis + Lua): the read-compute-write of the bucket runs as one atomic script server-side. Single round trip, no race. This is the standard answer.
  • Local cache + async sync: each node limits locally against its share and periodically reconciles with a global counter. Cheap and Redis-outage-tolerant, but temporarily inaccurate (can overshoot between syncs). Fine for cost-protection limits; wrong for security limits.
  • Sticky routing: consistently hash the rate-limit key (user id) to one node, which owns that key's bucket in memory — no shared store at all. Trade: rebalancing on node failure resets buckets, and hot keys hot-spot a node. This is how some in-memory gateway clusters (and Envoy's per-host mode) work.
-- ratelimit.lua: atomic token bucket in Redis.
-- KEYS[1] = bucket key
-- ARGV: capacity, refill_rate (tokens/sec), now_ms, cost
local capacity = tonumber(ARGV[1])
local rate     = tonumber(ARGV[2])
local now_ms   = tonumber(ARGV[3])
local cost     = tonumber(ARGV[4])

local state  = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity
local ts     = tonumber(state[2]) or now_ms

-- lazy refill, capped at capacity
tokens = math.min(capacity, tokens + ((now_ms - ts) / 1000) * rate)

local allowed = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
end

redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now_ms)
-- expire idle buckets: time to fully refill, plus slack
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate * 1000) * 2)
return { allowed, math.floor(tokens) }
import { createClient } from 'redis';

interface DistributedLimitResult { allowed: boolean; remaining: number; }

class RedisTokenBucket {
  private scriptSha: string | null = null;

  constructor(
    private readonly redis: ReturnType<typeof createClient>,
    private readonly capacity: number,
    private readonly refillRate: number,
    private readonly luaScript: string,          // contents of ratelimit.lua
  ) {}

  async tryConsume(key: string, cost = 1): Promise<DistributedLimitResult> {
    // Load once, then EVALSHA — avoids resending the script every call.
    this.scriptSha ??= await this.redis.scriptLoad(this.luaScript);
    try {
      const [allowed, remaining] = (await this.redis.evalSha(this.scriptSha, {
        keys: [`rl:${key}`],
        arguments: [
          String(this.capacity), String(this.refillRate),
          String(Date.now()), String(cost),
        ],
      })) as [number, number];
      return { allowed: allowed === 1, remaining };
    } catch (err) {
      // FAIL-OPEN policy: Redis down must not take the API down.
      // Flip to fail-closed for abuse-sensitive keys (login, OTP).
      console.error('rate limiter unavailable, failing open', err);
      return { allowed: true, remaining: 0 };
    }
  }
}

The HTTP contract: what to return

ElementValuePurpose
Status429 Too Many RequestsStandard signal (RFC 6585); use 503 only for server-wide load shedding
Retry-AfterSeconds (or HTTP date)Tells well-behaved clients exactly when to come back — cuts retry storms
X-RateLimit-Limite.g. 100The client's quota for the window
X-RateLimit-Remaininge.g. 17Lets clients self-throttle before hitting the wall
X-RateLimit-ResetEpoch secondsWhen the quota refreshes (IETF draft standardizes RateLimit-*)
BodyMachine-readable error code + docs linkDebuggability; don't leak which dimension tripped for abuse limits
import type { Request, Response, NextFunction } from 'express';

type KeyExtractor = (req: Request) => string;

/** Express middleware wiring the distributed token bucket + proper headers. */
function rateLimit(limiter: RedisTokenBucket, keyOf: KeyExtractor, limit: number) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const key = keyOf(req);                       // user id > API key > IP
    const result = await limiter.tryConsume(key);

    res.setHeader('X-RateLimit-Limit', String(limit));
    res.setHeader('X-RateLimit-Remaining', String(Math.max(0, result.remaining)));

    if (!result.allowed) {
      const retryAfterSec = 1; // or derive from bucket deficit
      res.setHeader('Retry-After', String(retryAfterSec));
      res.setHeader('X-RateLimit-Reset', String(Math.ceil(Date.now() / 1000) + retryAfterSec));
      return res.status(429).json({
        error: 'rate_limited',
        message: 'Too many requests. Retry after the indicated delay.',
      });
    }
    next();
  };
}

// Dimension by the most specific stable identity available:
const keyOf: KeyExtractor = (req) =>
  (req as any).user?.id ?? req.header('x-api-key') ?? req.ip ?? 'anon';
// app.use('/api', rateLimit(limiter, keyOf, 100));

Dimensioning: per-user vs per-IP vs per-API-key

  • Per-user (authenticated id): the fairest and most precise — use it wherever auth exists. Immune to NAT problems.
  • Per-API-key: the product/billing dimension — maps to plans and tiers; a customer's many servers share one quota.
  • Per-IP: the only option pre-auth (login, signup, OTP) — but corporate NATs and CGNAT put thousands of users behind one IP (limit too low → collateral damage) while attackers rotate IPs (limit too high → useless). Use it as a coarse outer ring, combined with per-account and per-phone-number limits inside.
  • Compound keys for abuse: fintech OTP endpoints want per-phone AND per-IP AND per-device limits simultaneously — any one tripping blocks.
MechanismDefinitionTriggerWho feels it
Rate limiting / throttlingEnforce a per-client quota (policy)Client exceeds its allowanceThe offending client only (429)
Load sheddingServer drops excess work to survive (self-defense)Server-wide overload (CPU, queue depth), regardless of who caused itSome/all clients (503), ideally lowest-priority first
BackpressureConsumer signals producer to slow down through the pipelineDownstream can't keep upUpstream producers (bounded queues, TCP windows, reactive streams)

Client-side behavior completes the loop: on 429/503, retry with exponential backoff + full jitter (sleep = random(0, min(cap, base·2^attempt)) — the AWS-recommended variant, because un-jittered backoff synchronizes clients into waves), always respect Retry-After when present, and cap total retries with a budget so retries can't exceed a fraction of live traffic. Adaptive limits go further: instead of static numbers, adjust limits from observed signals (latency gradients, error rates) — Netflix's concurrency-limits library and TCP-Vegas-style algorithms shrink the window when latency rises, giving you limits that track real capacity as it changes with deploys and hardware.

Which limiter where?

Multiple nodes share the limit? yes Redis + Lua atomic bucket (algorithm below still applies) no / per-node ok Bursts acceptable to downstream? no — smooth it Leaky bucket (queue) strict-TPS partner APIs yes Low limit on a precious op? no — general API Token bucket (default) or sliding window counter at scale yes Sliding window log (exact) 5 logins/min, 3 OTPs/hour
AWS mapping
  • API Gateway throttling: token bucket semantics with separate rate (steady req/s) and burst (bucket capacity); account-level defaults 10,000 rps / 5,000 burst per region, overridable per stage and per method. Returns 429 for you.
  • Usage plans + API keys: per-customer quotas (req/day/week/month) and throttles — the productized "your plan allows X" layer; pair with marketplace metering for paid tiers.
  • WAF rate-based rules: per-IP (or per header/geo aggregation) counts over a sliding window at the edge — your volumetric/abuse outer ring in front of CloudFront/ALB.
  • SQS as a natural throttle: put a queue between producers and a fragile consumer, and the consumer's poll rate is the limit — a leaky bucket you don't have to write. Combine with reserved Lambda concurrency to cap the drain rate.
  • ElastiCache Redis: the home of the Lua bucket above when you need custom, business-aware distributed limits.
  • Build custom when: limits key on business entities (phone number, card BIN, tenant plan) or need compound dimensions — gateway/WAF knobs only see IPs, keys, and headers.
Pitfalls
  • Per-instance limiters behind a load balancer: N instances silently multiply the limit by N. Either accept it explicitly (divide by N — brittle under autoscaling) or centralize.
  • GET/INCR without atomicity: read-then-write against Redis races under concurrency; the check and the update must be one atomic operation (Lua, or INCR-then-check patterns).
  • Fixed windows for security limits: the boundary burst gives attackers 2× your intended rate exactly when they schedule for it.
  • Unbounded Redis keys: per-user buckets without TTLs grow forever; always PEXPIRE idle buckets (the script above does).
  • Retrying 429s aggressively: clients that retry immediately on 429 convert a limit into an amplifier. Server: send Retry-After. Client: honor it.
  • Rate limiting health checks / internal callers: ALB health checks or sidecar probes hitting a limited route will flap your targets. Exempt infrastructure paths explicitly.
  • One global "requests" limit: a cheap GET and a payment initiation are not the same cost — use per-operation limits or token costs (note the cost parameter in the implementations).
Production best practice
  • Ship limits in shadow mode first: log would-be 429s for a week, size limits from real p99 client behavior, then enforce. Never guess limits into production.
  • Emit metrics per (route, key-dimension): allow/deny counts, top limited keys. A sudden spike in 429s is an incident signal (attack or broken client) either way.
  • Make the fail-open/fail-closed choice per route, in code, deliberately: fail open for general APIs (availability), fail closed for login/OTP/withdrawal endpoints (abuse), and alert loudly the moment the limiter backend is unreachable.
  • Document limits and headers publicly; well-informed clients self-throttle and file fewer tickets.
  • Keep the limiter check < 1–2 ms: EVALSHA (not EVAL), pipelining, and Redis in the same AZ. The limiter must never be the latency story.
What interviewers probe
  • Q: Design a distributed rate limiter. A: Run the ch25 method compactly: clarify requirements (limits per what key? accuracy vs latency? scale — say 100k rps, 10M keys), state the API (allow(key, cost) → {allowed, retryAfter}), pick token bucket for semantics, Redis + Lua for atomic shared state (~200 bytes/key → 10M keys ≈ 2 GB, shard by key hash when one node saturates), put the check in gateway middleware, define 429 + headers, then hit operations: Redis failure → fail-open with alerting, hot keys, multi-region (per-region limits or accept slack). Finish with the local-cache/sync variant as the latency optimization.
  • Q: Token bucket vs leaky bucket? A: Token bucket admits bursts up to capacity while capping the average — right for user-facing APIs where bursts are legitimate. Leaky bucket emits a constant output rate — right when the protected downstream genuinely can't absorb bursts. Same math family; the difference is whether burstiness passes through or is smoothed (at the cost of queueing latency).
  • Q: Redis is down — fail open or closed? A: Policy, not reflex: usually fail open — a rate limiter is protection, and protection that takes down the product is worse than brief unlimitedness; but fail closed (or fall back to strict per-instance local limits) for abuse-sensitive endpoints — login brute force, OTP sending, money movement — where unlimited traffic is the actual disaster. Strong answers name both, per-route, plus alerting and a local-limiter fallback as the middle ground.
  • Q: Why is fixed window bad? A: Boundary burst: 100/min allows 200 requests in the 2 seconds straddling a window edge. Show the fix: sliding window counter — two counters, previous window weighted by overlap.
  • Q: How do clients behave well? A: Exponential backoff with full jitter (un-jittered backoff synchronizes into thundering herds), honor Retry-After, cap retries with a retry budget, and read X-RateLimit-Remaining to slow down before the wall.
  • Q: Rate limiting vs load shedding? A: Limiting is per-client policy (429, the client's fault); shedding is server self-defense under aggregate overload (503, nobody's fault in particular) — you need both, and they trigger on different signals.
  • Q: Where do limits live in your architecture? A: Defense in depth — WAF per-IP at the edge, per-key plans at the gateway, business-aware compound limits (per-phone OTP) in services with Redis. Each layer stops what the previous can't see.
References
Part V · Cross-Cutting

18. Unique IDs, Clocks & Ordering

Two deceptively small questions underlie half of distributed systems: "how do I mint a unique ID?" and "which event happened first?" This chapter covers ID generation from UUIDs to Snowflake, then the uncomfortable truth that wall clocks lie — and the logical-clock machinery (Lamport, vector, hybrid) that lets you order events without trusting time. Interviewers love this pairing because the ID question looks easy and the ordering question exposes whether you actually understand distribution.

Why auto-increment breaks at scale

  • Single point of coordination: one database hands out the next number; every writer serializes through it. Shard the database and two shards will happily both issue id 1000001.
  • Leaks business volume: sequential order IDs let anyone diff two IDs a day apart and read your daily order count off your URL — a real competitive-intel technique (the "German tank problem" applied to your invoices).
  • Can't pre-generate or generate offline: clients and edge services must round-trip to the DB before they even have an identity for the object; no batching writes, no offline-first apps.
  • Ties identity to one storage engine: migrating off the database means migrating the ID authority too.

Requirements matrix — ask these before choosing a scheme:

  • Unique — always. Globally or per-shard?
  • Sortable? — roughly time-ordered IDs make B-tree PKs happy and give free "recent first" ordering.
  • Opaque? — must the ID hide volume/timing from outsiders? (Sortable and opaque are in tension.)
  • Size — 64-bit fits in a BIGINT and is cheap to index; 128-bit is collision-proof without coordination.
  • Generation locality — can any node mint IDs with zero coordination, or is a call to a service acceptable?

UUIDv4 vs UUIDv7/ULID: randomness is index-hostile

  • UUID v4: 122 random bits. Collision-proof, zero coordination, fully opaque — and terrible as a B-tree primary key. Each insert lands at a uniformly random leaf page: every page is always "hot", the working set is the whole index, page splits fragment constantly, and cache hit rates collapse. In InnoDB (clustered PK) this shreds insert throughput and bloats the table — see ch09 for the B-tree mechanics.
  • UUID v7 (RFC 9562): 48-bit Unix millisecond timestamp prefix + random tail. Inserts append to the "right edge" of the index like auto-increment, keeping pages warm and splits rare, while staying 128-bit and coordination-free. The modern default answer.
  • ULID: same idea predating v7 — 48-bit timestamp + 80 random bits, encoded as 26 chars of Crockford base32 (lexicographically sortable strings). Practically interchangeable with UUIDv7; pick whichever your ecosystem supports natively.
  • Caveat: time-prefixed IDs leak creation time and cluster writes at the index tail — a single hot shard in range-partitioned stores like DynamoDB (which is why DynamoDB wants high-cardinality, unordered partition keys — see AWS callout).
Real-world analogy

Filing cabinet vs shredder confetti. UUIDv7 keys are like filing invoices by date: new paper always goes in the last drawer, which is already open on your desk. UUIDv4 keys are confetti — every new sheet files into a random drawer, so you're forever walking the whole cabinet, opening cold drawers, and re-shuffling folders (page splits) to make room. Same cabinet, wildly different insert cost.

Snowflake IDs: 64 bits, k-sorted, coordination-free

Twitter's Snowflake packs a 64-bit integer: 41 bits of milliseconds since a custom epoch (~69 years) + 10 bits of machine id (1024 generators) + 12 bits of per-millisecond sequence (4096 IDs/ms/node ≈ 4M IDs/sec/node). Time-ordered across the fleet (roughly — "k-sorted"), fits a BIGINT, and each node mints locally. The only coordination is assigning machine ids (config, ZooKeeper, or a coordination-free hash of instance identity with collision checks).

64-bit Snowflake ID layout (MSB → LSB) 0 1 bit sign (unused) timestamp — ms since custom epoch monotonic-ish, gives time ordering 41 bits ≈ 69 years machine id datacenter+worker 10 bits = 1024 nodes sequence resets each ms 12 bits = 4096/ms throughput ceiling: 1024 nodes × 4096 ids/ms ≈ 4.2 billion IDs/second fleet-wide
/** Snowflake generator: 41-bit ms timestamp | 10-bit machine | 12-bit seq. */
class SnowflakeGenerator {
  // Custom epoch keeps 41 bits useful for ~69 years from here.
  private static readonly EPOCH = 1735689600000n;      // 2025-01-01T00:00:00Z
  private static readonly MACHINE_BITS = 10n;
  private static readonly SEQ_BITS = 12n;
  private static readonly MAX_SEQ = (1n << SnowflakeGenerator.SEQ_BITS) - 1n; // 4095

  private lastMs = -1n;
  private sequence = 0n;

  constructor(private readonly machineId: bigint) {
    if (machineId < 0n || machineId >= 1n << SnowflakeGenerator.MACHINE_BITS) {
      throw new RangeError('machineId must fit in 10 bits (0..1023)');
    }
  }

  next(): bigint {
    let nowMs = BigInt(Date.now());

    // Clock regression guard: NTP stepped us backwards. Never reuse a
    // past millisecond — that risks duplicate IDs. Refuse (or wait it out).
    if (nowMs < this.lastMs) {
      const driftMs = this.lastMs - nowMs;
      if (driftMs > 10n) {
        throw new Error(`clock moved back ${driftMs}ms; refusing to generate`);
      }
      nowMs = this.lastMs;               // small drift: pin to last seen ms
    }

    if (nowMs === this.lastMs) {
      this.sequence = (this.sequence + 1n) & SnowflakeGenerator.MAX_SEQ;
      if (this.sequence === 0n) {
        // 4096 IDs already minted this ms — spin until the next tick.
        while (BigInt(Date.now()) <= this.lastMs) { /* busy-wait ~≤1ms */ }
        nowMs = BigInt(Date.now());
      }
    } else {
      this.sequence = 0n;
    }
    this.lastMs = nowMs;

    return (
      ((nowMs - SnowflakeGenerator.EPOCH) <<
        (SnowflakeGenerator.MACHINE_BITS + SnowflakeGenerator.SEQ_BITS)) |
      (this.machineId << SnowflakeGenerator.SEQ_BITS) |
      this.sequence
    );
  }

  /** Decode for debugging/audit: when and where was this ID minted? */
  static decode(id: bigint): { timestampMs: number; machineId: number; seq: number } {
    return {
      timestampMs: Number((id >> 22n) + SnowflakeGenerator.EPOCH),
      machineId: Number((id >> 12n) & 0x3ffn),
      seq: Number(id & 0xfffn),
    };
  }
}

Ticket servers (Flickr style): before Snowflake, Flickr ran two MySQL "ticket" servers using REPLACE INTO on a single-row table with auto_increment_increment=2 and offsets 1 and 2 — one serves odd IDs, one even, so either can die. Simple, battle-tested, strictly-ordered-ish 64-bit IDs — but every ID mint is still a network round trip, and throughput caps at what two MySQL boxes can serve. It's the right answer when you already run MySQL and need modest scale; mention it as history, choose Snowflake/UUIDv7 today.

DimensionID generation as a serviceIn-process library
Latency per IDNetwork RTT (0.5–2 ms) — batch to amortizeNanoseconds
Failure modeService down → nobody writes (needs HA + client-side batching)Only local clock issues
Machine-id managementCentralized in the serviceMust assign 10-bit ids to every process (config/ZK/lease)
Polyglot consistencyOne implementation, every language calls itReimplement per language, drift risk
When to pickMany heterogeneous services, strict global propertiesDefault: fewer moving parts, no new SPOF

Clocks lie

  • Wall clock (Date.now()): tells you what time it is — and gets adjusted: NTP steps it forwards or backwards, VMs pause and jump, leap seconds happen. Two calls to Date.now() can return a smaller value the second time.
  • Monotonic clock (performance.now(), process.hrtime.bigint()): tells you how much time elapsed — guaranteed never to go backwards, but its absolute value is meaningless and incomparable across machines (or even process restarts).
  • NTP reality: ordinary servers drift ~10–200 ppm (seconds/day) and sync to within single-digit milliseconds on a good LAN — tens of ms or worse over WAN. Google/AWS "smear" leap seconds across the day rather than replaying a second.
  • Consequence — last-write-wins by timestamp silently loses data: if node A's clock is 30 ms ahead, A's earlier write can carry a later timestamp than B's subsequent write, and LWW (Cassandra-style) will discard the genuinely newer value — no error, no log line, just a lost update. Kleppmann's DDIA treats this as a headline hazard.
// WRONG: wall clock for durations — NTP can step Date.now() backwards
const t0 = Date.now();
await chargeCard(paymentIntent);
const elapsedWrong = Date.now() - t0;      // can be NEGATIVE or wildly off

// RIGHT: monotonic clock for durations — never goes backwards
const start = process.hrtime.bigint();     // ns, monotonic (Node)
await chargeCard(paymentIntent);
const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;

// Browser equivalent: performance.now() (monotonic ms since page load)
// Rule: wall clock for "when did it happen" (audit logs, certificates);
//       monotonic clock for "how long did it take" (latency, timeouts, leases).

Logical clocks: ordering without trusting time

If physical time can't order events, count causality instead. Lamport clocks capture happened-before: each process keeps a counter; increment on every local event; stamp outgoing messages; on receive, jump to max(local, received) + 1. Guarantee: if a → b (a causally precedes b), then L(a) < L(b).

/** Lamport clock: total-orderable timestamps that respect causality. */
class LamportClock {
  private time = 0;

  /** Rule 1: tick before any local event (write, state change). */
  tick(): number { return ++this.time; }

  /** Rule 2: stamp every outgoing message with the post-tick value. */
  stampSend(): number { return this.tick(); }

  /** Rule 3: on receive, jump past the sender then tick. */
  onReceive(remoteTime: number): number {
    this.time = Math.max(this.time, remoteTime) + 1;
    return this.time;
  }

  now(): number { return this.time; }
}

// Tie-break with node id for a TOTAL order (used by distributed locks,
// e.g. Lamport's mutual exclusion): compare [time, nodeId] tuples.
type LamportStamp = readonly [time: number, nodeId: string];
const before = (a: LamportStamp, b: LamportStamp): boolean =>
  a[0] !== b[0] ? a[0] < b[0] : a[1] < b[1];

The limitation: the converse doesn't hold. L(a) < L(b) does not mean a happened before b — they may be concurrent (neither could have known about the other). Lamport clocks can order events but cannot detect conflicts. For that you need vector clocks.

Lamport timestamps: arrows are messages; C=2 and B's 2 are CONCURRENT (no path between them) P1 P2 P3 1 a: local, L=1 2 b: send, L=2 3 d: receive, L=max(1,2)+1=3 1 e: local, L=1 msg(L=2) 2 c: local, L=2 — concurrent with b (same L!) 4 msg(L=3) f: receive, L=max(2,3)+1=4

Vector clocks: detecting concurrency

Keep one counter per node. Now comparison yields three outcomes: before, after, or concurrent — and concurrent means a real conflict that needs resolution (merge, CRDT, or ask the user). This is the Dynamo shopping-cart design: two clients modify the cart against different replicas during a partition; on read, the replicas return sibling versions whose vector clocks are concurrent, and the app merges (union of items) rather than silently dropping one.

type NodeId = string;
type Ordering = 'before' | 'after' | 'concurrent' | 'equal';

/** Vector clock: per-node counters; detects concurrency, not just order. */
class VectorClock {
  constructor(private readonly clock: Map<NodeId, number> = new Map()) {}

  /** Local event on `node`: bump that node's slot. */
  increment(node: NodeId): VectorClock {
    const next = new Map(this.clock);
    next.set(node, (next.get(node) ?? 0) + 1);
    return new VectorClock(next);
  }

  /** On receive: element-wise max, then bump the receiver's slot. */
  merge(other: VectorClock, receiver: NodeId): VectorClock {
    const next = new Map(this.clock);
    for (const [node, t] of other.clock) {
      next.set(node, Math.max(next.get(node) ?? 0, t));
    }
    next.set(receiver, (next.get(receiver) ?? 0) + 1);
    return new VectorClock(next);
  }

  /** before: every entry ≤ other's, at least one strictly <.
   *  concurrent: each side is ahead somewhere → true conflict. */
  compare(other: VectorClock): Ordering {
    let selfAhead = false, otherAhead = false;
    const nodes = new Set([...this.clock.keys(), ...other.clock.keys()]);
    for (const node of nodes) {
      const a = this.clock.get(node) ?? 0;
      const b = other.clock.get(node) ?? 0;
      if (a > b) selfAhead = true;
      if (b > a) otherAhead = true;
    }
    if (selfAhead && otherAhead) return 'concurrent';
    if (selfAhead) return 'after';
    if (otherAhead) return 'before';
    return 'equal';
  }
}

// Dynamo cart: two writes during a partition
let cartA = new VectorClock().increment('replicaA');   // {A:1} add "book"
let cartB = new VectorClock().increment('replicaB');   // {B:1} add "pen"
cartA.compare(cartB);   // 'concurrent' → return BOTH siblings; app merges
  • Version vectors vs vector clocks, one line: version vectors are the same structure but bumped only on writes to the object (not every event) — the right tool for replica conflict detection; vector clocks track causality of all events.
  • Cost: O(nodes) per stamp; Dynamo prunes old entries, Riak popularized "dotted version vectors" to bound growth.

Hybrid logical clocks and TrueTime

HLC combines a physical timestamp with a logical counter: timestamps stay close to wall time (readable, indexable, bounded drift from NTP time) while updating with Lamport's max-rule so causality is never violated even when physical clocks disagree — best of both. CockroachDB uses HLCs for transaction ordering, and MongoDB's causal-consistency sessions ride on a cluster-wide HLC (its ClusterTime). Google TrueTime takes the hardware route: GPS + atomic clocks in every datacenter expose time as a bounded interval [earliest, latest] (typically < 7 ms wide). Spanner commits a transaction, then waits out the uncertainty ("commit wait") before making it visible — so timestamp order provably matches real-time order, giving external consistency (strict serializability) across the planet. The lesson to quote: you can't remove clock uncertainty, but if you can bound it, you can wait it out.

Choosing an ID scheme

SchemeSizeSortableCoordinationOpaqueWatch out for
DB auto-increment64-bitStrictlyEvery insertNo — leaks volumeSPOF; breaks across shards
UUID v4128-bitNoNoneYesB-tree fragmentation as PK
UUID v7 / ULID128-bitYes (ms)NoneLeaks creation timeHot tail on range-partitioned stores
Snowflake64-bitYes (k-sorted)Machine-id assignment onlyLeaks time + rough volumeClock regression; 10-bit id exhaustion
Ticket server64-bitRoughlyRTT per ID (batchable)NoThroughput ceiling; ops burden
AWS mapping
  • DynamoDB: doesn't need your IDs ordered — partition keys are hashed, so UUIDv4 is fine there (and time-ordered keys can hot-spot a partition if they're the partition key). Your relational B-tree PKs (RDS/Aurora MySQL/Postgres) are where v7/ULID/Snowflake pay off.
  • Kinesis: assigns per-shard, monotonically increasing sequence numbers — ordering exists only within a shard/partition key, a recurring theme (same as Kafka offsets).
  • Libraries: KSUID (Segment) and ULID have solid implementations in every mainstream language; Postgres ≥ 18 has native uuidv7(), earlier versions via extension.
  • Time sync: Amazon Time Sync Service (chrony, leap-smeared) keeps EC2 clocks tight — microsecond-accurate hardware clocks on supported Nitro instances — but it's still NTP-class trust: good enough for HLC physical components and TTL-ish logic, not for correctness-by-timestamp.
  • Build custom when: you need Snowflake-style 64-bit ids across many services — run the generator in-process with machine ids leased from DynamoDB (conditional writes) or SSM Parameter Store.
Pitfalls
  • UUIDv4 as a clustered MySQL PK: random inserts fragment the B-tree, trash the buffer pool, and inflate secondary indexes (which embed the PK). Use v7/ULID/Snowflake, or keep an internal auto-inc PK and expose the UUID as a secondary unique key.
  • No clock-regression guard in Snowflake generators: NTP steps backwards → the same (timestamp, sequence) can be minted twice → duplicate "unique" IDs. Guard it (as above), and run generators with slew-only NTP configs.
  • Ordering by created_at across services: two services' wall clocks disagree by more than your event spacing; use per-partition sequence numbers or logical clocks for ordering, wall time for display.
  • LWW as the default conflict strategy: convenient and silently lossy under clock skew. In fintech, a lost write is a lost ledger entry — prefer conditional writes, version checks, or CRDTs.
  • Measuring latency with Date.now(): negative or garbage durations under NTP steps; monotonic clocks only.
  • Exposing sequential IDs publicly: enumeration attacks (walk /invoices/1001..) and volume leaks. Public identifiers should be random or at least unguessable, whatever the internal PK is.
  • Assuming Lamport order implies causality: L(a) < L(b) proves nothing about b depending on a; only the contrapositive is safe.
Production best practice
  • Default stack: UUIDv7/ULID for general entities, Snowflake where 64-bit and high mint rates matter (events, messages), random tokens for public-facing handles.
  • Make machine-id assignment boring and audited: a DynamoDB/ZooKeeper lease with TTL beats hand-edited config that duplicates ids after a copy-paste.
  • Stamp every event with both wall time (human debugging) and a causal marker (sequence number per partition, or HLC) — order by the latter.
  • Alert on clock health: NTP offset > a few ms on any host is an incident-in-waiting for lease/timeout logic. AWS Time Sync + chrony, slew not step, where you control it.
  • For cross-service business ordering (payment authorized → captured → settled), don't order by time at all — model it as an explicit state machine with versioned transitions (conditional writes).
What interviewers probe
  • Q: Design a distributed ID generator. A: Clarify requirements first: 64 vs 128 bits, sortable?, rate (say 100k IDs/s), how many generators. Then Snowflake: 41/10/12 layout, per-node locality, ~4M/s/node ceiling, machine-id assignment via leases, clock-regression guard, and the failure modes (NTP step, id exhaustion at >1024 nodes → steal bits or shard the scheme). Offer UUIDv7 as the zero-infrastructure alternative when 128 bits is acceptable.
  • Q: Why not UUIDv4 as a MySQL primary key? A: InnoDB clusters rows by PK; random keys insert at random leaf pages → constant page splits, cold-cache writes, fragmented table, and every secondary index carries the fat random PK. UUIDv7/ULID restore append-locality; cite the 48-bit ms prefix.
  • Q: How do you order events across services without synchronized clocks? A: Don't trust wall time. Options in increasing power: per-partition sequence numbers (Kafka/Kinesis — ordering within a key is usually all the business needs); Lamport clocks for a causality-respecting total order; vector clocks when you must detect concurrent conflicting updates; HLC when you want causality plus human-readable near-wall-time stamps. Pick per requirement, and say why LWW-by-timestamp loses data.
  • Q: Lamport vs vector clocks in one breath? A: Lamport: one counter, guarantees a→b ⇒ L(a)<L(b), cannot distinguish concurrency from order. Vector: one counter per node, compare gives before/after/concurrent — conflict detection at O(nodes) cost. Dynamo's cart siblings are the canonical example.
  • Q: What's special about Spanner/TrueTime? A: GPS+atomic clocks bound uncertainty to a small interval; Spanner commit-waits out that interval so timestamp order equals real-time order → external consistency without logical-clock bookkeeping. The generalizable idea: bounded uncertainty can be waited out; unbounded uncertainty can't.
  • Q: Wall vs monotonic clock — when does it bite? A: Timeouts, leases, and latency measured with Date.now() misfire when NTP steps; leases that "expire early" on one node cause split-brain. Durations and deadlines: monotonic. Timestamps for humans and audit: wall.
  • Q: Are your IDs a security surface? A: Yes — sequential ids enable enumeration and volume inference (German tank problem); time-prefixed ids leak creation time. Public handles should be unguessable even if internal keys are ordered.
References
Part V · Cross-Cutting

19. Microservices & Resilience Patterns

Microservices are an organizational scaling technique that happens to involve software, and every network hop you introduce is a new way to fail. This chapter covers when to split (and when not to), how to draw service boundaries that don't leak, why call chains multiply failure probability, and the resilience toolkit — timeouts, retries, circuit breakers, bulkheads, load shedding — with real TypeScript implementations. Interviewers use this topic to separate people who've read the blog posts from people who've been paged at 3am because service C got slow.

Monolith vs modular monolith vs microservices

The honest framing: microservices trade development velocity at small scale for organizational and deployment independence at large scale. The dominant cost driver is team count, not traffic.

DimensionMonolithModular monolithMicroservices
Dev velocity (1–3 teams)Highest — one repo, one deploy, refactor across modules freelyHigh — enforced module boundaries, still one deployLowest — every cross-cutting change touches N repos, N deploys, N reviews
Dev velocity (20+ teams)Collapses — merge conflicts, shared release train, one team's bug blocks everyoneStrained — deploy is still sharedHighest — teams ship independently
DeploymentAll-or-nothing; one bad change rolls back everythingSame (single artifact)Independent per service; blast radius contained
ScalingScale the whole thing (fine until one hotspot dominates)SameScale hot services independently (payments 50x, admin UI 1x)
Data consistencyLocal ACID transactions — trivially correctLocal ACIDDistributed — sagas, outbox, eventual consistency (ch14)
DebuggingOne stack traceOne stack traceDistributed tracing required (ch20)
Operational costOne thing to runOne thing to runN deploy pipelines, N dashboards, N on-call surfaces, service mesh, discovery…
Failure modesProcess dies — obviousSamePartial failure, slow dependencies, retry storms — subtle
  • Don't start with microservices. A startup with 8 engineers and 40 services is paying Google-scale coordination tax with a garage-scale org. Start with a modular monolith; split when a boundary is proven stable and a team needs deploy independence.
  • Modular monolith is the staff-level default answer for new products: enforce module boundaries in-process (separate packages, no cross-module DB access, internal APIs), so extraction later is a mechanical move, not an archaeology dig.
  • Legitimate split triggers: a team is blocked on another team's release train; a component needs a different scaling profile, language, or compliance boundary (e.g., PCI scope isolation in fintech — put card handling in its own service and shrink the audit surface).
Which architecture? (revisit yearly — the right answer changes as the org grows) More than ~3 teams shipping to one codebase? no Modular monolith yes Boundaries stable? (domain well understood) no Stay modular; harden seams yes Willing to fund CI/CD, tracing, on-call per service? no Not ready — platform first yes Extract microservices incrementally (strangler fig)

Service boundaries: bounded contexts and own-your-data

  • Bounded contexts (DDD): split along business capabilities, not technical layers. "Payments", "Ledger", "KYC", "Notifications" — not "API layer service" and "DB layer service". A good boundary is one where the vocabulary changes: "account" means different things to Ledger and to KYC, and that's fine because they're separate contexts.
  • Own-your-data rule: a service's database is private. Other services get data only via its API or its published events — never by querying its tables.
  • Database-per-service, and why shared DBs couple teams: a shared schema is a shared contract with no versioning. Team A can't add a column, change a type, or reindex without coordinating with every team that reads the table. You've re-created the monolith's release train at the schema layer — with none of the monolith's transactional benefits. Shared DB also destroys independent scaling (one service's table scan starves another's OLTP) and makes ownership of data quality ambiguous.
  • Corollary: cross-service "joins" become API composition or a read model built from events (CQRS, ch14). If you find yourself needing constant cross-service joins, the boundary is wrong — merge the services.
  • Litmus test for a boundary: can this service be meaningfully described in one sentence, deployed alone, and tested with stubs of its neighbors? If a change routinely fans out to three services, you have a distributed monolith (see below).
Real-world analogy

Restaurants in a food hall, not one kitchen with many doors. Each stall (service) owns its own pantry (database), menu (API), and staff (team). You order at the counter — you don't walk into the taco stall's fridge because you saw they have limes. A shared database is every stall cooking out of one communal fridge: nobody can rearrange a shelf without a meeting, and when the ramen stall hoards the stock pot at lunch rush, everyone's service slows.

Sync vs async coupling — the availability math

Every synchronous hop makes your availability the product of the chain. Five services at 99.9% each, called in series:

  • 0.9995 ≈ 0.995 → 99.5% — you silently spent 4x your error budget (43 min/month → ~3.6 h/month of downtime) just on topology.
  • Latency compounds too: chain p99s don't add nicely — the chain's tail is dominated by the worst hop, and fan-outs make hitting some slow replica nearly certain (ch20's percentile math).
  • Slow is worse than down. A dead dependency fails fast; a slow one holds your threads, connections, and memory hostage until the whole fleet is wedged. Every resilience pattern below exists mostly to convert "slow" into "fast failure".
  • Async decoupling (queue/stream between services, ch13) breaks the availability product: the producer succeeds if the broker accepts the message, even while the consumer is down. Cost: eventual consistency and harder reasoning about "is it done yet". Rule of thumb: sync for queries the user is waiting on; async for state changes that can be acknowledged and processed later (order placed → email, ledger posting, analytics).
Synchronous chain — availabilities multiply API GW 99.9% Orders 99.9% Payments 99.9% Risk 99.9% Ledger DB 99.9% End-to-end: 0.999 x 0.999 x 0.999 x 0.999 x 0.999 = 99.5% (~3.6 h/month down vs 43 min for one service) Async decoupling — producer availability no longer depends on consumer Orders ack after enqueue publish Queue / stream durable buffer consume later Notifications can be down; queue absorbs Trade: user sees "accepted", not "completed" — eventual consistency in exchange for fault isolation

The resilience toolkit

Timeouts and deadline propagation

  • Every remote call needs a timeout. The default in most HTTP clients is infinite or minutes — that's how one slow dependency consumes your entire connection pool.
  • Budget propagation: the edge sets a total deadline (e.g., 2s); each hop passes the remaining budget downstream (gRPC does this natively; over HTTP use a header). Without it, a service 3 hops deep happily works for 5s on a request whose caller gave up 4s ago — wasted work at best, retry amplification at worst.
  • Set timeouts from the dependency's observed p99 + headroom, not from vibes. Timeout > p99.9 means you almost never cut off legitimate work; timeout ≈ p50 means you time out half your traffic.
// Deadline propagation: pass remaining budget down the call chain.
interface Deadline {
  readonly expiresAt: number;              // epoch ms
  remainingMs(): number;
  expired(): boolean;
}

function deadlineIn(totalBudgetMs: number): Deadline {
  const expiresAt = Date.now() + totalBudgetMs;
  return {
    expiresAt,
    remainingMs: () => Math.max(0, expiresAt - Date.now()),
    expired:     () => Date.now() >= expiresAt,
  };
}

async function callWithDeadline<T>(
  url: string,
  deadline: Deadline,
  minUsefulMs = 25,                        // not worth dialing if less remains
): Promise<T> {
  const budget = deadline.remainingMs();
  if (budget < minUsefulMs) throw new Error('DeadlineExceeded: no budget left');

  const res = await fetch(url, {
    signal: AbortSignal.timeout(budget),   // hard cap on this hop
    headers: { 'x-request-deadline-ms': String(budget) }, // downstream reads
  });
  if (!res.ok) throw new Error(`Upstream ${res.status}`);
  return res.json() as Promise<T>;
}

// Edge handler: one budget governs the whole request tree.
// const dl = deadlineIn(2_000);
// const risk = await callWithDeadline<RiskScore>(riskUrl, dl);
// const quote = await callWithDeadline<Quote>(quoteUrl, dl); // gets what's left

Retries done right

  • Only retry idempotent operations (GET, PUT with idempotency keys — ch17). Retrying a non-idempotent POST after an ambiguous timeout is how customers get charged twice.
  • Exponential backoff + full jitter: without jitter, all clients that failed together retry together — synchronized waves that re-kill the recovering server. Full jitter (AWS's recommendation): sleep random(0, min(cap, base·2^attempt)).
  • Retry budgets, not just per-call caps: cap retries as a fraction of total traffic (e.g., retries ≤ 10% of requests). Per-call "3 retries" through a 3-deep chain is 3×3×3 = 27x amplification at the bottom — a self-inflicted DDoS called a retry storm. Retry at one layer (usually the closest to the user), not every layer.
  • Only retry retryable errors: timeouts, 503, connection reset. Never retry 400/422 — the request is wrong and will be wrong again.
// Exponential backoff + full jitter + a fleet-level retry budget.
class RetryBudget {
  private tokens: number;
  constructor(private readonly max: number, private readonly refillPerSec: number) {
    this.tokens = max;
    setInterval(() => { this.tokens = Math.min(this.max, this.tokens + refillPerSec); }, 1_000);
  }
  tryAcquire(): boolean {
    if (this.tokens < 1) return false;     // budget exhausted: fail, don't storm
    this.tokens -= 1;
    return true;
  }
}

const budget = new RetryBudget(100, 10);   // ~10 retries/sec fleet-wide per client

async function withRetries<T>(
  op: () => Promise<T>,
  isRetryable: (e: unknown) => boolean,
  { attempts = 3, baseMs = 100, capMs = 2_000 } = {},
): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    try {
      return await op();
    } catch (err) {
      const lastTry = attempt >= attempts - 1;
      if (lastTry || !isRetryable(err) || !budget.tryAcquire()) throw err;
      const ceiling = Math.min(capMs, baseMs * 2 ** attempt);
      const sleepMs = Math.random() * ceiling;          // FULL jitter
      await new Promise((r) => setTimeout(r, sleepMs));
    }
  }
}

Circuit breaker

When a dependency is failing, stop calling it. Fail fast locally, give it room to recover, and probe cautiously before resuming. Three states: closed (normal, counting failures) → open (all calls rejected instantly for a cool-down) → half-open (let a few probes through; success closes, failure re-opens).

Real-world analogy

The electrical breaker in your house. When a short circuit draws dangerous current, the breaker trips — cutting power to that circuit so the fault can't burn the whole house down. You don't keep flipping it back every second; you wait, then reset it once and watch (half-open). If it trips again, the fault is still there. A software circuit breaker does the same: it sacrifices one feature's availability to protect the caller's thread pools, latency, and everything else the process serves.

// Circuit breaker with typed states and half-open probe logic.
type BreakerState =
  | { kind: 'closed'; consecutiveFailures: number }
  | { kind: 'open'; openedAt: number }
  | { kind: 'half-open'; probesInFlight: number; probeSuccesses: number };

interface BreakerOptions {
  failureThreshold: number;   // consecutive failures to trip (e.g. 5)
  openMs: number;             // cool-down before probing (e.g. 30_000)
  probeCount: number;         // successes needed to close (e.g. 3)
  maxProbes: number;          // concurrent probes allowed in half-open
}

class CircuitOpenError extends Error {
  constructor(public readonly retryAfterMs: number) { super('circuit open'); }
}

class CircuitBreaker {
  private state: BreakerState = { kind: 'closed', consecutiveFailures: 0 };
  constructor(private readonly opts: BreakerOptions) {}

  async exec<T>(op: () => Promise<T>): Promise<T> {
    const s = this.state;
    if (s.kind === 'open') {
      const elapsed = Date.now() - s.openedAt;
      if (elapsed < this.opts.openMs) {
        throw new CircuitOpenError(this.opts.openMs - elapsed);  // fail FAST
      }
      this.state = { kind: 'half-open', probesInFlight: 0, probeSuccesses: 0 };
    }
    if (this.state.kind === 'half-open') {
      if (this.state.probesInFlight >= this.opts.maxProbes) {
        throw new CircuitOpenError(0);       // only a trickle of probes
      }
      this.state.probesInFlight++;
    }
    try {
      const result = await op();
      this.onSuccess();
      return result;
    } catch (err) {
      this.onFailure();
      throw err;
    }
  }

  private onSuccess(): void {
    if (this.state.kind === 'half-open') {
      this.state.probeSuccesses++;
      if (this.state.probeSuccesses >= this.opts.probeCount) {
        this.state = { kind: 'closed', consecutiveFailures: 0 };  // recovered
      } else {
        this.state.probesInFlight--;
      }
    } else {
      this.state = { kind: 'closed', consecutiveFailures: 0 };
    }
  }

  private onFailure(): void {
    if (this.state.kind === 'half-open') {
      this.state = { kind: 'open', openedAt: Date.now() };        // re-trip
    } else if (this.state.kind === 'closed') {
      this.state.consecutiveFailures++;
      if (this.state.consecutiveFailures >= this.opts.failureThreshold) {
        this.state = { kind: 'open', openedAt: Date.now() };      // trip
      }
    }
  }
}
// Production nuance: count failures over a sliding window with a rate
// threshold (e.g. >50% of last 20 calls) instead of consecutive count,
// and treat timeouts as failures — slow IS failing.
CLOSED calls flow; count failures OPEN reject instantly (fail fast) HALF-OPEN allow N probe calls failures ≥ threshold cool-down elapsed probe fails → re-open N probes succeed → close (recovered) success resets counter

Bulkheads

  • Isolate resource pools per dependency — separate connection pools, thread pools, or concurrency limits for each downstream. A slow recommendations service can then exhaust its 20 permits, while payments' pool stays untouched.
  • Without bulkheads, one slow dependency + one shared pool = every request handler blocked waiting on the same wedge, and your service is down even though 90% of its dependencies are healthy.
  • Also applies at infra level: separate ASGs/target groups for critical vs bulk traffic, per-tenant quotas, dedicated DB replicas for analytics.
Real-world analogy

Ship compartments. A hull breach floods one watertight compartment; the bulkhead walls stop the water there and the ship stays afloat. No bulkheads and one hole sinks everything. (The Titanic's bulkheads famously didn't go high enough — water spilled over the top. The software equivalent: a "per-dependency" pool that secretly shares the same event loop, DNS resolver, or global agent.)

// Bulkhead: a semaphore capping concurrent calls per dependency.
class Bulkhead {
  private inFlight = 0;
  private readonly waiters: Array<() => void> = [];
  constructor(
    private readonly maxConcurrent: number,  // e.g. 20 for recs, 100 for payments
    private readonly maxQueue: number,       // bounded! unbounded queues just hide the wedge
  ) {}

  async run<T>(op: () => Promise<T>): Promise<T> {
    if (this.inFlight >= this.maxConcurrent) {
      if (this.waiters.length >= this.maxQueue) {
        throw new Error('BulkheadRejected: dependency saturated'); // shed, don't queue forever
      }
      await new Promise<void>((resolve) => this.waiters.push(resolve));
    }
    this.inFlight++;
    try {
      return await op();
    } finally {
      this.inFlight--;
      this.waiters.shift()?.();              // admit next waiter, if any
    }
  }
}

// One bulkhead per downstream — pools must not be shared.
const pools = {
  payments: new Bulkhead(100, 50),
  recs:     new Bulkhead(20, 10),            // slow recs can only wedge 20 slots
};
// await pools.recs.run(() => fetchRecommendations(userId));

Fallbacks & graceful degradation

  • When the breaker is open or the bulkhead rejects, have a plan B: serve stale cache (last known FX rates with a "as of 10:02" stamp), defaults (generic top-10 instead of personalized recommendations), or degraded UX (hide the widget entirely).
  • Rank features by criticality: checkout must work; the "customers also bought" rail is sacrificial. Wire breakers so sacrificial features fail silently and critical paths fail loudly.
  • Fintech caution: never fall back on correctness-critical data — a stale risk score or balance is worse than an error. Fallbacks are for optional enrichments, not for the ledger.

Load shedding & admission control

  • Under overload, serving some requests well beats serving all requests badly (everyone times out, throughput collapses to zero — congestion collapse). Reject excess early with 429/503 + Retry-After.
  • Shed by priority: drop crawler/analytics traffic first, interactive user traffic last; per-customer quotas stop one tenant from starving the rest (rate limiting mechanics in ch16).
  • Good signals for "I'm overloaded": queue depth, event-loop lag, p99 latency vs SLO — not CPU alone.
  • Admission control at the front door (API gateway / LB) is worth 10 resilience patterns inside: work rejected in 1ms costs nothing downstream.

Service discovery

Instances come and go (autoscaling, deploys, failures); callers need a live answer to "where is payments right now?".

ApproachHow it worksProsConsExamples
Server-side (LB in path)Client calls a stable VIP/DNS name; the load balancer routes to healthy instancesDumb clients, central health checks, easy TLS terminationExtra hop, LB is infra to run/scale, per-service LB costALB/NLB per service, Kubernetes Service/kube-proxy
Client-side (registry)Instances register in a registry; clients fetch the instance list and balance locallyNo extra hop, smarter per-client policies (EWMA, zone-aware)Fat clients in every language, stale-cache bugs, registry is critical infraEureka + Ribbon, Consul, gRPC client LB
DNS-basedRegistry publishes A/SRV records; clients resolve namesUniversal — everything speaks DNS; zero client codeTTL caching means slow convergence; clients that cache forever (older JVMs) route to dead IPsAWS Cloud Map DNS mode, Kubernetes headless services
Mesh (sidecar)Sidecar proxy on every pod does discovery + LB; app calls localhostLanguage-agnostic client-side LB, uniform policyOperational complexity, resource overhead per podEnvoy/Istio, AWS App Mesh, Linkerd

Service mesh

  • Sidecar model: an Envoy proxy runs next to every service instance; all inbound/outbound traffic flows through it. A control plane (Istio, App Mesh) pushes config to all sidecars.
  • What you get without touching app code: mTLS everywhere (identity + encryption between services — big for PCI/SOC2), retries/timeouts/circuit breaking as config, traffic shifting (1% canary to v2), and uniform metrics/traces for every hop.
  • The pitch in one line: the resilience toolkit above, implemented once in infrastructure instead of N times in N languages.
  • When it's overkill: < ~10–15 services, single language (a shared library is cheaper), or a team with no platform capacity — a mesh is a distributed system you now operate underneath your distributed system. Sidecar-less alternatives (VPC Lattice, Cilium/eBPF, Istio ambient mode) reduce the per-pod tax.

Strangler-fig migration

Never rewrite big-bang. Put a router in front of the monolith and peel routes off one at a time: new service takes /payments/*, everything else still hits the monolith. Each slice ships independently, is reversible with a route flip, and shrinks the monolith until it "withers".

  • Order of extraction: start with a leaf capability (few inbound deps), high change-rate (most benefit), clean data ownership. Not the gnarliest core module.
  • Data comes with the code: give the new service its own store; sync from the monolith DB during transition (dual-write with reconciliation, or CDC via outbox — ch14), then cut over reads, then writes, then delete the old tables.
  • Keep a comparison window: shadow-read both paths and diff results before trusting the new service (especially for money paths).
Real-world analogy

The strangler fig tree germinates in the canopy of a host tree, sends roots down around the trunk, and gradually envelops it until the fig stands on its own and the host decomposes inside. The router is the canopy; each extracted service is another root reaching the ground; the monolith is the host — still structurally load-bearing until, one day, it isn't.

Clients unchanged URLs Routing facade API GW / ALB rules route-by-path /payments/* (step 1) Payments svc own DB /kyc/* (step 2) KYC svc own DB everything else Monolith shrinking over time CDC sync during cutover Each peeled route is independently shippable and reversible with a route flip — no big-bang rewrite

The distributed monolith anti-pattern

  • All the operational cost of microservices, none of the independence: services that must be deployed together (lockstep releases), share a database, chat over chains of synchronous calls, and share fragile internal libraries with breaking changes.
  • Smells: a "release train" spreadsheet coordinating 12 services; integration environments where nothing works unless everything is on matching versions; one team's schema migration breaking three other teams.
  • Root cause is almost always boundaries drawn along technical layers or org-chart lines instead of business capabilities — the split happened before the domain was understood.
  • The fix is usually to merge services back, not add more. Fewer, better-bounded services beat many entangled ones.
Pitfalls
  • Retrying at every layer: 3 retries × 3 layers = 27x amplification at the bottom of the stack. Retry once, near the edge, with a budget.
  • No timeout, or timeout > caller's timeout: downstream keeps working after the caller has given up and retried — doubling load exactly when the system is struggling.
  • Circuit breaker on error count only: a dependency answering 200 OK in 30 seconds never trips it. Count timeouts and latency-SLO violations as failures.
  • Shared database "just for now": the coupling outlives everyone who said "just for now". It quietly forbids independent schema changes, scaling, and deploys.
  • Splitting by technical layer ("frontend-api service", "db-access service"): every feature change now touches every service — the distributed monolith starter kit.
  • Unbounded queues as bulkheads: a 10,000-deep wait queue doesn't protect anything; it converts fast failure into slow failure plus memory pressure. Bound the queue and shed.
  • Fallbacks that hide outages: serving stale defaults silently for days because nobody alerted on breaker-open state. Every fallback activation must emit a metric.
Production best practice
  • Standardize the toolkit in one place — a shared client library or the mesh — so every remote call gets timeout + retry budget + breaker + bulkhead by default. Opt-out, not opt-in.
  • Make resilience state observable: breaker state transitions, bulkhead rejections, retry-budget exhaustion, and fallback activations are all first-class metrics with alerts.
  • Test failure, don't hope: chaos experiments (kill a dependency, add 2s latency — latency injection finds more bugs than kill injection) in staging and eventually in prod, game days for the humans.
  • One retry-owning layer per call path; document it. Everything below passes errors up fast.
  • Deploying many services safely: progressive delivery — canary 1% → 10% → 100% gated on SLO metrics, automatic rollback on error-budget burn; never "deploy all 100 at once".
AWS mapping
  • Compute: ECS/Fargate (simplest container orchestration, per-service services + target groups), EKS (Kubernetes when you need its ecosystem or run multi-cloud), Lambda (per-function "nanoservices" — great for spiky/event-driven, watch cold starts and 15-min cap).
  • Discovery: AWS Cloud Map (DNS or API-based registry, integrates with ECS service discovery); ALB per service (server-side discovery) — the boring default; NLB for gRPC/raw TCP.
  • Mesh: App Mesh (Envoy sidecars, mTLS/retries/canaries; note AWS announced end-of-support — migrate) and VPC Lattice — the current AWS answer: mesh-like service-to-service auth, routing, and observability without sidecars, across VPCs/accounts.
  • Resilience: Route 53 health checks + failover; ALB outlier ejection via health checks; SQS between services for async decoupling; API Gateway throttling/usage plans for admission control at the edge.
  • Build vs buy: use SDK-level retries (AWS SDKs ship adaptive retry with budgets — good prior art), the mesh/Lattice for cross-cutting policy; hand-roll breakers/bulkheads only in the app when policy must consider business context (e.g., shed analytics traffic but never checkout).
What interviewers probe
  • Q: When would you split a monolith? A: When an organizational or operational constraint bites, not on principle: teams blocked on a shared release train, a component needing a different scaling/compliance/language profile (e.g., isolating PCI scope). Precondition: the boundary is stable and the data ownership is clean. I'd extract via strangler fig, leaf-first, with CDC-based data migration — never big-bang. If boundaries are still shifting, a modular monolith gets you 80% of the benefit at 20% of the cost.
  • Q: What happens when a downstream dependency gets slow — not down, slow? A: That's the worse case: every caller thread/connection blocks for the full timeout, pools drain, and the outage propagates upstream even though nothing is "down". Defenses in order: aggressive timeouts sized off p99, bulkheads so only that dependency's pool wedges, breakers that count latency violations (not just errors) to trip, and load shedding when queue depth grows. Health checks won't save you — the dependency still returns 200.
  • Q: How do you deploy 100 services safely? A: Independent pipelines with progressive delivery: canary a small slice, gate promotion on SLO metrics (error rate, p99), auto-rollback on error-budget burn. Contract tests + backward-compatible APIs (expand/contract) so no deploy requires lockstep. If services must deploy together, that's the real bug to fix.
  • Q: Why is 5 nines-of-three services worse than one service? A: Serial availability multiplies: 0.999^5 ≈ 99.5%. Every synchronous hop is an availability tax; you buy it back with async decoupling, caching, fallbacks, or fewer hops.
  • Q: Retries — what's the danger? A: Retry storms: synchronized, layered retries amplify load on an already-struggling dependency (3 layers × 3 retries = 27x). Fix: retry only idempotent ops, exponential backoff with full jitter, a retry budget as a fraction of traffic, and one owning layer.
  • Q: Circuit breaker vs bulkhead — aren't they the same? A: Breaker is temporal isolation (stop calling a bad dependency for a while); bulkhead is spatial isolation (cap how much of my capacity any dependency can consume, even while I still call it). You want both: the bulkhead limits damage before the breaker has enough signal to trip.
  • Q: Service mesh — would you adopt one? A: At > ~15 services in multiple languages with mTLS/compliance requirements, yes — policy once in infra beats N library ports. Below that, a shared client library and an ALB per service is simpler; a mesh is another distributed system to operate.
References
Part VI · Production-Grade

20. Observability — Logs, Metrics, Traces

Monitoring tells you when a known failure mode fires; observability lets you debug an unknown-unknown at 3am from telemetry alone, without shipping new code to ask the question. This chapter covers the three pillars and — more importantly — how they connect: correlation IDs stitching logs to traces, percentiles instead of averages, SLOs and error budgets as the contract between reliability and release velocity, and OpenTelemetry as the vendor-neutral default. Interviewers probe this to see whether you've operated systems, not just designed them.

The three pillars — and the real test

  • Logs: discrete, timestamped events with arbitrary detail. Answer "what exactly happened to request X?" Highest fidelity, highest cost per unit of insight.
  • Metrics: pre-aggregated numbers over time (counters, gauges, histograms). Answer "is the system healthy? how healthy? trending which way?" Cheap to store and query, cheap to alert on — but aggregation destroys the individual request.
  • Traces: the life of one request across services — a tree of timed spans. Answer "where in this 9-hop call tree did the time or the error go?"
  • The pillars are lenses on the same events, and the workflow chains them: a metric alert fires (p99 up) → an exemplar trace shows the slow span is the ledger DB call → logs for that trace ID reveal a lock-wait on a specific account. Any pillar alone leaves you stuck.
  • The staff-level test isn't "do you emit all three" — it's "can you go from symptom to root cause for a failure mode you never anticipated, using only what's already emitted?" That requires high-cardinality, correlated telemetry, not three disconnected silos.
Real-world analogy

Cockpit instruments vs looking out the window. In clear daylight (dev, low traffic) you can fly by looking outside — SSH in, read stdout, poke the process. In cloud at night (prod incident, 3am, 40 services) the window shows you nothing; you fly entirely on instruments. Metrics are the altimeter and airspeed (cheap, continuous, alarm-able), traces are the flight-path recorder for one flight, logs are the maintenance log with every detail. Pilots train for instrument flight before entering the clouds — instrumenting during the incident is too late.

Structured logging

  • JSON, one event per line. logger.info({ event: 'payment.captured', amountMinor: 4210, currency: 'INR', paymentId }) — machine-parseable, queryable by field. String-interpolated prose ("Payment 123 captured for ₹42.10") is grep-only archaeology.
  • Levels used correctly: error = broken and needs action (a human or an alert should care); warn = surprising but handled (fallback served, retry succeeded); info = business-significant state changes (order placed, payout initiated); debug = developer detail, off in prod by default. If everything is error, nothing is.
  • What to log: event name, entity IDs, outcome, duration, error class/code, and always the correlation IDs. What never to log (fintech especially): full PANs/card data (PCI-DSS scope explosion — one logged PAN makes your log pipeline in-scope for audit), CVVs (never storable, period), passwords/tokens/API keys, full PII (mask: a****@orbitxpay.com), full request bodies on money paths. Enforce with a redaction layer in the logger, not developer discipline.
  • Sampling high-volume logs: keep 100% of errors and slow requests, sample the happy path (e.g., 1%) — errors are rare and precious, successes are statistically redundant.
  • Cost control: log verbosity per environment (debug in dev, info in prod), retention tiers (hot/searchable 7–30 days → archive to S3/Glacier for compliance years), and drop-filters for known-noisy events. Log bills quietly reaching 6 figures is a rite of passage — cardinality and volume budgets are real engineering.

Correlation IDs are the keystone. Assign (or accept) a request ID at the edge, propagate it through every service hop and queue message, and auto-attach it to every log line. Node's AsyncLocalStorage makes this invisible to business code — this is the pattern interviewers want to see you actually know:

// Request context + logger that auto-attaches requestId/traceId to every line.
// AsyncLocalStorage carries the context across awaits, callbacks, and promises
// without threading a `ctx` parameter through every function signature.
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

interface RequestContext {
  requestId: string;        // ours, minted at the edge if absent
  traceId?: string;         // W3C trace ID, links logs <-> traces
  userId?: string;
  route?: string;
}

const als = new AsyncLocalStorage<RequestContext>();

type Level = 'debug' | 'info' | 'warn' | 'error';
const REDACT = /"(pan|cvv|password|token|ssn)"\s*:\s*"[^"]*"/gi;   // last line of defense

function emit(level: Level, event: string, fields: Record<string, unknown> = {}): void {
  const ctx = als.getStore();               // undefined outside a request (cron, startup)
  const line = JSON.stringify({
    ts: new Date().toISOString(),
    level,
    event,                                  // machine-stable name: 'payment.captured'
    requestId: ctx?.requestId,
    traceId: ctx?.traceId,
    userId: ctx?.userId,
    route: ctx?.route,
    ...fields,
  });
  process.stdout.write(line.replace(REDACT, '"$1":"[REDACTED]"') + '\n');
}

export const log = {
  debug: (e: string, f?: Record<string, unknown>) => emit('debug', e, f),
  info:  (e: string, f?: Record<string, unknown>) => emit('info', e, f),
  warn:  (e: string, f?: Record<string, unknown>) => emit('warn', e, f),
  error: (e: string, f?: Record<string, unknown>) => emit('error', e, f),
};

// Express middleware: open a context per request; everything downstream inherits it.
export function requestContext(req: Req, res: Res, next: Next): void {
  const ctx: RequestContext = {
    requestId: req.header('x-request-id') ?? randomUUID(),  // accept or mint
    traceId: parseTraceparent(req.header('traceparent'))?.traceId,
    route: req.path,
  };
  res.setHeader('x-request-id', ctx.requestId);   // echo so clients can report it
  als.run(ctx, next);                             // scope ctx to this request's async tree
}

// Outbound calls forward the IDs — the chain must not break at hop 2:
//   fetch(url, { headers: { 'x-request-id': als.getStore()!.requestId, traceparent } })
// Business code stays clean: log.info('payout.initiated', { payoutId, amountMinor });
// ...and every line carries requestId/traceId automatically.

Metrics: RED, USE, and why averages lie

  • Instrument types: counter (monotonic: requests_total, errors_total — rates come from deltas), gauge (point-in-time: queue depth, in-flight requests, memory), histogram (distribution in buckets: request duration — the only honest way to get percentiles).
  • RED method — for every service: Rate (req/s), Errors (failed req/s), Duration (latency distribution). If you dashboard nothing else per service, dashboard RED.
  • USE method — for every resource (CPU, disk, pool, queue): Utilization (busy %), Saturation (queued/waiting work — the leading indicator), Errors. RED is the customer's view; USE is the machine's view. Saturation rises before utilization pegs — watch it.
  • Averages lie. Mean latency blends a fast majority with a catastrophic tail and reports "fine". At 1M requests/day, p99 = 10,000 requests/day having a terrible time — and heavy users (your best customers) hit more requests, so they hit the tail more often.
StatisticCheckout latency (example)What it tells you
mean92 msAlmost nothing — distorted by both the fast bulk and the slow tail
p50 (median)60 msThe typical request; what demos feel like
p95210 ms1 in 20 requests is at least this slow — a heavy user hits this daily
p991,400 msThe tail: lock waits, GC pauses, cold caches, that one slow shard
p99.94,800 msTimeout/retry territory; where cascading failures are born
  • Tail latency amplification: fan out to N services in parallel and the response waits for the slowest. P(all N respond fast) = 0.99N if each is fast with probability 0.99 — for N = 10, only 90% of user requests avoid every p99; for N = 100, just 37%. Your users experience your dependencies' p99 at your p50. This is why Google's "Tail at Scale" strategies exist: hedged requests, tied requests, cutting off slow replicas.
  • Percentiles don't average: the mean of five servers' p99s is meaningless. Aggregate histograms, then compute the percentile.
// RED metrics with a histogram — typed thin wrapper over prom-client.
import { Counter, Histogram, register } from 'prom-client';

const httpRequests = new Counter({
  name: 'http_requests_total',
  help: 'Requests by route/method/status',
  labelNames: ['route', 'method', 'status'] as const,   // low cardinality ONLY —
});                                                     // never userId/requestId as labels

const httpDuration = new Histogram({
  name: 'http_request_duration_seconds',
  help: 'Latency distribution',
  labelNames: ['route', 'method'] as const,
  // Buckets shaped around the SLO (300ms) so burn is readable straight off the chart:
  buckets: [0.025, 0.05, 0.1, 0.2, 0.3, 0.5, 1, 2, 5],
});

export function redMetrics(req: Req, res: Res, next: Next): void {
  const stop = httpDuration.startTimer({ route: req.route?.path ?? 'unmatched', method: req.method });
  res.on('finish', () => {
    stop();
    httpRequests.inc({ route: req.route?.path ?? 'unmatched', method: req.method,
                       status: String(res.statusCode) });
  });
  next();
}
// GET /metrics -> register.metrics() for Prometheus/ADOT scrape.
// p99 in PromQL: histogram_quantile(0.99,
//   sum by (le, route) (rate(http_request_duration_seconds_bucket[5m])))

SLI, SLO, SLA — and error budgets

  • SLI (indicator): a measured ratio of good events / total events. "Proportion of checkout requests under 300ms returning non-5xx."
  • SLO (objective): the internal target on that SLI over a window. "99.9% over 30 days." Set by engineering + product; drives alerts and priorities.
  • SLA (agreement): the external, contractual promise — looser than the SLO, with money attached (service credits). You breach the SLO before the SLA, on purpose: the SLO is the tripwire.
  • Error budget = 1 − SLO: the failure you're allowed. 99.9% monthly = 43.8 minutes of full downtime (or the equivalent in partial failure). The budget is a shared currency: deploys, migrations, and chaos experiments all spend it.
  • Budgets gate release velocity: budget healthy → ship fast, take risks; budget burned → feature freeze, reliability work only. This turns "dev wants to ship vs ops wants stability" from a culture war into arithmetic. It also caps over-engineering: if you're under-spending the budget, you're probably shipping too slowly.
SLO (monthly)Error budget / 30 daysPractical meaning
99%7.3 hoursInternal tools; batch systems with catch-up capacity
99.9%43.8 minutesStandard for user-facing services; one bad deploy can eat a month
99.95%21.9 minutesPayments-grade; requires multi-AZ, canaries, fast rollback
99.99%4.4 minutesNo human in the loop can respond in time — recovery must be automated
99.999%26 secondsReserved for infrastructure primitives; each nine roughly 10x the cost

Distributed tracing

  • Model: a trace = one request end-to-end; a span = one timed operation (an HTTP handler, a DB query, a queue publish) with attributes, status, and a parent span — forming a tree whose waterfall shows exactly where time went.
  • Context propagation — W3C traceparent: 00-<trace-id 16 bytes>-<parent-span-id 8 bytes>-<flags>, e.g. 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01. Every outbound call forwards it (HTTP headers, message attributes on SQS/Kafka); the receiver continues the same trace. One service dropping the header cuts the trace in half — propagation discipline is the whole game.
  • Sampling: tracing everything at scale is unaffordable. Head sampling decides at trace start (cheap, simple — e.g. keep 1%) but is blind: it discards errors and slow traces at the same rate as boring ones. Tail sampling buffers spans and decides at trace end ("keep all errors, all >1s, 1% of the rest") — exactly the traces you want, at the cost of a stateful collector tier. Staff answer: head-sample at a base rate + tail-sample errors/slow traces in the collector.
  • OpenTelemetry (OTel) is the vendor-neutral standard for all three signals: instrument once, export anywhere (X-Ray, Jaeger, Datadog, Honeycomb) by swapping the exporter. Auto-instrumentation patches HTTP/Express/pg/redis clients so you get spans and propagation with near-zero code; manual spans add business context.
// OpenTelemetry: typed manual span around an outbound call.
// Auto-instrumentation (@opentelemetry/auto-instrumentations-node, loaded via
// --require before the app) already creates spans for inbound HTTP, Express,
// pg, redis, and injects/extracts `traceparent` on fetch — manual spans are
// for business operations you want visible in the waterfall by name.
import { trace, SpanStatusCode, SpanKind, type Span } from '@opentelemetry/api';

const tracer = trace.getTracer('payments-service', '1.4.0');

interface CaptureResult { captureId: string; status: 'captured' | 'declined'; }

export async function capturePayment(paymentId: string, amountMinor: number): Promise<CaptureResult> {
  // startActiveSpan makes this span the parent of everything awaited inside —
  // the PSP HTTP call below auto-nests under it in the waterfall.
  return tracer.startActiveSpan(
    'payments.capture',
    { kind: SpanKind.CLIENT, attributes: {
        'payment.id': paymentId,               // IDs are fine as span attributes
        'payment.amount_minor': amountMinor,   // (high cardinality OK here,
        'payment.currency': 'INR',             //  unlike metric labels)
    }},
    async (span: Span): Promise<CaptureResult> => {
      try {
        const res = await pspClient.capture(paymentId, amountMinor); // traceparent auto-injected
        span.setAttribute('psp.response_code', res.code);
        if (res.status === 'declined') {
          span.addEvent('psp.declined', { 'decline.reason': res.reason }); // event, not error
        }
        span.setStatus({ code: SpanStatusCode.OK });
        return res;
      } catch (err) {
        span.recordException(err as Error);
        span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
        throw err;                              // observe, never swallow
      } finally {
        span.end();                             // unended spans never export
      }
    },
  );
}
Trace 4bf92f35… — POST /checkout — 460 ms total (spans as bars, time →) 0 ms 460 ms api-gateway POST /checkout — 460 ms orders-svc createOrder — 430 ms payments-svc payments.capture — 330 ms ← the fat span psp (external) POST psp/capture — 275 ms inventory-svc reserve — 45 ms 1 gap after PSP return = time lost in payments-svc

Alerting philosophy, dashboards, runbooks

  • Alert on symptoms (SLOs), not causes. Page on "checkout SLI burning error budget 14x too fast", not on "CPU > 80%". Causes without user impact are noise; user impact without a matching cause-alert still deserves a page. Burn-rate alerts (fast-burn: page; slow-burn: ticket) are the SRE-book pattern.
  • Page vs ticket: a page means "a human must act now or users suffer"; everything else is a ticket for business hours. Every page must be actionable and urgent — if the responder's move is "shrug, it self-healed", delete or demote the alert.
  • Alert fatigue is a reliability risk: a team paged 20x/night stops reading pages, and the real one dies in the noise. Review every page weekly; each is either actioned, tuned, or deleted.
  • Dashboards that matter: one per-service RED dashboard, same layout everywhere — top row: request rate, error rate (with SLO line), p50/p95/p99 duration; second row: saturation (pool usage, queue depth, event-loop lag); third row: dependency view (RED of each downstream this service calls). Uniformity is the feature — at 3am nobody wants to learn a bespoke dashboard.
  • Runbooks: every page links to a runbook — symptom, triage queries (pre-built log/trace links), remediation, escalation. Written before the incident; full incident-response and postmortem practice in ch21.
The three pillars — same events, three lenses Metrics aggregate health, cheap alerts Traces where in the call tree Logs exact detail, why 1 SLO burn alert: p99 spike Which SLI, since when, which route? 2 exemplar trace Fat span: ledger DB call is 90% of latency 3 filter logs by traceId Root cause: lock wait on hot account row Metric says "something is wrong" → trace says "where" → logs say "why". Correlation IDs make the handoffs one click.
Pitfalls
  • High-cardinality metric labels: userId/requestId as a Prometheus label = one time series per user = cardinality explosion that OOMs the TSDB. IDs belong in logs and span attributes, never metric labels.
  • Averaging percentiles: mean(p99 of each host) is statistically meaningless. Merge histograms first, then take the quantile.
  • Broken propagation: one service (or one queue hop) dropping traceparent/x-request-id orphans everything downstream. Verify propagation in integration tests, including through SQS/Kafka message attributes.
  • Logging PII/secrets: a PAN in logs drags the whole log pipeline into PCI scope; GDPR deletion requests against immutable log archives are a nightmare. Redact at the logger, audit with scanners.
  • Alerting on causes: "CPU > 80%" pages at 3am while users are fine; meanwhile a 100% error rate on one route never pages because CPU is idle.
  • Head-sampling away your incidents: keep-1% head sampling keeps 1% of your errors too. Tail-sample errors and slow traces or you'll have traces for everything except the outage.
  • Unbounded log spend: debug-level in prod plus 400-day hot retention. Set level per env, tier retention, and put a cost dashboard on telemetry itself.
Production best practice
  • Instrument via OpenTelemetry from day one: auto-instrumentation for the plumbing, manual spans for business operations, one collector tier owning sampling/redaction/export. Swapping vendors becomes a config change.
  • Emit the trio correlated: every log line carries requestId + traceId; metrics carry exemplars linking to traces. The 3am workflow is only fast if the handoffs are one click.
  • Define SLIs/SLOs per user-facing capability (checkout, payout, login), wire burn-rate alerts (fast-burn pages, slow-burn tickets), and review error-budget spend in the weekly ops review.
  • Standardize one RED dashboard template and one log event-name convention (domain.action) across all services.
  • Telemetry is a production dependency: capacity-plan the pipeline, and make sure observability doesn't share fate with what it observes (the monitoring for region A shouldn't live only in region A).
AWS mapping
  • Logs: CloudWatch Logs (stdout JSON → log groups via the ECS/EKS/Lambda agents), Logs Insights for querying, subscription filters → Kinesis/Firehose → S3 + Athena for the cheap archive tier.
  • Metrics: CloudWatch Metrics + Alarms (composite alarms for burn-rate-style logic), Embedded Metric Format (EMF) to emit metrics from log lines; Amazon Managed Prometheus + Grafana when you want PromQL and open tooling.
  • Traces: X-Ray (native, cheap, integrated with Lambda/API Gateway; limited retention and query power) and ADOT — AWS Distro for OpenTelemetry: OTel SDK + collector, AWS-supported, exports to X-Ray/CloudWatch or any OTel backend. CloudWatch ServiceLens/Application Signals stitches traces + metrics + logs into a service map.
  • When to pick which: all-in on AWS + Lambda-heavy → X-Ray is the path of least resistance. Anything long-lived, multi-vendor, or migration-prone → instrument with OTel via ADOT and keep the backend swappable. Vendor-neutral OTel is the staff-level default answer: telemetry outlives any vendor contract, and instrumentation is the expensive part to redo.
What interviewers probe
  • Q: How would you debug a p99 latency spike? A: Scope it on metrics first — which service/route/AZ, when did it start, does it align with a deploy, and is p50 also up (systemic: saturation, GC) or only the tail (contention: locks, cold cache, one slow shard)? Then pull exemplar traces from the spike window and read the waterfall for the fat span; then filter logs by those traceIds for the why. Check USE saturation on the implicated resource, correlate with deploy/config timeline; mitigate first (rollback, shed, scale), root-cause after.
  • Q: What's an error budget? A: 1 − SLO — the quantified unreliability you're allowed per window (99.9% ≈ 43.8 min/month). It converts reliability-vs-velocity into arithmetic: budget remaining → ship aggressively; budget exhausted → freeze features and pay down reliability. It also legitimizes risk: deploys and chaos testing are budget spends, not sins.
  • Q: Logs vs metrics vs traces — when do you reach for which? A: Metrics for detection and trends (cheap, aggregated, alertable); traces for localization across services (where did the time/error go); logs for root cause detail (exact state, exact error). Detection → localization → explanation, glued by correlation IDs. Building a new service, instrument in that order too: RED metrics first, then propagation/tracing, then structured logs.
  • Q: Why p99 and not average? A: Means hide the tail; at scale the tail is thousands of users daily, skewed toward your heaviest users. Worse, fan-out amplifies it: hitting 10 dependencies means only 0.9910 ≈ 90% of requests dodge every dependency's p99 — the tail becomes the typical experience.
  • Q: Head vs tail sampling? A: Head decides at trace start — cheap, stateless, but discards errors at the same rate as successes. Tail decides at trace end in a buffering collector — keep all errors and slow traces, sample the boring rest — at the cost of collector state and delay. Production default: low base head rate + tail rules for errors/latency.
  • Q: SLO vs SLA? A: SLO is the internal target that drives alerts and engineering priorities; SLA is the external contract with penalties, set deliberately looser so the SLO breaches first and gives you reaction room.
  • Q: What would you never log? A: Card data (PANs put the log pipeline in PCI scope; CVV is never storable), credentials/tokens, and unmasked PII. Enforced by a redaction layer in the logging library plus automated scanners — policy, not developer memory.
References
Part VI · Production-Grade

21. Production Best Practices — Shipping & Running Systems

Designing a system is table stakes; staff engineers are judged on whether they can ship and run it without waking anyone up at 3am. This chapter covers deployment strategies, graceful shutdown, zero-downtime migrations, config and secrets, resilience testing, capacity planning, cost awareness, and incident response — the operational maturity signals interviewers listen for after the whiteboard boxes are drawn.

Deployment strategies

Every deploy is a controlled experiment: "does the new version behave in production?" The strategies differ in blast radius, rollback speed, and cost.

  • Rolling: replace instances N at a time behind the LB. Cheap, no extra fleet — but two versions run simultaneously (your API and schema must tolerate that), and rollback means rolling forward again through the same slow process.
  • Blue-green: stand up a full copy (green), flip traffic at the LB/DNS, keep blue warm. Instant rollback — flip back. Cost: ~2× fleet during the deploy window. Watch for state: in-flight jobs, sticky sessions, and DB schema shared by both colors.
  • Canary: shift 1% → 5% → 25% → 100% of traffic to the new version, gated on metrics (error rate, p99 latency, business KPIs). Automated rollback on regression is the point — a canary without a metric gate is just a slow rolling deploy.
  • Feature flags: deploy the code dark, release by flipping a flag. Decouples deploy (engineering event) from release (business event), enables per-user targeting and instant kill switches — but flags rot into permanent config forks if you don't delete them.
StrategyBlast radiusRollback speedExtra costBest for
RollingGrows with each batchSlow (re-roll)~0Routine low-risk deploys, cost-sensitive fleets
Blue-greenAll-at-once at flipInstant (flip back)~2× during deployRisky releases needing fast escape hatch
Canary1–5% initiallyFast (shift weights back)SmallHigh-traffic services where metrics can judge health
Feature flagPer-user / per-segmentInstant (flip flag)Code complexityProduct launches, experiments, kill switches
Blue-green Load balancer Blue (v1) kept warm Green (v2) 100% after flip 0% (rollback path) 100% Canary Load balancer Stable (v1) 95% weight Canary (v2) 5% weight, gated 95% 5%
Real-world analogy

Blue-green is a theatre with an understudy in full costume backstage: if the lead falters, the understudy walks on mid-scene — expensive to keep two actors ready, but the show never stops. Canary is a restaurant testing a new dish on one table before printing it on every menu: if that table sends it back, only four diners were disappointed. Feature flags are the dimmer switch installed during construction — the wiring (deploy) went in weeks before opening night (release).

Canary rollout with a metric gate

Deploy canary (1%) Bake 10–15 min, compare metrics vs stable Error rate / p99 regressed? yes Auto-rollback shift weight to 0, alert no At 100% traffic? no Increase weight 1 → 5 → 25 → 50 → 100 yes Promote retire old version

Typed feature-flag client

// Minimal typed feature-flag client with targeting rules.
// Real systems (LaunchDarkly, AppConfig) add streaming updates + analytics.
interface FlagContext {
  userId: string;
  country?: string;
  plan?: 'free' | 'pro' | 'enterprise';
}

type Rule =
  | { kind: 'allowList'; userIds: string[] }
  | { kind: 'attribute'; attr: keyof FlagContext; equals: string }
  | { kind: 'percentage'; rollout: number };          // 0–100

interface FlagDefinition {
  key: string;
  enabled: boolean;               // master kill switch
  rules: Rule[];                  // first match wins
  defaultValue: boolean;
}

// Deterministic bucketing: same user always lands in the same bucket,
// so a 5% rollout doesn't flicker per-request for the same person.
function bucket(userId: string, flagKey: string): number {
  let h = 2166136261;                       // FNV-1a
  for (const c of `${flagKey}:${userId}`) {
    h = (h ^ c.charCodeAt(0)) * 16777619 >>> 0;
  }
  return h % 100;
}

export function isEnabled(flag: FlagDefinition, ctx: FlagContext): boolean {
  if (!flag.enabled) return false;          // kill switch beats everything
  for (const rule of flag.rules) {
    switch (rule.kind) {
      case 'allowList':
        if (rule.userIds.includes(ctx.userId)) return true;
        break;
      case 'attribute':
        if (ctx[rule.attr] === rule.equals) return true;
        break;
      case 'percentage':
        if (bucket(ctx.userId, flag.key) < rule.rollout) return true;
        break;
    }
  }
  return flag.defaultValue;
}
Pitfalls
  • Flag debt: every flag is an if fork you must test both ways. Set an expiry date at creation; audit quarterly. A codebase with 400 stale flags has 2^400 theoretical configurations — you are testing none of them.
  • Canary on too little traffic: 1% of 100 rps is 1 rps — you won't reach statistical significance on error rate in 10 minutes. Size the canary and bake time to the traffic you actually have.
  • Blue-green with shared mutable state: flipping the LB doesn't flip in-flight queue consumers, cron jobs, or DB schema. Enumerate every traffic source before you call it "instant rollback".
  • Rolling deploys with incompatible versions: during the roll, v1 and v2 serve simultaneously. Wire formats and DB schema must be compatible across one version gap — always.

Graceful shutdown & health checks

Deploys, autoscaling, and spot reclaims all kill processes constantly. A service that drops in-flight requests on SIGTERM turns every routine deploy into a micro-outage. The sequence is always the same:

  • 1. Receive SIGTERM (orchestrator asks nicely; SIGKILL follows after the grace period — typically 30s).
  • 2. Fail readiness / stop accepting: flip the readiness probe to failing so the LB stops routing new traffic. Wait a few seconds for LB connection draining (deregistration delay) to take effect — the LB and your process must agree on who's still sending traffic.
  • 3. Drain in-flight requests with a deadline shorter than the grace period.
  • 4. Close resources in dependency order: HTTP server → queue consumers → DB/Redis pools.
  • 5. Exit 0. Exiting non-zero or timing out gets you SIGKILLed mid-write.
// Graceful shutdown for a Node HTTP service with typed, ordered hooks.
import http from 'node:http';

type ShutdownHook = {
  name: string;
  timeoutMs: number;
  fn: () => Promise<void>;
};

class GracefulShutdown {
  private hooks: ShutdownHook[] = [];
  private shuttingDown = false;

  /** Hooks run in registration order: server first, pools last. */
  register(hook: ShutdownHook): void {
    this.hooks.push(hook);
  }

  get isShuttingDown(): boolean { return this.shuttingDown; }

  listen(): void {
    for (const sig of ['SIGTERM', 'SIGINT'] as const) {
      process.on(sig, () => void this.run(sig));
    }
  }

  private async run(signal: string): Promise<void> {
    if (this.shuttingDown) return;          // idempotent — signals can repeat
    this.shuttingDown = true;               // readiness probe now returns 503
    console.log(`${signal} received; draining...`);

    // Give the LB time to observe failing readiness and stop sending traffic
    // (must be >= probe interval; pairs with ALB deregistration delay).
    await new Promise(r => setTimeout(r, 5_000));

    for (const hook of this.hooks) {
      const timer = new Promise<never>((_, rej) =>
        setTimeout(() => rej(new Error(`${hook.name} timed out`)), hook.timeoutMs));
      try {
        await Promise.race([hook.fn(), timer]);
        console.log(`closed: ${hook.name}`);
      } catch (err) {
        console.error(`shutdown hook failed: ${hook.name}`, err); // keep going
      }
    }
    process.exit(0);
  }
}

const shutdown = new GracefulShutdown();
const server = http.createServer((req, res) => {
  if (req.url === '/health/ready') {
    res.writeHead(shutdown.isShuttingDown ? 503 : 200).end();
    return;
  }
  if (req.url === '/health/live') { res.writeHead(200).end(); return; }
  res.end('ok');
});

// 1. Stop accepting + drain in-flight (server.close waits for open sockets)
shutdown.register({
  name: 'http-server', timeoutMs: 20_000,
  fn: () => new Promise((res, rej) => server.close(e => e ? rej(e) : res())),
});
// 2. Then release downstream resources
shutdown.register({ name: 'pg-pool', timeoutMs: 5_000, fn: () => pgPool.end() });

server.listen(8080, () => shutdown.listen());

Liveness vs readiness vs startup

ProbeQuestion it answersOn failureShould check dependencies?
Liveness"Is this process wedged (deadlock, event loop stuck)?"Restart the containerNo — process-local only
Readiness"Can this instance serve traffic right now?"Remove from LB rotation (no restart)Yes — critical local deps (pool connected, cache warm)
Startup"Has slow initialization finished?"Hold off liveness checks until it passesAs needed during boot

Why a failing dependency must NOT fail liveness: if your DB has a blip and every pod's liveness check pings the DB, the orchestrator restarts your entire healthy fleet simultaneously — converting a database blip into a full outage plus a thundering-herd reconnect storm. Restarting the app cannot fix the database. Readiness may reflect dependencies; liveness reflects only the process itself.

Real-world analogy

Graceful shutdown is closing a restaurant: lock the front door (fail readiness), let seated diners finish (drain in-flight), clean the kitchen (close pools), then switch off the lights (exit). SIGKILL is the fire alarm — everyone out mid-meal. Liveness vs readiness: liveness asks "is the chef conscious?"; readiness asks "can we seat another table?" If the meat supplier is late, you stop seating tables — you don't fire the chef.

Zero-downtime database migrations

The cardinal rule: never make a change that old code can't survive, because during any deploy old and new code run against the same schema. The expand–migrate–contract pattern makes every step backward-compatible. Example: splitting full_name into first_name/last_name on a 500M-row table:

  • 1. Expand — add nullable columns. ALTER TABLE users ADD COLUMN first_name text NULL. Nullable + no default means metadata-only in Postgres 11+ — no table rewrite, no lock storm. Old code ignores the columns entirely.
  • 2. Dual-write. Deploy code that writes both old and new columns on every insert/update. Old rows are still stale — that's fine.
  • 3. Backfill old rows in small batches (5–10k rows, sleep between batches, order by PK) so you never hold long locks or saturate replication. For 500M rows this runs for hours-to-days as a background job — monitor replica lag and throttle.
  • 4. Verify, then switch reads to the new columns — ideally behind a feature flag so the read path can be flipped back instantly. Add constraints (NOT NULL, indexes) only now, using NOT VALID + VALIDATE CONSTRAINT and CREATE INDEX CONCURRENTLY.
  • 5. Contract — stop dual-writing, drop the old column after a full release cycle (and after checking nothing else — reports, ETL, other services — reads it).

Why you never rename in one step: ALTER TABLE ... RENAME COLUMN is atomic for the database but not for your fleet — the instant it commits, every still-running old instance starts throwing "column does not exist" on every query. A rename is really add-new → dual-write → backfill → switch → drop-old; it just takes five deploys instead of one.

time — each step is a separate deploy; every state is backward-compatible with the previous one 1. Expand add nullable cols 2. Dual-write old + new cols 3. Backfill batched, throttled 4. Switch reads behind a flag 5. Contract drop old col later safe: old code unaffected hours–days for 500M rows only after full cycle
-- Step 1: metadata-only in PG 11+ (nullable, no default => no rewrite)
ALTER TABLE users ADD COLUMN first_name text NULL;
ALTER TABLE users ADD COLUMN last_name  text NULL;

-- Step 3: backfill in small keyed batches; sleep between batches,
-- watch replica lag, and make the job resumable from last_id.
UPDATE users
SET    first_name = split_part(full_name, ' ', 1),
       last_name  = split_part(full_name, ' ', 2)
WHERE  id > :last_id AND id <= :last_id + 10000
  AND  first_name IS NULL;               -- idempotent re-runs

-- Step 4: constraints without long locks
ALTER TABLE users ADD CONSTRAINT first_name_nn
  CHECK (first_name IS NOT NULL) NOT VALID;   -- instant
ALTER TABLE users VALIDATE CONSTRAINT first_name_nn; -- scans w/o excl. lock
CREATE INDEX CONCURRENTLY idx_users_last_name ON users (last_name);

Config, secrets & resilience testing

  • 12-factor config: config lives in the environment, not the artifact — one build promoted through dev → staging → prod. Rebuild-per-environment means you never tested the artifact you shipped.
  • Env vars vs secret manager: env vars are fine for non-sensitive config; secrets belong in a secret manager (encrypted at rest, IAM-audited access, rotation). Env vars leak into crash dumps, docker inspect, CI logs, and child processes.
  • Rotation: secrets must be rotatable without a deploy — fetch at startup + refresh on TTL, or dual-secret overlap windows so old and new credentials both work during the swap. Never in code, never in logs; add log scrubbers for known secret patterns.
  • Chaos engineering: deliberately inject failure (kill instances, add latency, black-hole a dependency) to verify your fallbacks actually work. Start in staging, with a hypothesis ("if Redis dies, we serve from DB at higher latency, error rate stays < 0.1%") and a stop button. Graduate to production only with tight blast-radius controls. Game days — scheduled failure rehearsals with the whole team — test the humans and runbooks, not just the software.
  • Load testing before launch: test to 2–3× expected peak, with production-shaped traffic (real payload sizes, realistic cache-hit ratios, cold caches). Find the knee of the latency curve and the first bottleneck before launch day does.

Capacity planning & headroom

  • Target ~50–60% steady-state utilization: headroom for AZ failure (lose 1 of 3 AZs → surviving nodes absorb +50%), traffic spikes, and deploys that temporarily reduce capacity.
  • Autoscaling lags demand by 3–10 minutes (metric aggregation → alarm → instance boot → warm-up → in-service). A traffic spike faster than that eats your headroom, not your autoscaler. Headroom handles the spike; autoscaling handles the trend.
  • Scale on leading indicators (request count per target, queue depth) rather than trailing ones (CPU), and set a sane minimum so a quiet weekend doesn't scale you to a floor you can't recover from on Monday morning.

Cost awareness — a staff signal

Interviewers increasingly probe cost because it's a proxy for judgment: a staff engineer treats dollars as a first-class constraint, like latency.

  • Right-sizing: most fleets run < 20% CPU. Measure, downsize, repeat. An m5.4xlarge running at 8% is a space heater.
  • Spot for stateless: 60–90% discount for interruptible capacity. Fine for stateless web tiers and batch workers — which is exactly why graceful shutdown (above) matters: spot gives you a 2-minute warning.
  • S3 lifecycle policies: Standard → Infrequent Access (30d) → Glacier (90d) → delete. Logs and old exports quietly become one of the biggest line items without this.
  • Data transfer is the silent killer: compute costs are visible on day one; transfer costs appear at scale. Chatty cross-AZ microservices or a cross-region replication misconfiguration can exceed the compute bill.
Path (AWS, typical)CostDesign implication
Same-AZ, private IPFreeZone-aware routing for chatty service pairs
Cross-AZ~$0.01/GB each direction (~$0.02/GB total)Chatty east-west traffic adds up fast at scale
Cross-region~$0.02/GB and upReplicate what you must, compress, batch
Out to internet~$0.09/GBPut CloudFront in front; egress is the priciest hop
NAT gateway processing~$0.045/GB + hourlyUse VPC endpoints for S3/DynamoDB — classic silent bill

Incident response & operational readiness

  • Severity levels gate the response: SEV1 = customer-facing outage / data loss (all hands, exec comms), SEV2 = major degradation or SLO burn (paged team, incident channel), SEV3 = minor impact (business hours). Pre-agree the definitions so nobody debates severity mid-incident.
  • Incident commander (IC): one person coordinates — assigns investigation, makes rollback calls, owns comms. The IC does not debug; the moment the IC is grepping logs, nobody is steering. Rotate the role; it's a skill.
  • Comms cadence: updates on a fixed clock (SEV1: every 15–30 min, even if the update is "still investigating"). Silence is what makes stakeholders dogpile the incident channel.
  • Mitigate first, diagnose second: roll back, flip the flag, shed load. Root cause is for the postmortem.

Blameless postmortem — the system failed, not a person; if a human "made a mistake," the interesting question is why the system let that mistake reach production. Template:

  • Impact: duration, customers/requests affected, revenue/SLO cost.
  • Timeline: detection → escalation → mitigation → resolution (with timestamps; note time-to-detect and time-to-mitigate).
  • Root cause(s) — usually plural; "5 whys" until you hit process/system, not a name.
  • What went well / what went poorly (detection gaps, runbook gaps, tooling friction).
  • Action items with owners and due dates — tracked like real work, or the postmortem was theatre.

A good runbook contains: symptom → dashboard links → decision tree of probable causes → copy-pasteable mitigation commands → rollback procedure → escalation path with named owners. If it can't be followed at 3am by an engineer who has never touched the service, it isn't done. Test runbooks in game days.

Operational readiness review — the launch checklist

  • SLOs defined (availability, latency) with error budgets agreed by product.
  • Dashboards for the four golden signals (latency, traffic, errors, saturation).
  • Alerts wired to SLOs — symptom-based, paging a real rotation, with no known-noisy alerts.
  • Runbooks written for the top failure modes and linked from every alert.
  • Rollback tested — actually executed, not just documented.
  • Load tested to 2–3× projected peak; first bottleneck identified.
  • Graceful shutdown verified (deploy under load drops zero requests).
  • Security review done: authn/z, secrets handling, dependency scan.
  • Capacity + cost model reviewed; autoscaling limits and budgets set.
  • On-call rotation staffed and trained; game day scheduled.
AWS mapping
  • CodeDeploy — built-in blue/green for EC2/ECS/Lambda with automatic rollback on CloudWatch alarms; canary/linear traffic shifting for Lambda aliases (e.g. Canary10Percent5Minutes).
  • ALB weighted target groups — DIY canary: two target groups, shift weights 5/95 → 50/50 → 100/0; pair with CloudWatch alarms + a rollback Lambda or step function.
  • AppConfig — feature flags with validators, gradual rollouts, and alarm-based auto-rollback; CloudWatch Evidently for experiments/A-B. Build the custom flag client above only when you need targeting logic these don't cover.
  • Secrets Manager vs Parameter Store:
    Secrets ManagerSSM Parameter Store
    Built-in rotationYes (Lambda-driven, native for RDS)No (DIY)
    Cost~$0.40/secret/month + API callsStandard tier free
    Cross-account/regionNative replicationLimited
    Pick whenDB creds, API keys needing rotationPlain config, non-rotating values
  • Fault Injection Service (FIS) — managed chaos: terminate instances, inject latency/API errors, AZ-loss experiments, with stop-conditions tied to alarms.
  • ALB deregistration delay (default 300s — tune to your longest request) is the LB half of graceful shutdown; ECS delivers SIGTERM and waits stopTimeout before SIGKILL.
What interviewers probe
  • Q: How do you roll out a risky change? A: Layered: ship dark behind a feature flag, canary the deploy at 1% with automated metric gates (error rate, p99, key business metric), progressive traffic shifting with a defined auto-rollback trigger, and a rehearsed rollback path. Name the blast radius at each step.
  • Q: How do you migrate a 500M-row table with zero downtime? A: Expand–migrate–contract: add nullable columns (metadata-only), dual-write, batched throttled backfill watching replica lag, switch reads behind a flag after verification, contract a release cycle later. Never rename in one step — old code must always survive the current schema.
  • Q: Walk me through your incident process. A: Severity classification gates response; a dedicated IC coordinates and doesn't debug; mitigate first (rollback/flag) then diagnose; comms on a fixed cadence; blameless postmortem with tracked action items. Cite time-to-detect and time-to-mitigate as the metrics you drive down.
  • Q: Why not check the database in the liveness probe? A: Restarts can't fix a downstream dependency — you'd mass-restart a healthy fleet during a DB blip and add a reconnect storm. Dependencies belong in readiness, which only removes you from rotation.
  • Q: Blue-green vs canary — when each? A: Blue-green when you need instant whole-fleet rollback and can afford 2× briefly, or traffic is too low for canary statistics. Canary when traffic is high enough that metrics can judge 1–5% slices — smaller blast radius, less spare fleet.
  • Q: How would you cut this design's AWS bill 30%? A: Measure first: right-size (most fleets under 20% CPU), spot for stateless tiers, S3 lifecycle policies, kill cross-AZ chatter with zone-aware routing, VPC endpoints instead of NAT for S3/DynamoDB, CloudFront in front of egress. Data transfer is where the surprise usually hides.
  • Q: What's on your launch checklist? A: SLOs + dashboards + alerts wired to them, runbooks linked from alerts, rollback actually tested, load tested past peak, graceful shutdown verified under load, security review, on-call staffed. The theme: prove operability before traffic arrives.
Production best practice
  • Automate the rollback decision, not just the rollback — humans hesitate at 3am; a metric gate doesn't.
  • Deploys should be boring and frequent: small diffs, always through the same pipeline, no snowflake "just this once" manual pushes.
  • Every schema change ships in its own deploy, separate from the code that depends on it.
  • Treat runbooks and postmortem action items as code-reviewed, tracked deliverables — untracked action items are how the same incident happens twice.
  • Budget headroom explicitly: N+1 AZ math and autoscaling lag are design inputs, not afterthoughts.
References
Part VI · Production-Grade

22. Security for System Designers

Security questions separate engineers who have shipped internet-facing systems from those who have only diagrammed them. You don't need to be a pentester — you need the threat-model mindset, fluency in authn/authz mechanics (JWT, OAuth2/OIDC, mTLS), the OWASP failure modes that actually hit APIs, and defense-in-depth defaults. In fintech, this doubles as a compliance conversation: auditors ask the same questions interviewers do.

Threat-model mindset

Before picking mechanisms, ask three questions — keep it on one whiteboard, not a 40-page document:

  • Assets: what's worth stealing or breaking? (card data, PII, auth tokens, money-movement APIs, availability itself).
  • Actors: who attacks? External attackers, malicious insiders, compromised dependencies, and — most commonly — your own bugs (a leaked key in a repo, an unauthenticated internal endpoint).
  • Attack surface: every path in — public APIs, webhooks, admin panels, CI/CD, third-party integrations, employee laptops. Each system-design component you draw adds surface; say so as you draw it.

Then apply defense in depth: assume any single layer fails, and make sure the next one limits the damage.

Real-world analogy

A bank branch doesn't rely on the front-door lock. There's a guard (WAF), a teller who verifies your identity (authentication), a ledger of what your account allows (authorization), a vault with its own combination (encryption at rest), cameras recording everything (audit logs), and the manager's key opens the vault but not the safe-deposit boxes (least privilege). Threat modeling is the security consultant walking the floor asking "what would I steal, and how would I get in?"

Authentication vs authorization

  • Authentication (authn): who are you? Verifying identity — passwords + MFA, sessions, tokens, certificates.
  • Authorization (authz): what may you do? Verifying permission — roles, policies, ownership checks.
  • They fail differently: broken authn lets strangers in; broken authz lets legitimate users do illegitimate things (see BOLA below). Interviewers love candidates who keep the two words straight under pressure.
Real-world analogy

The nightclub bouncer does two distinct jobs: checking your ID proves you are who you claim (authentication); checking the VIP list decides whether you get past the velvet rope (authorization). A perfect ID check with no VIP list means anyone with a real ID wanders into the VIP room — that's BOLA in a nutshell.

Session-based vs token-based auth

Server-side sessionsTokens (JWT)
StateSession store (Redis/DB) holds truthStateless — claims travel in the token
RevocationInstant: delete the session rowHard: valid until expiry unless you add a denylist
ScalingNeeds a shared, fast session storeAny node verifies with the public key — no lookup
Cross-serviceEvery service must reach the storeSelf-contained; ideal for microservices/API gateways
Payload riskNothing sensitive leaves the serverClaims are readable by anyone holding the token
Best forFirst-party web apps, high-security (banking) flowsService-to-service, mobile/SPA APIs, federated identity

JWT deep dive — and its pitfalls

  • Structure: base64url(header).base64url(payload).signature. Header says the algorithm and key id (kid); payload carries claims (sub, exp, iss, aud, jti); signature proves integrity.
  • Signed, not encrypted: base64url is an encoding, not encryption — anyone can read the payload with one decode. Never put secrets in claims. The signature only proves the token wasn't tampered with.
  • alg=none attack: naive libraries once honored a header claiming "no signature needed". Algorithm confusion: if the verifier lets the token choose the algorithm, an attacker can take your RS256 public key (which is public) and use it as an HS256 HMAC secret to forge tokens. Fix for both: the server pins the accepted algorithm list; never trust the header's alg.
  • The revocation problem: a stateless token is valid until exp — you can't "log out" a stolen one. Mitigations: short TTL (5–15 min access tokens) + refresh-token rotation (each refresh issues a new refresh token and invalidates the old; reuse of an old one signals theft — revoke the whole family). For the residual window, a denylist of revoked jti values in Redis — small, because only entries younger than the max TTL matter. Note the irony: the denylist reintroduces the per-request lookup JWTs were meant to remove; that's the trade you're consciously making.
  • Clock skew: distributed verifiers' clocks drift; tolerate ±30–60s on exp/nbf or you'll reject freshly minted tokens.
  • Key rotation: publish keys at a JWKS endpoint keyed by kid; verifiers select by kid and cache the key set, so you can roll keys without downtime.
// Typed JWT verification middleware: pinned algorithms, clock-skew
// tolerance, key rotation via kid -> JWKS lookup, audience/issuer checks.
import { createVerify } from 'node:crypto';

interface JwtHeader { alg: string; kid?: string; typ?: string }
interface JwtClaims {
  sub: string; iss: string; aud: string;
  exp: number; iat: number; nbf?: number; jti?: string;
  scope?: string;
}
type VerifyResult =
  | { ok: true; claims: JwtClaims }
  | { ok: false; reason: 'expired' | 'bad-signature' | 'bad-alg'
              | 'unknown-kid' | 'wrong-audience' | 'malformed' };

const ALLOWED_ALGS = new Set(['RS256']);      // server pins — never trust header
const CLOCK_SKEW_SEC = 60;

// JWKS cache: kid -> PEM public key, refreshed in background on a TTL,
// force-refreshed once on unknown kid (handles mid-rotation verifiers).
const keyCache = new Map<string, string>();
async function getKey(kid: string): Promise<string | undefined> {
  if (!keyCache.has(kid)) await refreshJwks(keyCache);   // one retry
  return keyCache.get(kid);
}

export async function verifyJwt(
  token: string, expected: { iss: string; aud: string },
): Promise<VerifyResult> {
  const parts = token.split('.');
  if (parts.length !== 3) return { ok: false, reason: 'malformed' };
  const [h, p, sig] = parts;

  const header = JSON.parse(Buffer.from(h, 'base64url').toString()) as JwtHeader;
  // Defeats alg=none AND RS256->HS256 confusion in one check:
  if (!ALLOWED_ALGS.has(header.alg)) return { ok: false, reason: 'bad-alg' };
  if (!header.kid) return { ok: false, reason: 'unknown-kid' };

  const pem = await getKey(header.kid);
  if (!pem) return { ok: false, reason: 'unknown-kid' };

  const verifier = createVerify('RSA-SHA256').update(`${h}.${p}`);
  if (!verifier.verify(pem, Buffer.from(sig, 'base64url'))) {
    return { ok: false, reason: 'bad-signature' };
  }

  const claims = JSON.parse(Buffer.from(p, 'base64url').toString()) as JwtClaims;
  const now = Math.floor(Date.now() / 1000);
  if (claims.exp + CLOCK_SKEW_SEC < now) return { ok: false, reason: 'expired' };
  if (claims.nbf !== undefined && claims.nbf - CLOCK_SKEW_SEC > now) {
    return { ok: false, reason: 'expired' };            // not yet valid
  }
  if (claims.iss !== expected.iss || claims.aud !== expected.aud) {
    return { ok: false, reason: 'wrong-audience' };
  }
  // Optional: check claims.jti against the Redis revocation denylist here.
  return { ok: true, claims };
}
Pitfalls
  • Putting PII or secrets in JWT claims — the payload is world-readable; it's a postcard with a tamper-proof seal, not an envelope.
  • Trusting the token's alg header — root cause of both alg=none and algorithm-confusion forgeries.
  • 24-hour access tokens "for convenience" — you've built a 24-hour un-revokable credential. Short TTL + refresh rotation exists precisely for this.
  • Storing tokens in localStorage for browser apps — readable by any XSS. Prefer httpOnly, Secure, SameSite cookies for first-party web.
  • Skipping aud/iss checks — a valid token for service A gets replayed against service B.
  • Comparing API keys or HMACs with === — timing side channel; use a constant-time compare.

OAuth2 + OIDC flows

  • OAuth2 is delegation, not authentication: "let app X act on my behalf at API Y with scope Z." OIDC is the identity layer on top: it adds the id_token (a JWT answering "who logged in") and standard user claims.
  • id_token vs access_token: the id_token is for the client — proof of who the user is; never send it to APIs. The access_token is for the resource server — proof the caller may access it; the client should treat it as opaque.
  • Authorization code + PKCE — the modern default for every user-facing app (web, SPA, mobile). PKCE binds the code to the initiating client: the app sends a hashed code_challenge up front and must present the matching code_verifier to redeem the code, so an intercepted code is worthless. Codes are one-time-use, short-lived, and redeemed server-to-server.
  • Client credentials — service-to-service, no user involved: service authenticates with its own credentials and receives a scoped access token.
  • Implicit flow is deprecated — it returned tokens in the URL fragment (leaks via history, referrer, logs). If you see it in a design, that's your cue to say "auth code + PKCE replaced this."
App (client) Auth server Resource API 1 authorize?code_challenge=S256(verifier)+scope 2 user authenticates + consents; redirect with code 3 POST /token: code + code_verifier 4 verifies hash matches challenge → access_token + id_token (OIDC) + refresh_token 5 GET /accounts Authorization: Bearer access_token 6 API verifies signature, iss, aud, exp, scope → 200 PKCE: an attacker who steals the code in step 2 cannot redeem it in step 3 without the verifier

API keys, mTLS, RBAC vs ABAC

API key management

  • Hash them like passwords: store SHA-256 of the key, never plaintext — a DB dump must not hand out live credentials. Show the key once at creation.
  • Prefix for identification: sk_live_ / sk_test_ (Stripe-style) lets logs, support, and secret scanners identify a key type without revealing it, and lets you look up the record by prefix while comparing only hashes.
  • Scope and expire: per-key permissions (read-only vs write), per-key rate limits, last-used timestamps, painless rotation (two active keys during overlap) and instant revocation.

mTLS for service-to-service

  • Plain TLS authenticates the server to the client; mutual TLS adds a client certificate, so both ends prove identity before any request is sent — the standard answer for zero-trust east-west traffic.
  • The pain is operational: issuing, distributing, rotating, and revoking certs per workload. That's why the practical answer is a service mesh does this for you — Istio/Linkerd/App Mesh sidecars (or ECS Service Connect) auto-issue short-lived certs (often 24h) and rotate them with zero app code.
  • Identity from mTLS (the cert's SPIFFE ID / SAN) then feeds authorization: "payments-service may call ledger-service" becomes a mesh policy, not a firewall rule.

RBAC vs ABAC

RBAC (role-based)ABAC (attribute-based)
ModelUser → roles → permissionsPolicy over attributes of user, resource, action, context
Example"support agents can view transactions""agents can view transactions in their region, under $10k, during shift hours"
StrengthsSimple, auditable, easy to reason aboutFine-grained, contextual, fewer role explosions
WeaknessesRole explosion as rules get contextualPolicies get complex; harder to answer "who can do X?"
Reach forInternal tools, coarse app tiers — the defaultMulti-tenant, regulated, ownership/context-heavy rules (often layered on RBAC)
// RBAC with role hierarchy + an ABAC-style ownership condition on top —
// the layered pattern most real systems converge on.
type Role = 'viewer' | 'support' | 'admin';
type Action = 'transaction:read' | 'transaction:refund' | 'user:delete';

// Each role inherits everything from the roles it extends.
const ROLE_HIERARCHY: Record<Role, { grants: Action[]; inherits?: Role }> = {
  viewer:  { grants: ['transaction:read'] },
  support: { grants: ['transaction:refund'], inherits: 'viewer' },
  admin:   { grants: ['user:delete'],        inherits: 'support' },
};

function effectivePermissions(role: Role): Set<Action> {
  const out = new Set<Action>();
  for (let r: Role | undefined = role; r; r = ROLE_HIERARCHY[r].inherits) {
    ROLE_HIERARCHY[r].grants.forEach(a => out.add(a));
  }
  return out;
}

interface Principal { userId: string; role: Role; tenantId: string }
interface Resource  { ownerId: string; tenantId: string; amountCents?: number }

export function can(p: Principal, action: Action, res: Resource): boolean {
  // Layer 1 — RBAC: does the role (or an inherited one) grant the action?
  if (!effectivePermissions(p.role).has(action)) return false;
  // Layer 2 — ABAC conditions: tenant isolation is non-negotiable...
  if (p.tenantId !== res.tenantId) return false;
  // ...and contextual rules: support can only refund small amounts.
  if (action === 'transaction:refund' && p.role === 'support') {
    return (res.amountCents ?? 0) <= 10_000_00;   // ≤ $10k
  }
  return true;
}

OWASP essentials for API builders

VulnerabilityThe attackThe fix
Injection (SQL/NoSQL/command)User input interpreted as code: '; DROP TABLE--Parameterized queries / prepared statements, always. ORMs mostly do this; raw string-built queries never pass review.
Broken authenticationCredential stuffing, weak session handling, missing MFAManaged IdP, MFA, rate-limit login, bcrypt/argon2 hashes, rotate session on privilege change
BOLA / IDOR (API #1)Authenticated user requests GET /accounts/12346 — someone else's ID — and gets itCheck ownership, not just authentication: every object access verifies the caller may access that object (see can() above); use non-guessable IDs as defense-in-depth only
SSRFAttacker supplies a webhook/callback URL like http://169.254.169.254/ and your server fetches internal metadataValidate + allowlist outbound URLs, resolve-then-check IPs (block private ranges), egress via proxy, IMDSv2
Mass assignmentPATCH /me {"role":"admin"} binds straight onto the modelExplicit allowlisted DTOs per endpoint — never bind request bodies directly to persistence models
Security misconfigurationDebug endpoints, permissive CORS, default creds in prodHardened baseline images/IaC, config scanning in CI, no wildcard CORS with credentials

Encryption, secrets, edge defense & audit

Encryption at rest vs in transit — envelope encryption

  • In transit: TLS 1.2+ everywhere — including inside the VPC ("the network is hostile" is the zero-trust default). mTLS where both ends need identity.
  • At rest: you don't encrypt terabytes directly with the KMS master key — KMS calls are limited to 4KB payloads and you'd bottleneck on the API. Instead, envelope encryption: KMS generates a data key; you encrypt the data locally with it, then store the encrypted data key alongside the ciphertext and throw away the plaintext key. To decrypt: send the encrypted data key to KMS, get the plaintext key back, decrypt locally. The master key never leaves KMS hardware; revoking KMS access instantly makes every envelope unopenable.
  • Secrets rotation: rotate on schedule and on any suspicion of exposure; design apps to fetch secrets at runtime (with caching) so rotation needs no deploy; use dual-secret overlap windows so nothing breaks mid-rotation.
KMS (HSM-backed — master key never leaves) Customer master key (CMK / KMS key) Your service App encrypts locally (AES-256) Storage (S3/DB) ciphertext + encrypted data key 1 GenerateDataKey 2 plaintext key + encrypted key 3 store both; discard plaintext key Decrypt path send enc. key to KMS, get plaintext key back, decrypt data locally

WAF, DDoS & least privilege

  • Layered edge: CDN absorbs volumetric traffic → DDoS protection (AWS Shield) handles L3/L4 floods → WAF filters L7 (SQLi/XSS signatures, bot rules, geo/IP rules) → your rate limiting handles per-client abuse (mechanics in ch17). Each layer strips a different attack class before it costs you compute.
  • Least privilege: IAM roles with temporary credentials, not IAM users with long-lived keys; scope policies to exact actions/resources (s3:GetObject on one bucket, not s3:*); separate roles per service; time-boxed, approved elevation for humans ("break-glass"). Blast radius of a compromised credential = its policy — write policies as if the credential is already leaked.

Audit logging — the fintech angle

  • Record who did what, to what, when, from where — actor, action, resource, result, source IP, request ID — for every privileged or money-moving operation.
  • Immutable and append-only: ship logs to a store the application (and its admins) cannot rewrite — e.g. S3 with Object Lock/versioning, or a dedicated account. An attacker who can edit audit logs erases themselves.
  • Never log secrets, full PANs, or tokens — PCI-DSS explicitly requires masking card data (show at most first 6/last 4), mandates audit trails for all access to cardholder data, and dictates retention (12 months, 3 months hot). SOC 2 auditors will sample these logs; design for that day one.
AWS mapping
  • Cognito — managed user pools: OIDC/OAuth2 flows, MFA, social/SAML federation, JWTs with a hosted JWKS. Use it before building your own IdP; go custom (or Auth0/Okta) when you need flows/branding it can't express.
  • IAM — authn+authz for everything AWS-side: roles for services (IRSA/task roles), scoped policies, permission boundaries; IAM Access Analyzer flags over-broad grants.
  • KMS — envelope encryption exactly as diagrammed; native integration with S3/EBS/RDS/DynamoDB (tick "encrypt with CMK"); key policies + CloudTrail give per-decrypt audit.
  • Secrets Manager — storage plus automatic rotation (native for RDS); Parameter Store for plain config (see ch21 table).
  • WAF + Shield — WAF managed rule groups (SQLi/XSS, bot control, IP reputation) on ALB/CloudFront/API Gateway; Shield Standard is free/automatic at L3/L4, Advanced adds response team + cost protection.
  • VPC layers — private subnets for anything with data, security groups as stateful instance firewalls, NACLs as subnet guardrails, VPC endpoints so S3/DynamoDB traffic never touches the internet; CloudTrail + GuardDuty for API audit and threat detection.
What interviewers probe
  • Q: How do you secure service-to-service calls? A: Layered: network isolation (private subnets/SGs) so most things can't connect at all; mTLS for mutual identity — via a service mesh so cert issuance/rotation is automated; then per-service authorization (mesh policy or client-credentials tokens with scopes). Never "it's inside the VPC so it's trusted."
  • Q: JWT vs session for a banking app? A: For first-party banking web/mobile: server-side sessions (or very short-TTL JWTs + rotation + denylist) — instant revocation is a hard requirement when fraud hits. Stateless JWTs shine for service-to-service and third-party API access. State the trade: statelessness vs revocability, and pick revocability where money moves.
  • Q: How do you store API keys? A: Like passwords: show once, store only a SHA-256 hash, identify by a typed prefix (sk_live_), attach scopes + rate limits + last-used, support dual-key rotation and instant revocation. Compare with constant-time equality.
  • Q: What's BOLA and how do you prevent it? A: Broken Object Level Authorization — the API's #1 risk: authenticated user swaps an ID and reads someone else's object. Prevent with per-object ownership/tenancy checks in a central authorization layer (never per-endpoint ad hoc), plus tests that assert cross-tenant access fails.
  • Q: Why is a JWT "signed, not encrypted" a problem in practice? A: Teams treat claims as private and stuff PII/secrets in; anyone with the token base64-decodes it. Signature = integrity only. If confidentiality is needed, use JWE or keep data server-side.
  • Q: Walk me through OAuth for a mobile app. A: Authorization code + PKCE: challenge up front, verifier at redemption, so an intercepted code is useless; short-lived access token, rotating refresh token, id_token only for the client. Note implicit flow is deprecated and why.
  • Q: How would you encrypt user documents in S3? A: Envelope encryption via KMS: per-object data keys, encrypted data key stored with the object, master key never leaves KMS; SSE-KMS gets you this managed, with CloudTrail auditing each decrypt and key policy as the revocation switch.
Production best practice
  • Centralize authorization in one audited module/service (like can() above) — scattered per-endpoint checks are where BOLA breeds.
  • Deny by default: new endpoints require explicit authz annotations to serve traffic; a missing check should fail closed, loudly.
  • Buy identity, don't build it: managed IdP (Cognito/Auth0/Okta) unless identity is literally your product.
  • Put secret scanning (gitleaks/trufflehog) and dependency scanning in CI; treat a leaked-then-rotated key as an incident with a postmortem.
  • Write cross-tenant and IDOR tests into the standard test suite — security regressions should fail the build, not wait for a pentest.
References
Part VII · Design Craft

23. SOLID & Design Patterns in TypeScript

System design interviews at staff level don't stop at boxes and arrows — interviewers probe whether your code-level instincts match your architecture-level claims. SOLID is the micro version of the same trade-offs: coupling, cohesion, substitutability, and dependency direction. This chapter gives each principle a one-liner, an analogy, a short before/after in TypeScript, and — most importantly — where it shows up at system scale. Then the handful of patterns that actually earn their keep in distributed systems.

The five principles, translated to systems

  • SRP — Single Responsibility. A module should have one reason to change. System scale: service boundaries. A "user service" that also renders emails and computes billing is three deploy trains welded together.
  • OCP — Open/Closed. Open for extension, closed for modification: add behavior by adding code, not editing tested code. System scale: strategy-pluggable rate limiters, pluggable fraud rules — ship a new rule without touching the engine.
  • LSP — Liskov Substitution. Subtypes must honor the base type's contract — not just its signature, its behavior. System scale: substitutable storage backends: if your S3-backed blob store throws where the local one returned null, every caller breaks in prod only.
  • ISP — Interface Segregation. No client should depend on methods it doesn't use. System scale: narrow API contracts. A consumer that only reads balances shouldn't take a dependency on the full 40-endpoint ledger API surface.
  • DIP — Dependency Inversion. High-level policy depends on abstractions; details depend on the same abstractions. System scale: ports & adapters — your domain talks to a MessageBus port, and SQS vs Kafka is an adapter decision you can reverse.
Real-world analogy

SOLID is electrical wiring standards. SRP: one breaker per circuit, so the kitchen shorting doesn't kill the server room. OCP: wall outlets — you add appliances by plugging in, not by rewiring the wall. LSP: any device with the right plug must draw current the way the socket expects; a "compatible" plug that pulls 40 A melts the house. ISP: a lamp needs two prongs, not the 14-pin industrial connector. DIP: appliances depend on the outlet standard, not on which power plant feeds it — the grid can swap coal for solar and your toaster never knows.

SRP & OCP — one reason to change, extend without editing

// ─── BEFORE: one class, four reasons to change (SRP violation) ───
class PaymentProcessorBad {
  async process(p: Payment): Promise<void> {
    if (p.amount <= 0) throw new Error("invalid");        // validation
    await db.query("INSERT INTO payments ...", [p.id]);    // persistence
    await smtp.send(p.email, `Paid ${p.amount}`);          // notification
    // adding PayPal? edit this method + retest everything (OCP violation)
    if (p.method === "card") await stripe.charge(p);
    else if (p.method === "bank") await plaid.transfer(p);
  }
}

// ─── AFTER: one job each; new methods are new classes ───
interface PaymentMethodHandler {
  readonly method: Payment["method"];
  execute(p: Payment): Promise<ChargeResult>;
}

class CardHandler implements PaymentMethodHandler {
  readonly method = "card" as const;
  execute(p: Payment) { return stripe.charge(p); }
}
class BankHandler implements PaymentMethodHandler {
  readonly method = "bank" as const;
  execute(p: Payment) { return plaid.transfer(p); }
}

class PaymentProcessor {
  // registry: adding PayPal = registering one new handler, zero edits here
  constructor(
    private handlers: Map<string, PaymentMethodHandler>,
    private repo: PaymentRepository,        // persistence lives elsewhere
    private notifier: Notifier,             // so does notification
  ) {}

  async process(p: Payment): Promise<void> {
    const handler = this.handlers.get(p.method);
    if (!handler) throw new UnsupportedMethodError(p.method);
    const result = await handler.execute(p);
    await this.repo.save({ ...p, status: result.status });
    await this.notifier.paymentCompleted(p);   // async in real life: emit event
  }
}

LSP & ISP — honor contracts, keep them narrow

// ─── BEFORE ───
interface FileStore {                     // fat interface (ISP violation)
  read(key: string): Promise<Buffer>;
  write(key: string, data: Buffer): Promise<void>;
  delete(key: string): Promise<void>;
  listVersions(key: string): Promise<string[]>;
  restoreVersion(key: string, v: string): Promise<void>;
}
class LocalStore implements FileStore {
  // LSP violation: "implements" the contract by exploding at runtime.
  // Callers written against FileStore break only when handed a LocalStore.
  listVersions(): Promise<string[]> { throw new Error("not supported"); }
  restoreVersion(): Promise<void>  { throw new Error("not supported"); }
  /* read/write/delete elided */ read!: any; write!: any; delete!: any;
}

// ─── AFTER: segregate; capabilities are separate, composable contracts ───
interface BlobReader  { read(key: string): Promise<Buffer>; }
interface BlobWriter  { write(key: string, data: Buffer): Promise<void>;
                        delete(key: string): Promise<void>; }
interface Versioned   { listVersions(key: string): Promise<string[]>; }

// Each backend implements exactly what it truly supports — no lying.
class S3Store    implements BlobReader, BlobWriter, Versioned { /* ... */
  read!: any; write!: any; delete!: any; listVersions!: any; }
class LocalDisk  implements BlobReader, BlobWriter { /* honest subset */
  read!: any; write!: any; delete!: any; }

// Consumers declare the minimum they need (ISP), and ANY implementation
// that type-checks also behaves correctly (LSP) — substitution is safe.
async function generateInvoicePdf(store: BlobReader, id: string) {
  return render(await store.read(`invoices/${id}.json`));
}

DIP — ports & adapters (the interview favorite)

This is the answer to "how would you structure code so you can swap SQS for Kafka?" The domain owns a port (interface written in the domain's vocabulary); infrastructure supplies adapters. Dependency arrows point inward — the domain never imports an SDK.

// ─── PORT: owned by the domain, speaks domain language ───
interface EventBus {
  publish<T extends DomainEvent>(topic: string, event: T): Promise<void>;
  subscribe(topic: string, handler: (e: DomainEvent) => Promise<void>): void;
}

// Domain service depends ONLY on the port — no AWS/Kafka imports here.
class SettlementService {
  constructor(private bus: EventBus, private ledger: LedgerRepository) {}
  async settle(batch: SettlementBatch): Promise<void> {
    await this.ledger.postEntries(batch.entries);          // strong consistency
    await this.bus.publish("settlements", {                // eventual fan-out
      type: "SettlementCompleted", batchId: batch.id, at: new Date(),
    });
  }
}

// ─── ADAPTERS: interchangeable infrastructure details ───
class SqsEventBus implements EventBus {
  constructor(private sqs: SQSClient, private queueUrls: Record<string,string>) {}
  async publish(topic: string, event: DomainEvent) {
    await this.sqs.send(new SendMessageCommand({
      QueueUrl: this.queueUrls[topic],
      MessageBody: JSON.stringify(event),
      MessageDeduplicationId: dedupeKey(event),   // FIFO idempotency
    }));
  }
  subscribe() { /* long-poll loop elided */ }
}

class KafkaEventBus implements EventBus {
  constructor(private producer: KafkaProducer) {}
  async publish(topic: string, event: DomainEvent) {
    await this.producer.send({ topic, messages: [{
      key: partitionKey(event),                   // per-entity ordering
      value: JSON.stringify(event),
    }]});
  }
  subscribe() { /* consumer group elided */ }
}

// Swap = one line at the composition root. Tests inject an InMemoryEventBus.
const bus: EventBus = config.streaming ? new KafkaEventBus(producer)
                                       : new SqsEventBus(sqs, urls);
Domain core — no SDK imports SettlementService business rules only EventBus port (interface) LedgerRepo port (interface) HTTP / gRPC driving adapter calls in SqsEventBus adapter KafkaEventBus implements port PostgresRepo adapter DynamoRepo implements port Dependency arrows point INWARD — infrastructure depends on the domain, never the reverse.

Patterns that matter for system design

Interviewers don't want the Gang-of-Four catalog recited — they want to see you reach for the right five patterns instinctively. Each one below maps to a distributed-systems concern.

  • Strategy — interchangeable algorithms behind one interface. Systems use: LB algorithms (round-robin vs least-connections), retry policies, rate-limiter algorithms, fraud-scoring rules.
  • Factory — centralize construction that varies by environment. Systems use: building clients per env/region (LocalStack in dev, real AWS in prod), connection creation with the right TLS/timeout config.
  • Singleton (done right) — in TS, a module-level instance is a singleton; no class ceremony needed. Systems use: connection pools, SDK clients (reuse across Lambda invocations). Danger: hidden global state makes tests order-dependent — prefer "single instance wired at the composition root" over "class that enforces its own singleness."
  • Observer / PubSub — decouple emitters from reactors. Systems use: the in-process seed of event-driven architecture; the same shape scales up to SNS/EventBridge/Kafka topics.
  • Adapter — wrap a third-party shape in your interface. Systems use: payment providers (Stripe/Adyen behind one PaymentGateway), so a provider outage becomes a config flip, not a rewrite.
  • Decorator — layer behavior via composition. Systems use: withRetry(withMetrics(withCache(client))) — the client-side middleware stack; the same idea as service-mesh sidecars, in-process.
  • Repository — data access behind a collection-like interface. Systems use: keeps SQL/Dynamo details out of the domain; enables the Postgres→Dynamo migration story and in-memory fakes for tests.
  • Dependency Injection — pass dependencies in via constructor instead of reaching out. Beats service locators because dependencies are visible in the signature, type-checked, and fakeable. DI containers (NestJS, InversifyJS) help at ~50+ services with deep graphs; below that, manual wiring in one composition-root.ts is simpler and grep-able.
// STRATEGY: retry policies as swappable values
interface RetryPolicy { delayMs(attempt: number): number | null; }  // null = give up
const exponentialJitter = (base = 100, max = 5): RetryPolicy => ({
  delayMs: (n) => n >= max ? null : Math.random() * base * 2 ** n,
});
const noRetry: RetryPolicy = { delayMs: () => null };

// DECORATOR: compose cross-cutting behavior around a narrow client port
interface HttpClient { request(req: ApiRequest): Promise<ApiResponse>; }

const withRetry = (inner: HttpClient, policy: RetryPolicy): HttpClient => ({
  async request(req) {
    for (let attempt = 0; ; attempt++) {
      try { return await inner.request(req); }
      catch (err) {
        const delay = isRetryable(err) ? policy.delayMs(attempt) : null;
        if (delay === null) throw err;
        await sleep(delay);                    // jitter prevents thundering herd
      }
    }
  },
});

const withMetrics = (inner: HttpClient, m: Metrics): HttpClient => ({
  async request(req) {
    const start = performance.now();
    try { return await inner.request(req); }
    finally { m.timing(`http.${req.route}`, performance.now() - start); }
  },
});

// Composition reads inside-out: metrics times each attempt; retry wraps all.
const client = withRetry(withMetrics(rawFetchClient, metrics),
                         exponentialJitter());

// SINGLETON done right: module scope + explicit wiring. In Lambda, this
// lives outside the handler so warm invocations reuse the pool.
export const pgPool = new Pool({ max: 10, connectionTimeoutMillis: 2_000 });
// Testing danger: importing this module opens real connections. Fix: export
// a factory and inject the pool — the singleton is a wiring decision, not a class.
// ADAPTER + REPOSITORY + constructor DI, in one realistic slice
interface PaymentGateway {                       // OUR interface, our vocabulary
  charge(req: ChargeRequest): Promise<ChargeResult>;
}
class StripeAdapter implements PaymentGateway {  // their SDK stays in here
  constructor(private stripe: Stripe) {}
  async charge(req: ChargeRequest): Promise<ChargeResult> {
    const intent = await this.stripe.paymentIntents.create({
      amount: req.minorUnits, currency: req.currency,
      idempotency_key: req.idempotencyKey,       // provider-level idempotency
    } as never);
    return { status: intent.status === "succeeded" ? "settled" : "pending",
             providerRef: intent.id };
  }
}

interface OrderRepository {                      // Repository: collection-like
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

class CheckoutService {
  constructor(                                   // constructor DI: deps visible,
    private orders: OrderRepository,             // typed, and trivially faked
    private gateway: PaymentGateway,
  ) {}
  async checkout(orderId: OrderId, key: string): Promise<ChargeResult> {
    const order = await this.orders.findById(orderId);
    if (!order) throw new OrderNotFoundError(orderId);
    const result = await this.gateway.charge({
      minorUnits: order.totalMinor, currency: order.currency,
      idempotencyKey: key,
    });
    await this.orders.save({ ...order, paymentStatus: result.status });
    return result;
  }
}

// composition-root.ts — the ONE place that knows concrete types
const service = new CheckoutService(new PostgresOrderRepo(pgPool),
                                    new StripeAdapter(stripeSdk));
// test.ts — no mocks framework needed, just honest fakes
const testSvc = new CheckoutService(new InMemoryOrderRepo(),
                                    new FakeGateway({ status: "settled" }));
PatternIntentSystem-design example
StrategySwap algorithms behind one interfaceRound-robin vs least-conn LB; token-bucket vs sliding-window limiter; retry policies
FactoryCentralize env-dependent constructionClient builders: LocalStack vs prod endpoints, per-region config, TLS/timeouts
SingletonExactly one shared instanceDB connection pool; SDK client reused across Lambda warm invocations
Observer / PubSubDecouple emitters from reactorsIn-process events → SNS/EventBridge/Kafka at scale; cache invalidation on change events
AdapterForeign shape → your interfaceStripe/Adyen behind one PaymentGateway; multi-provider failover
DecoratorLayer behavior by compositionwithRetry(withMetrics(client)); the in-process version of a sidecar/mesh
RepositoryData access behind a collection APIDomain unaware of SQL vs Dynamo; enables migration + in-memory test fakes
Dependency InjectionDepend on injected abstractionsComposition root wires SQS vs Kafka adapter; tests inject fakes, not mocks-of-SDKs

Anti-patterns: when patterns hurt

Pattern-itis is a real interview red flag. Wrapping a two-implementation if/else in AbstractStrategyFactoryProvider signals cargo-culting, not seniority. Premature abstraction is worse than duplication: the wrong abstraction couples callers to a shape you invented before seeing real variation — and unwinding it costs more than the copy-paste ever did. The staff heuristic: abstract on the second or third concrete need, keep ports narrow (one or two methods), and let interfaces be discovered from usage rather than designed speculatively. Say this out loud in interviews; restraint is a signal.

AWS mapping
  • DIP/ports in the cloud: code against your own EventBus/BlobStore ports; adapters wrap @aws-sdk/client-sqs, client-s3, etc. This is also your LocalStack/testcontainers story and your multi-cloud exit story.
  • Decorator: AWS SDK v3 middleware stack is literally this — retry, signing, and logging are composed middlewares around the HTTP call. Add your own via client.middlewareStack.add(...).
  • Strategy: configure, don't code — SDK retry modes (standard vs adaptive), ALB routing rules, DynamoDB adaptive capacity.
  • Singleton in Lambda: instantiate clients at module scope so warm starts reuse connections; per-invocation construction is the #1 self-inflicted Lambda latency bug.
  • Build custom vs use managed: never hand-roll retry/backoff for AWS calls — the SDK's built-in adaptive retry with client-side rate limiting is better than what you'll write in an afternoon.
What interviewers probe
  • Q: "How would you structure code so you can swap SQS for Kafka?" A: DIP: domain owns an EventBus port in domain vocabulary; SQS/Kafka adapters implement it; the swap happens at one composition root. Caveat like a staff engineer: the port must not leak semantics — ordering, replay, and delivery guarantees differ, so the port's contract documents the weakest guarantee (at-least-once, no global order) and the domain is built for that.
  • Q: "Explain DIP with an example from your work." A: Concrete story: "Our settlement service published to a MessageBus interface; adapters for SQS in prod, in-memory in tests. When a partner integration forced Kafka, we added one adapter and touched zero domain code." Name the mechanism (dependency direction inward) and the payoff (testability + reversibility).
  • Q: "Isn't an interface with one implementation over-engineering?" A: Usually yes — unless the second implementation is the test fake, which you need on day one for anything touching network or money. That's the honest justification, not "we might switch databases."
  • Q: "DI container or manual wiring?" A: Manual composition root until the object graph genuinely hurts (dozens of services, request-scoped deps). Containers add magic, runtime resolution errors, and slower cold starts in Lambda.
  • Q: "Where does LSP bite in real systems?" A: "Compatible" storage/queue backends with different failure semantics — a backend that throws where another returns empty, or an adapter that silently truncates. Contract tests run against every adapter catch this.
  • Q: "When did you remove an abstraction?" A: Have a story. Deleting a speculative layer that only ever had one real implementation is as senior as adding the right one.
Pitfalls
  • Leaky ports: an EventBus port that exposes MessageGroupId or Kafka partitions isn't an abstraction — it's the SDK with extra steps. If swapping the adapter changes caller behavior, the port failed.
  • Mocking the SDK instead of faking the port: tests full of jest.mock("@aws-sdk/...") assert implementation details and break on every upgrade. Fake your own narrow interface instead.
  • Singleton-as-class with private constructor: untestable global state in TS clothing. Module-scope instance + injection gives you singleness without the lock-in.
  • Decorator ordering bugs: withCache(withRetry(x)) caches failures-turned-successes differently than withRetry(withCache(x)) — order is semantics; document it.
  • Base-class inheritance for reuse: deep hierarchies violate LSP under pressure. Prefer composition (decorators) and small interfaces.
  • Abstracting on the first use: you're guessing the axis of change. Wait for the second concrete case.
Production best practice
  • One composition-root.ts per deployable; nothing else calls new on infrastructure classes.
  • Write contract tests once against the port, run them against every adapter (SQS, Kafka, in-memory) — this enforces LSP mechanically.
  • Keep ports to 1–3 methods; if an interface needs a paragraph of docs per method, split it (ISP).
  • Decorators for retry/metrics/tracing/caching on every outbound client — standardized in a shared package, applied in one known order.
  • In Lambda: module-scope clients, lazy-connect pools, and no DI container on the hot path.
References
Part VII · Design Craft

24. The AWS Building-Blocks Map

The lookup chapter. Every abstract concept in this book has a managed AWS incarnation, and interviewers at AWS-shaped companies expect you to name the service, its limits, and when you'd refuse it and build your own. This is organized as comparison tables — scan the row that matches your design, say the trade-off out loud, and move on. Numbers are order-of-magnitude and drift with AWS releases; the decision logic doesn't.

Real-world analogy

AWS services are a commercial kitchen supplier's catalog. You can buy a combi-oven (Lambda: walk in, cook, walk out, pay per dish), lease a fitted kitchen (ECS/Fargate: your recipes, their equipment), or build your own from ductwork up (EC2/EKS: total control, you fix the extractor fan at 2 a.m.). Nobody wins Michelin stars for welding their own ovens — but a bakery pushing 10,000 loaves a day eventually finds the rented oven's duty cycle, and the economics flip. Managed until scale, economics, or control forces otherwise.

Compute

The axis is ops burden vs control vs cost shape. Scaling granularity: instance (EC2/ASG) → task/pod (ECS/EKS/Fargate) → request (Lambda).

ServiceCold startScaling modelOps burdenCost modelWhen
EC2 + ASGMinutes (boot AMI)Instance count via ASG policiesHigh: OS, patching, AMIsPer-instance-hour; RI/Savings Plans; spot up to ~90% offSpecial hardware (GPU), OS control, steady predictable load, lift-and-shift
ECS on EC2Seconds (task) if capacity existsTasks + cluster ASG (two layers)Medium: hosts yours, orchestration theirsPay for EC2 under it (bin-packing wins)Containers at scale where you'll optimize utilization
EKSSeconds (pod) if capacity existsK8s HPA/KarpenterHigh: cluster upgrades, add-ons, IAM↔RBAC$~75/mo control plane + nodesYou need the K8s ecosystem, multi-cloud posture, or platform team exists
Fargate~30–60 s per task launchTask count; no hosts to manageLowPer vCPU-second + GB-second; ~2–3× EC2 at high utilizationDefault for containers unless utilization economics or daemonsets force EC2
Lambda~100 ms–1 s+ (runtime, bundle size, VPC ENI)Per-request, to thousands of concurrent instantlyMinimalPer-ms + per-request; free at zero trafficSpiky/event-driven/glue; APIs at low-to-mid volume; NOT long-lived connections, >15 min jobs, or steady high QPS (cost crossover vs Fargate)

Decision flowchart: which compute?

Event-driven or spiky, <15 min? yes Lambda watch cold starts no Need GPUs, OS control, daemons? yes EC2 + ASG or EKS w/ node groups no Org runs Kubernetes / multi-cloud mandate? yes EKS needs platform team no Sustained high utilization, cost-sensitive at scale? yes ECS on EC2 bin-pack + spot no Fargate the sane default

Edge & network

ServiceLayer / jobKey featuresWhen
Route 53DNSPolicies: simple, weighted (canary), latency, geolocation, geoproximity, failover (health checks), multivalueAlways; weighted for gradual rollouts, failover for DR, latency for multi-region
CloudFrontCDN, L7 edgeEdge caching, TLS termination, signed URLs, Functions/Lambda@Edge, origin failoverStatic assets, video, API acceleration with caching; DDoS absorption with Shield
Global AcceleratorL4 anycast2 static anycast IPs, routes on AWS backbone, instant regional failover, no cachingNon-HTTP (TCP/UDP, gaming, VoIP), static-IP allowlists, fast failover for dynamic APIs. CloudFront when cacheable HTTP; GA when not
ALBL7 LBPath/host/header routing, WebSockets, gRPC, target groups, OIDC authDefault for HTTP microservices behind one entry point
NLBL4 LBMillions of RPS, static IP/EIP per AZ, TLS passthrough, preserves source IPExtreme throughput, non-HTTP protocols, PrivateLink endpoints
API GW (REST)Managed API front doorAPI keys, usage plans, throttling, request validation, caching, WAFPublic APIs needing keys/quotas; serverless backends. ~10k RPS default limit; adds ~10–30 ms + cost per request
API GW (HTTP API)Lighter, cheaper front door~70% cheaper, lower latency, JWT auth built-in, fewer featuresDefault for Lambda-backed APIs unless you need REST-API extras
AppSyncManaged GraphQLResolvers to Dynamo/Lambda/RDS, subscriptions over WebSocket, offline syncGraphQL with real-time subscriptions without running Apollo yourself

Data

ServiceData modelConsistencyScalingTypical latencyWhen
RDS (Postgres/MySQL)RelationalStrong (single writer); replicas lag asyncVertical + read replicas; ~64 TB ceiling1–10 msDefault OLTP; transactions, joins, constraints
AuroraRelational (PG/MySQL wire-compatible)Strong; replicas ~10–20 ms lag off shared storageStorage auto-grows to 128 TiB; 15 replicas; Serverless v2 scales ACUs; Global Database ~1 s cross-region lag1–10 msRDS needs more: faster failover (~30 s), replica fan-out, cross-region DR, spiky load (Serverless)
DynamoDBWide-column KV / documentEventual by default; strong read option (same region); transactions availableEffectively unlimited via partitioning; 3k RCU/1k WCU per partition; on-demand or provisionedSingle-digit ms; DAX ~µsKnown access patterns, massive scale, predictable latency; NOT ad-hoc queries
DocumentDBDocument (MongoDB-ish API)Strong on primaryReplicas; storage like AuroramsMigrating existing MongoDB workloads; otherwise prefer Dynamo or RDS jsonb
ElastiCache RedisKV + structures (sorted sets, streams)Ephemeral; async replication (can lose acked writes on failover)Cluster mode: shard across nodes<1 msCache, sessions, rate limits, leaderboards, locks (carefully)
MemoryDBRedis-compatible, durableMulti-AZ durable via transaction logShards like Redis cluster~ms writes, <1 ms readsRedis as a primary store (durability), not just a cache
OpenSearchInverted index / documentsNear-real-time (~1 s refresh)Shards across data nodes10–100 ms queriesFull-text search, log analytics, faceting — as a derived read model, never source of truth
RedshiftColumnar warehouseBatch/analyticalMPP cluster or serverlessSecondsOLAP: BI, aggregations over TB–PB; never on the request path
S3Object storeStrong read-after-write (since 2020)Effectively infinite; 3.5k PUT/5.5k GET per prefix/s~10–100 ms first byteBlobs, data lake, backups, static hosting; 11 nines durability

Messaging & orchestration

ServiceOrderingReplayFan-outThroughputWhen
SQS StandardBest-effort (none)No — consumed = goneNo (one consumer group); pair with SNSEffectively unlimitedWork queues, decoupling, buffering writes; at-least-once, so consumers must be idempotent
SQS FIFOStrict per message-groupNoNo300 msg/s/group; 3k/s batched (higher with high-throughput mode)Ordered processing per entity with dedup (5-min window)
SNSNo (FIFO topics exist)NoYes — up to 12.5M subs; filter policiesVery highPub/sub fan-out: one event → many SQS queues/Lambdas (the SNS→SQS fan-out is the classic pattern)
EventBridgeNoArchive + replay (bolt-on)Yes — content-based routing rules, cross-account, SaaS partners, SchedulerModest (~thousands/s soft limits)Low-volume domain events routed by content; the org-wide event bus. Not a firehose
Kinesis Data StreamsPer shard (partition key)Yes — 24 h to 365 dMultiple consumers; enhanced fan-out 2 MB/s each1 MB/s in, 2 MB/s out, 1k rec/s per shard; scale by shardsOrdered, replayable streams on AWS with minimal ops: clickstreams, CDC, metrics
MSK / KafkaPer partitionYes — retention you choose, compacted topicsConsumer groups, unlimited readersTens of MB/s per partition; scale wideKafka ecosystem (Connect, Streams, schema registry), long retention, very high throughput, multi-cloud portability
Step Functionsn/a — orchestrationExecution historyn/aStandard: 1 yr max duration, exactly-once workflow steps; Express: high-volume, ≤5 minSagas, human-in-loop flows, retries/backoff as config; visual audit trail (compliance loves it)

Storage tiers

OptionWhat it isWhen
S3 StandardHot object storageActive data; default
S3 Intelligent-TieringAuto-moves objects between tiers by accessUnknown/shifting access patterns — the lazy-correct default
S3 Standard-IA / One Zone-IACheaper storage, per-GB retrieval fee, 30-day minBackups, older assets read monthly-ish
S3 Glacier Instant / Flexible / Deep ArchiveArchive: ms / minutes–hours / ~12 h retrievalCompliance archives, 7-year fintech retention (Deep Archive ≈ $1/TB-month)
EBSNetwork block device, single-instance attach (io2 multi-attach exception), AZ-boundDatabase volumes; gp3 default, io2 for high-IOPS
EFSManaged NFS, multi-instance, multi-AZShared POSIX file access; higher latency than EBS
Instance storePhysically attached NVMe; data gone on stopScratch, caches, and DBs that replicate anyway (e.g., self-managed Kafka/Cassandra)

ML/misc one-liners: SageMaker = managed train/deploy; Bedrock = hosted foundation-model API; Athena = SQL directly over S3 (serverless, per-TB-scanned); Glue = managed Spark ETL + data catalog; Kinesis Data Firehose = zero-ops delivery of streams into S3/OpenSearch/Redshift.

Managed vs build-your-own

The staff-level default: managed until scale, economics, or control forces otherwise. Your engineers' time is the scarcest resource; undifferentiated heavy lifting is exactly what you rent. The exceptions are real but must be argued with numbers, not vibes.

CriterionCustom gateway vs API GatewaySelf-managed Kafka vs MSK vs KinesisSelf-managed Redis vs ElastiCache
ControlCustom (Envoy/nginx) wins: bespoke auth, exotic protocols, per-route logicSelf-managed wins: broker configs, versions, exotic plugins; Kinesis least controlSelf-managed: modules (RedisBloom), version pinning; ElastiCache covers 95% of needs
Cost at scaleAPI GW per-request pricing gets brutal >~1B req/mo; Envoy on Fargate flat-costsKinesis per-shard cheap small, pricey huge; MSK mid; self-managed cheapest at very high MB/s if you already have the teamComparable until huge fleets; ElastiCache premium is modest
Ops burdenCustom = you own upgrades, WAF, DDoSKafka ops (brokers, ZK/KRaft, rebalances, disk) is a full-time team; MSK removes ~70%; Kinesis ~100%Redis failover/cluster ops are subtle (split-brain); managed handles patching + failover
ComplianceEither; managed inherits AWS attestations (PCI, SOC2) — a fintech accelerantManaged inherits attestations; self-managed = you evidence everythingSame — ElastiCache is PCI-eligible out of the box
Exit strategyEnvoy is portable; API GW is notKafka protocol is portable (MSK → Confluent → self-hosted); Kinesis is AWS-only lock-inRedis protocol is portable either way

Interview phrasing: "I'd start on Kinesis for zero ops; if we outgrow shard economics or need Connect/exactly-once-streams, the port-based design (Ch. 23) makes MSK a contained migration."

Three blueprints you can draw from memory

1. Classic 3-tier web

Route 53 latency + failover CloudFront static + WAF ALB ECS Fargate multi-AZ, autoscaled Aurora writer + replicas ElastiCache cache-aside, TTL Session state in ElastiCache/JWT — services stay stateless, scale horizontally.

2. Serverless API

API Gateway HTTP API + JWT Lambda sync handler DynamoDB single-table SQS buffer + DLQ Lambda workers batch 10, idempotent write async write results Respond fast, defer work: the sync path touches only Dynamo; everything slow goes through SQS.

3. Event streaming pipeline

Producers apps, CDC, IoT Kinesis shards by entity key Lambda / Flink enrich, window, agg S3 data lake partitioned parquet OpenSearch live dashboards batch real-time Replayable log in the middle: reprocess history by re-reading the stream — the lambda/kappa backbone.

Service → open-source equivalent

AWS serviceOSS equivalentWorth knowing
SQSRabbitMQRabbitMQ adds routing exchanges, priorities; SQS adds infinite scale, zero ops
KinesisKafkaSame log abstraction; Kafka richer ecosystem, Kinesis zero ops
DynamoDBCassandra / ScyllaDBBoth descend from the Dynamo paper's ideas; Cassandra gives tunable consistency + multi-DC control
ElastiCacheRedis / ValkeySame engine; managed adds failover + patching
ALBnginx / Envoy / HAProxyEnvoy = programmable L7, the service-mesh data plane
EventBridgeCustom bus on Kafka/Rabbit + routerYou'd hand-build content-based routing, archive, schema registry
Step FunctionsTemporal / AirflowTemporal: code-first, local-testable workflows; Step Functions: JSON state machines, zero infra
AuroraPostgres/MySQL (+ Vitess/Citus to shard)Aurora's trick is disaggregated storage — "the log is the database"
OpenSearchElasticsearchForked from ES 7.10 after the license change
S3MinIO / CephS3 API is the de-facto object-storage standard

SDK v3 in practice: client reuse + composed write

Two things interviewers check in serverless code: clients created once at module scope (warm invocations reuse TCP/TLS connections — recreating per invocation adds tens of ms and leaks sockets), and awareness that an SQS send + DynamoDB write is not atomic.

// clients.ts — module scope: constructed ONCE per Lambda container (cold
// start), reused across warm invocations. SDK v3 is modular: import only
// the clients you use, keeping the bundle (and cold start) small.
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({
  maxAttempts: 3,                      // SDK-managed retry w/ adaptive backoff
}));
const sqs = new SQSClient({ maxAttempts: 3 });
export { ddb, sqs, PutCommand, SendMessageCommand };
// handler.ts — typed order intake: persist THEN publish (write-then-emit).
import { ddb, sqs, PutCommand, SendMessageCommand } from "./clients";

interface OrderPlaced {
  type: "OrderPlaced";
  orderId: string;
  accountId: string;
  amountMinor: number;               // integer minor units — never float money
  currency: "USD" | "EUR" | "GBP";
  placedAt: string;                  // ISO-8601
}

export async function placeOrder(event: OrderPlaced): Promise<void> {
  // 1. Durable write first — conditional put makes retries idempotent.
  await ddb.send(new PutCommand({
    TableName: process.env.ORDERS_TABLE!,
    Item: { pk: `ORDER#${event.orderId}`, sk: "META", ...event },
    ConditionExpression: "attribute_not_exists(pk)",   // reject duplicates
  }));

  // 2. Then emit. If THIS fails after the write, we have a dropped event —
  // the dual-write problem. Production fix: DynamoDB Streams or an outbox
  // table + CDC, so the event derives from the committed write.
  await sqs.send(new SendMessageCommand({
    QueueUrl: process.env.ORDER_EVENTS_QUEUE!,
    MessageBody: JSON.stringify(event),
    MessageAttributes: {
      eventType: { DataType: "String", StringValue: event.type },
    },
  }));
}
// Cold-start notes: bundle with esbuild/tree-shaking (SDK v3 modularity
// matters), avoid VPC unless the Lambda truly needs private resources,
// and consider provisioned concurrency only for p99-sensitive sync APIs.
AWS mapping — when to build custom instead
  • Gateway: API Gateway until per-request pricing or bespoke routing hurts (~1B+ req/mo) → Envoy/nginx on Fargate behind an NLB.
  • Streams: Kinesis → MSK when you need Kafka ecosystem/retention/throughput → self-managed Kafka only with a dedicated platform team and a numbers-backed case.
  • Cache: ElastiCache almost always; self-managed Redis only for modules AWS doesn't ship.
  • Workflows: Step Functions for AWS-native sagas; Temporal (self-hosted/cloud) when workflows are complex enough to demand code-first versioned logic.
  • Search: OpenSearch Service; self-managing Elasticsearch clusters is a famous ops tarpit.
  • Frame every one of these as: managed default, named trigger condition for switching, and the port-based design that keeps the switch cheap.
What interviewers probe
  • Q: "Why Aurora over RDS?" A: Disaggregated storage: compute nodes share a replicated storage layer that only ships redo log records — so failover in ~30 s (vs minutes), 15 low-lag replicas off shared storage (vs replica-per-copy), auto-growing storage to 128 TiB, and Global Database for ~1 s cross-region RPO. Pay more per instance; worth it when availability or replica fan-out matters. RDS is fine for modest, steady workloads.
  • Q: "Kinesis vs SQS?" A: Different abstractions: SQS is a queue — message consumed and deleted, competing workers, no order, no replay. Kinesis is a log — ordered per shard, retained, multiple independent consumers, replayable. Task distribution → SQS; event stream with multiple readers or reprocessing → Kinesis.
  • Q: "When is Lambda the wrong answer?" A: Long-lived connections (WebSockets — use API GW WebSocket or Fargate), >15-min jobs, p99-critical paths where cold starts bite, steady high-volume traffic where per-ms pricing crosses over Fargate (roughly: sustained >~30–50% utilization equivalent), and anything needing GPUs or huge memory.
  • Q: "ALB or API Gateway in front of your services?" A: ALB for internal/high-QPS HTTP (flat cost, lower latency); API GW when you need API keys, usage plans, request validation, or Lambda-native integration for a public API.
  • Q: "How do you avoid lock-in?" A: Not by avoiding managed services — by ports & adapters at the code layer, portable protocols where cheap (Kafka, Redis, S3 API, Postgres wire), and accepting lock-in deliberately where the managed win is huge (DynamoDB) with an exit cost written down.
  • Q: "MSK or Kinesis for a new event platform?" A: Kinesis to start: zero ops, per-shard scaling, native Lambda triggers. Trigger conditions for MSK: Kafka Connect/Streams ecosystem, >7-day-to-year retention with compaction, throughput where shard economics break, or a multi-cloud mandate.
Pitfalls
  • Dual-write: DB write + queue publish without an outbox/stream = dropped or phantom events. Say "outbox pattern" or "DynamoDB Streams" unprompted.
  • Hot partition: Dynamo's per-partition cap (3k RCU/1k WCU) throttles a hot key no matter how big the table; same for a hot Kinesis shard. Design keys for spread.
  • Lambda-in-VPC by default: only attach a VPC when it must reach private resources; it adds ENI cold-start cost and NAT expense for internet egress.
  • SQS Standard treated as ordered/exactly-once: it's at-least-once, unordered — idempotent consumers are mandatory, not optional.
  • EventBridge as a firehose: its throughput limits are meant for domain events, not clickstreams — that's Kinesis/MSK territory.
  • Quoting exact limits as gospel: AWS raises limits constantly. Say "order of magnitude, roughly X per shard as of my last check" — precision theater reads worse than calibrated approximation.
Production best practice
  • Multi-AZ is table stakes for anything stateful; multi-region only when the business case (RTO/RPO) justifies the complexity tax.
  • Every queue gets a DLQ + alarm on depth and age-of-oldest-message; every Lambda gets reserved-concurrency thinking so one consumer can't starve the account.
  • Tag-based cost allocation from day one; the "managed premium" argument needs numbers when finance asks.
  • Infrastructure as code (CDK/Terraform) for everything — the console is for reading, not writing.
  • Prefer service-native idempotency knobs (SQS FIFO dedup, Dynamo conditional writes, Lambda event-source retries) before writing your own.
References
Part VII · Design Craft

25. Worked Interview Problems

Six classics, each run through the Chapter 1 framework in compressed form: clarify → estimate → API → high-level design → deep dives → 10× → staff flourishes. These are not six different games — interviewers recycle the same handful of archetypes with new skins. Internalize these six and you have coverage for most of what gets asked; the payment system at the end is the fintech finale and the deepest of the set.

Problem 1 — URL shortener (the warm-up)

Deceptively simple; the interviewer is checking whether you run the framework under low pressure before they escalate.

  • Clarify: Custom aliases? (yes, optional). Expiry? (yes, default none). Analytics? (click counts, yes). Scale? (assume 100M new URLs/month, 10B redirects/month). Redirect latency target? (<50 ms p99).
  • Estimate: Writes ~40/s; reads ~4,000/s — 100:1 read-heavy. Storage: 100M/mo × 500 B ≈ 50 GB/yr — tiny; the problem is read latency, not data volume. 627 ≈ 3.5 trillion codes — 7 chars lasts decades.
  • API: POST /links {url, alias?, ttl?} → {code}; GET /{code} → 302; GET /links/{code}/stats.
  • Design: stateless API tier; Redis cache in front of the link store; async click events to a stream. Key generation: counter + base62 (a range-allocating ticket server hands each app node a block of 100k IDs) beats hashing — no collisions by construction, no retry loop. Hash (MD5 first 7 chars) needs collision detection + re-salt; mention it, then discard it.
  • Deep dives: (1) 301 vs 302 — 301 is cached permanently by browsers, so you lose every subsequent click for analytics; 302 keeps traffic flowing through you. Choose 302 and say why. (2) Cache policy: cache-aside, 24 h TTL, ~95% hit rate makes the DB nearly idle. (3) DB: DynamoDB or Postgres both fine at this size — key-value lookup by code; unique index on alias.
  • 10×: add CDN/edge redirects for the hottest 1% of codes; shard the counter ranges; replicate read caches per region.
  • Staff flourishes: expiry via TTL column + lazy delete on read (plus a sweeper); rate-limit link creation (abuse/spam); reserve profanity/lookalike codes; the analytics pipeline is fire-and-forget so a Kafka outage never blocks redirects.
Client API service Redis cache ~95% hit, 24h TTL Link store DynamoDB / Postgres Click stream Kafka → warehouse GET /aZ3kQ9x 1 2 on miss 3 async click event
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

/** Counter → short code. 62^7 ≈ 3.5T — no collisions by construction. */
export function toBase62(id: bigint): string {
  if (id === 0n) return "0";
  let out = "";
  while (id > 0n) { out = ALPHABET[Number(id % 62n)] + out; id /= 62n; }
  return out;
}

interface ShortLink { code: string; targetUrl: string; expiresAt?: string }

/** Redirect handler: cache-aside read path + non-blocking analytics. */
export async function redirect(req: Request): Promise<Response> {
  const code = new URL(req.url).pathname.slice(1);
  const cached = await redis.get(`url:${code}`);                 // ~1 ms hot path
  const link: ShortLink | null = cached
    ? (JSON.parse(cached) as ShortLink)
    : await db.links.findByCode(code);                           // ~5 ms miss path
  if (!link || (link.expiresAt && new Date(link.expiresAt) < new Date())) {
    return new Response("Not found", { status: 404 });
  }
  if (!cached) await redis.set(`url:${code}`, JSON.stringify(link), { EX: 86_400 });
  clickStream.publish({ code, at: Date.now(), ref: req.headers.get("referer") }); // fire-and-forget
  return Response.redirect(link.targetUrl, 302);                 // 302: clicks stay measurable
}

Problem 2 — Distributed rate limiter

Deep mechanics live in Chapter 17; here is the interview-shaped compression. No separate diagram needed — it is the shortener's read path with Redis moved into the hot path instead of beside it: client → API gateway → Redis check → upstream service.

  • Clarify: Per-API-key or per-user? (both, config-driven). Hard or soft limits? (soft — bursts allowed). Added latency budget? (<2 ms). Must survive limiter outage? (yes — decide fail-open vs fail-closed per endpoint).
  • Estimate: 50k req/s across the fleet → one Redis cluster node handles ~100k simple ops/s; a few shards by key hash suffice. State per key: two numbers (~100 B) — millions of keys fit in one node's RAM.
  • API: internal: check(key, cost) → {allowed, remaining, retryAfterMs}; surface 429 + Retry-After to clients.
  • Design: token bucket per key stored in Redis as {tokens, lastRefillMs}; refill computed lazily on each check. The read-compute-write must be atomic — a Lua script executes refill + decrement as one Redis operation, eliminating the race between two gateways checking the same key simultaneously.
  • Deep dives: (1) Fail-open vs fail-closed: Redis down → fail-open for product traffic (availability wins), fail-closed for login/OTP/payment-auth endpoints (abuse cost wins). Say it as a per-route policy, not one global answer. (2) Sync vs async counting: exact = every request round-trips Redis (+1–2 ms); approximate = count locally per gateway, sync deltas every ~100 ms — 10× cheaper, allows ~N-gateway× brief overshoot. Marketing API? async. Payments? sync.
  • 10× / multi-region: per-region independent limits (each region gets quota/N) — simple and robust; a global strongly-consistent counter costs cross-region RTT on every request and is almost never worth it. Global abusers get caught by a slower async aggregation layer.
  • Staff flourishes: return rate-limit headers always (clients self-throttle); shadow-mode a new limit before enforcing; monitor rejected-rate per key — a spike is either an attack or a client bug, both worth an alert.

Problem 3 — Notification system (push / email / SMS)

  • Clarify: Channels? (push, email, SMS). Broadcast to millions? (yes — celebrity/announcement case). Delivery guarantee? (at-least-once with dedup). Priorities? (OTP must beat marketing). Provider rate limits? (yes — e.g. SES and Twilio both throttle).
  • Estimate: 50M notifications/day ≈ 600/s average, 10× spikes on broadcast; a broadcast to 10M users must be spread over minutes, not fired in one second at Twilio.
  • API: POST /notifications {id, userId|segment, templateId, data, channels, priority} — the caller supplies id as the idempotency key.
  • Design (diagram below): ingest → validate + dedup → user preference & quiet-hours check → channel router → per-channel, per-priority queues → provider workers with client-side rate limits → retries with backoff → DLQ → delivery-receipt tracking (provider webhooks update status).
  • Deep dives: (1) Idempotency/dedup: dedup store keyed on notification id, 7-day TTL — retried ingestion never double-sends an OTP. (2) Priority lanes: separate queues per priority so 5M queued marketing emails can never delay a login code; workers drain *.transactional first. (3) Broadcast fan-out: never enqueue 10M messages synchronously in the API call — write one broadcast row, let fan-out workers expand it in batches (the celebrity problem again, wearing a different hat).
  • 10×: partition queues by userId hash; template rendering as a stateless pre-send step (cache compiled templates); per-provider worker pools scale independently — SMS is the slow lane.
  • Staff flourishes: providers fail regionally — support fallback routing (Twilio → SNS); track per-provider delivery rate and cost per message (SMS ≈ 100× email cost — route by value); receipts feed a status API so support can answer "did the user get it?".
Ingest API validate + dedup(id) Preference svc opt-outs, quiet hours Channel router Queues: priority lanes push.high/low email.high/low sms.high/low Workers: rate-limited Push worker Email worker SMS worker APNs / FCM SES Twilio DLQ Delivery status provider webhooks max retries
type Channel = "push" | "email" | "sms";
type Priority = "transactional" | "marketing";        // OTP beats promo, always

interface Notification {
  id: string;                       // idempotency key — dedup on (id, channel)
  userId: string;
  templateId: string;
  data: Record<string, string>;     // template variables
  channels: Channel[];
  priority: Priority;
  createdAt: string;                // ISO-8601
}

interface UserPrefs {
  optOut: Set<Channel>;
  quietHours?: { startHour: number; endHour: number; tz: string };
}

/** Route one notification into per-channel priority queues after policy checks. */
export async function routeNotification(n: Notification, prefs: UserPrefs): Promise<void> {
  if (await dedupStore.seen(n.id)) return;            // at-least-once in → idempotent out
  for (const channel of n.channels) {
    if (prefs.optOut.has(channel)) continue;
    // Marketing respects quiet hours; transactional (OTP, alerts) never waits.
    if (n.priority === "marketing" && inQuietHours(prefs.quietHours)) {
      await scheduler.deferUntilMorning(n, channel);
      continue;
    }
    await queues.publish(`${channel}.${n.priority}`, { ...n, channel });
  }
  await dedupStore.mark(n.id, { ttlSeconds: 7 * 86_400 });
}

Problem 4 — Chat system (WhatsApp-scale)

  • Clarify: 1:1 and groups? (yes, groups ≤1,024). Delivery states? (sent/delivered/read). Offline delivery? (yes). Media? (out of scope — presigned blob URLs, one sentence). E2E encryption? (acknowledge, one paragraph).
  • Estimate: 500M DAU × 40 msgs/day ≈ 230k msgs/s average, ~1M/s peak. Write-heavy — the opposite of the feed problem. Connections: 100M concurrent → at ~1M connections/box (epoll + small per-conn state), a fleet of 100–200 gateways.
  • API: WebSocket frames: send{convId, clientMsgId, body}, ack{msgId, state}; HTTP for history: GET /conversations/{id}/messages?before=msgId.
  • Design (diagram below): clients hold one WebSocket to a gateway; a connection registry (Redis, userId → gatewayId, TTL-refreshed by heartbeat) lets the chat service route to whichever gateway holds the recipient. Online: deliver via recipient's gateway. Offline: park in a per-user inbox queue + fire a push notification; drain the inbox on reconnect.
  • Deep dives: (1) Storage: Cassandra-style — partition key conversationId, clustering key a time-ordered msgId (Snowflake-style, Chapter 18): one partition per conversation, messages physically sorted, history reads are one slice. (2) Delivery states via acks: server ack → "sent"; recipient-device ack → "delivered"; read event → "read". Client retries with the same clientMsgId; server dedups — at-least-once transport, exactly-once appearance. (3) Group fan-out: write the message once per conversation, fan out pointers to member inboxes; for very large groups flip to read-time pull (same write-vs-read fan-out dial as the news feed).
  • Presence: heartbeat every ~30 s refreshes a Redis key with 60 s TTL; expiry = offline. Never broadcast presence to all contacts on every flap — subscribers pull on conversation open, plus debounced pushes.
  • E2E encryption (one paragraph): Signal protocol — clients exchange public keys via the server; server relays ciphertext it cannot read. Consequences you should name: server-side search and content moderation become impossible, multi-device needs per-device sessions, and delivery metadata remains visible to the server.
  • 10× / staff: shard registry and inboxes by userId; gateways are cattle — on gateway death clients reconnect through the LB and re-register (registry TTL self-heals); monitor end-to-end delivery latency p99, not just server CPU.
Mobile A Mobile B WS gateway fleet Gateway 1 Gateway 2 Conn registry userId → gw, TTL 60s Chat service Messages Cassandra: convId + msgId Offline inbox Push (APNs/FCM) 1 2 3 lookup B 4 B online 5 B offline wake acks (delivered / read) travel the reverse path
Real-world analogy

The connection registry is a hotel switchboard: guests (users) check in at whatever room (gateway) was free, the switchboard keeps the room ledger current, and callers never need to know the room number — they ask the operator. When a guest checks out silently (connection drop), the ledger entry simply expires. The offline inbox is the pigeonhole behind the front desk: messages wait there, and the bellhop (push notification) knocks to say something arrived.

Problem 5 — News feed (read-heavy fan-out)

  • Clarify: Follow graph or friend graph? (follow — asymmetric, so celebrities exist). Ranked or chronological? (ranked, but design for chrono first). Feed latency? (<200 ms p99). Post-visibility delay tolerable? (seconds — eventual consistency is fine).
  • Estimate: 300M DAU, 5 feed loads/day ≈ 17k reads/s; 2M posts/day ≈ 25 writes/s — but each write fans out to avg 200 followers → 5k feed-cache writes/s, and a 50M-follower celebrity would mean 50M writes for one post. That single number decides the architecture.
  • API: POST /posts; GET /feed?cursor={snowflakeId}&limit=20 — cursor pagination by time-sortable ID (Chapter 18), never offset (offset breaks as new posts land).
  • Design: hybrid fan-out is THE answer — push for normal users (write post → queue → fan-out workers append post ID to each follower's Redis feed list), pull for celebrities (their posts are merged in at read time from a per-author list). Read path: fetch own feed-cache IDs + pull followed celebrities' recent IDs → merge → rank → hydrate from post store. Cap feed lists (~800 IDs, LTRIM); skip fan-out for followers inactive >30 days and rebuild their feed lazily on login.
  • Deep dives: (1) why pure push dies (celebrity write storm) and pure pull dies (reading 1,000 followees' timelines per feed load); (2) ranking as a separate stateless service — candidates in, scores out — so ML iteration never touches feed infrastructure; (3) write path fully async via queue: post creation returns fast, fan-out lag is invisible because nobody knows a post exists seconds after creation.
  • 10× / staff: shard feed caches by userId; feed cache is disposable — Redis loss degrades to pull-and-rebuild, not an outage; watch fan-out queue lag as the core SLO; A/B ranking behind a flag; cost lever: feed-list length × DAU is your Redis bill.
Author has >100k followers? no yes PUSH: fan out post ID append to follower feed caches PULL: merge at read time from per-author recent-post list skip followers inactive >30d; lazy rebuild on login Write path (async) Post service Queue Fan-out workers Feed caches Redis list per user, cap 800 Read path (<200 ms) Client Feed service Ranking svc Post store hydrate IDs → content cursor 1 get IDs + celebrity pull 2 3
Real-world analogy

Push fan-out is newspaper home delivery: cheap when each paperboy serves a street, absurd if one publisher (the celebrity) suddenly has 50 million subscribers on one route. Pull is the newsstand: readers walk over and grab the latest — fine for a few famous publishers on the corner, exhausting if you had to visit a thousand stands to assemble your morning reading. The hybrid delivers the neighborhood papers and lets you grab the big mastheads from the stand.

Problem 6 — Payment system (the fintech finale)

The one where the priority stack inverts: correctness > auditability > availability > latency. Say that sentence in the first two minutes and the interview tilts your way.

  • Clarify: We orchestrate an external PSP (Stripe/Adyen), not acquiring ourselves? (yes). Volume? (1M payments/day ≈ 12 TPS — tiny; the difficulty is correctness, not scale, and saying so is a signal). Refunds/partial captures? (yes). Multi-currency? (design for it; demo single).
  • API: POST /payments with a mandatory Idempotency-Key header; GET /payments/{id}; POST /payments/{id}/capture | /refund. Amounts are integer minor units — never floats.
  • Design (diagram below): gateway checks/records the idempotency key → payment service drives a strict state machine → every money movement lands in an append-only double-entry ledger → PSP adapter isolates the flaky external world → nightly reconciliation compares our ledger against PSP settlement reports.
  • Deep dive 1 — double-entry ledger: every transfer writes a balanced debit/credit pair in one DB transaction; sum(debits) = sum(credits) holds by construction. No UPDATEs, no DELETEs — corrections are new reversing entries. Balances are a derived cache, rebuildable by replaying the ledger. This is also your audit log with arithmetic teeth.
  • Deep dive 2 — exactly-once effect: networks give you at-least-once; you manufacture exactly-once with idempotency at every hop: client → API (idempotency key), service → PSP (same key on every retry — Stripe dedups server-side), PSP webhook → us (dedup on event ID). Timeout on a PSP call means unknown outcome: never auto-refund, retry with the same key or query status.
  • Deep dive 3 — state machine + saga: created → authorized → captured → settled, with failed/refunded branches; illegal transitions throw (typed below). Multi-step flows (auth → ledger hold → capture → payout) run as a saga with compensating actions (Chapter 10) — you cannot wrap Stripe and your DB in one ACID transaction, so you sequence and compensate.
  • Reconciliation: a nightly job diffs internal ledger vs PSP report; classify mismatches (missing capture, amount drift, orphan refund) into an exception queue for ops. Expect ~0.01% mismatch as steady state; alert on trend, not existence.
  • Staff flourishes: outbox pattern for emitting payment events (no dual-write); PCI scope: never touch PANs — client tokenizes directly with the PSP, keeping your services out of PCI-DSS audit scope; fail-closed everywhere; ledger DB gets the strongest durability money buys (synchronous replication, PITR).
Real-world analogy

Reconciliation is balancing the till at closing time: the register tape (your ledger) says the drawer should hold 1,482.50; you count the cash (the PSP settlement report). A small, explained variance gets logged and investigated; a growing one means a broken process or a thief. Shops that skip the nightly count discover fraud months late — payment systems that skip reconciliation discover it at audit time, which is worse.

Client API gateway Idempotency-Key Payment service state machine PSP adapter retry w/ same key External Stripe / Adyen Ledger (append-only) debits = credits Reconciliation job nightly diff → exceptions Audit log / outbox payment events atomic DB tx read ledger settlement report
/** One row per account movement. Append-only: no UPDATE, no DELETE, ever. */
interface LedgerEntry {
  id: string;                        // ULID — time-sortable, unique
  transferId: string;                // groups the balanced pair
  accountId: string;
  direction: "debit" | "credit";
  amountMinor: bigint;               // integer cents — never floats for money
  currency: "USD" | "EUR" | "INR";
  createdAt: string;
}

interface TransferRequest {
  idempotencyKey: string;            // UNIQUE index on transfers table
  fromAccount: string;
  toAccount: string;
  amountMinor: bigint;
  currency: LedgerEntry["currency"];
}

/** Balanced debit/credit pair in ONE DB transaction. Retry-safe by key. */
export async function postTransfer(req: TransferRequest): Promise<{ transferId: string }> {
  return db.transaction(async (tx) => {
    // Replay? Return the original result — the exactly-once backstop.
    const prior = await tx.transfers.findByIdempotencyKey(req.idempotencyKey);
    if (prior) return { transferId: prior.id };

    const bal = await tx.balances.lockForUpdate(req.fromAccount); // SELECT ... FOR UPDATE
    if (bal.availableMinor < req.amountMinor) throw new InsufficientFundsError(req.fromAccount);

    const transferId = ulid();
    await tx.transfers.insert({ id: transferId, idempotencyKey: req.idempotencyKey });
    const base = {
      transferId, amountMinor: req.amountMinor,
      currency: req.currency, createdAt: new Date().toISOString(),
    };
    await tx.ledger.insertMany([
      { ...base, id: ulid(), accountId: req.fromAccount, direction: "debit" },
      { ...base, id: ulid(), accountId: req.toAccount,   direction: "credit" },
    ]); // sum(debits) === sum(credits) by construction

    await tx.balances.apply(req.fromAccount, -req.amountMinor);  // derived cache
    await tx.balances.apply(req.toAccount, req.amountMinor);
    return { transferId };
  });
}
/** Payment lifecycle as a discriminated union — illegal states unrepresentable. */
type PaymentState =
  | { status: "created";    paymentId: string }
  | { status: "authorized"; paymentId: string; pspAuthId: string; authExpiresAt: string }
  | { status: "captured";   paymentId: string; capturedMinor: bigint }
  | { status: "settled";    paymentId: string; settledAt: string }
  | { status: "failed";     paymentId: string; reason: string; retriable: boolean }
  | { status: "refunded";   paymentId: string; refundId: string };

const LEGAL: Record<PaymentState["status"], PaymentState["status"][]> = {
  created:    ["authorized", "failed"],
  authorized: ["captured", "failed"],     // auth expiry → failed
  captured:   ["settled", "refunded"],
  settled:    ["refunded"],
  failed:     [],                         // terminal
  refunded:   [],                         // terminal
};

export function transition(cur: PaymentState, next: PaymentState): PaymentState {
  if (!LEGAL[cur.status].includes(next.status)) {
    throw new IllegalTransitionError(`${cur.status} → ${next.status}`);
  }
  auditLog.append({ paymentId: cur.paymentId, from: cur.status, to: next.status, at: Date.now() });
  return next;
}

How to practice: the mock loop drill

Reading worked solutions builds recognition; only self-runs build recall under a clock.

  • The loop: pick one archetype above → set a 35-minute timer → run the full framework out loud on a whiteboard or blank doc, no notes → then diff your run against this chapter. Log what you skipped (it is almost always estimation or failure modes).
  • Rotate: one problem per evening, six evenings, then re-run your weakest two. Second passes should reach the deep dives by minute 20.
  • Mutate: re-run each with one twist — shortener with vanity-domain multi-tenancy, chat with 100k-member channels (Discord territory), payments with payouts/marketplace splits. Twists are exactly what strong interviewers do live.
ProblemChapters it exercisesThe decisive insight
URL shortenerCh 2 (estimation), 6 (caching), 9 (KV stores)Counter+base62 kills collisions; 302 keeps analytics
Rate limiterCh 6 (Redis), 17 (algorithms), 12 (multi-region)Atomic Lua bucket; fail-open/closed is per-route
Notification systemCh 10 (queues), 14 (retries/DLQ), 17 (provider limits)Priority lanes: OTP must never queue behind marketing
Chat systemCh 9 (wide-column), 13 (WebSockets), 18 (IDs/ordering)Connection registry + per-user inbox for offline
News feedCh 6 (Redis lists), 10 (async fan-out), 18 (cursors)Hybrid fan-out: push for most, pull for celebrities
Payment systemCh 8 (transactions), 10 (sagas/outbox), 14 (idempotency)Append-only double-entry ledger + idempotency end-to-end
What interviewers probe
  • Q: "Design Ticketmaster / Uber / a parking garage" — something not on this list. A: Map it to an archetype out loud: "This is inventory + contention, so it is the payments/ledger shape with a reservation state machine." Interviewers reuse roughly eight archetypes; naming the mapping is itself a staff signal.
  • Q: "What breaks first at 10×?" A: Name a specific component and metric ("fan-out queue lag passes feed-freshness SLO"), not "we'd add more servers."
  • Q: "What if this dependency is down?" A: Have a per-dependency answer ready: cache loss = degrade to source; PSP timeout = unknown outcome, retry same key; Redis limiter down = per-route fail-open/closed.
  • Q: "Why that database?" A: Tie to access pattern and numbers from your own estimate ("write-heavy, partition-local reads → wide-column"), never to fashion.
  • Q: "How do you know it works?" A: One SLO per system: redirect p99, delivery latency, feed freshness, ledger-vs-PSP mismatch rate — and the alert on each.
  • Q: "Walk me through a duplicate request." A: Trace the idempotency key through every hop; the candidate who traces the retry path end-to-end stands out immediately.
Pitfalls
  • Diving into DB schema before requirements. Designing tables for an unscoped problem reads as junior; spend the first five minutes buying constraints.
  • Ignoring failure paths. A design with no answer for "the queue is down" or "the PSP timed out" fails the staff bar regardless of how pretty the happy path is.
  • Hand-waving "just use Kafka." Naming a technology is not a design. Say what the topic key is, what ordering you get, what happens to consumers on lag, and why a queue is even needed here.
  • Skipping estimation, then guessing architecture. The 100:1 read ratio (shortener) and the 50M-follower write storm (feed) are the facts that pick the architecture — without the numbers you are pattern-matching blind.
  • One global answer to fail-open vs fail-closed. It is a per-endpoint business decision; saying so is worth more than either default.
Production best practice
  • Every mutating API in every one of these systems takes an idempotency key. Retries are not an edge case; they are the steady state of distributed systems.
  • Every queue gets a DLQ, a max-retry policy, and an alert on DLQ depth — a silent DLQ is a data-loss incident on a delay timer.
  • Make caches disposable by design (feed cache, link cache): losing one must degrade latency, never correctness.
  • For money: append-only ledger, integer minor units, reconciliation from day one, and outbox instead of dual-writes.
AWS mapping
  • Shortener: CloudFront + Lambda@Edge (hot redirects), DynamoDB (links), ElastiCache Redis, Kinesis → S3/Athena for clicks.
  • Rate limiter: API Gateway usage plans for coarse limits; ElastiCache Redis + Lua when you need per-tenant token buckets (build custom at that point).
  • Notifications: SQS (priority via separate queues), SNS mobile push, SES, Pinpoint if you want managed campaigns; DLQs are native SQS redrive.
  • Chat: API Gateway WebSockets works to ~medium scale; beyond that, self-managed gateways on EC2/EKS + ElastiCache registry + Keyspaces/DynamoDB for messages.
  • Feed: ElastiCache Redis (feed lists), Kinesis/MSK (fan-out), DynamoDB (posts), SageMaker endpoint for ranking.
  • Payments: Aurora Postgres (ledger — you want real ACID), Step Functions for sagas, EventBridge for payment events, QLDB only if an immutable-by-storage ledger is a compliance requirement.
References
Part VIII · DS & Algorithms

26. Data Structures in Real Systems

This is not a leetcode drill. Every structure below is mapped to where it actually lives in production — which database, which cache, which scheduler — plus a compact TypeScript implementation for the ones interviewers make you build. Staff-level answers connect "I know a heap" to "that's why LSM compaction merges SSTables with one" — this chapter is that connective tissue.

Arrays and the cache-locality truth

The dynamic array (JS Array, Java ArrayList, Go slice) is a contiguous block that doubles when full. Amortized O(1) append, O(1) index, O(n) middle insert. On paper a linked list beats it for inserts; in practice it almost never does.

  • Why arrays win: CPUs fetch memory in 64-byte cache lines and prefetch sequential access. Scanning an array streams through cache; chasing linked-list pointers takes a ~100ns main-memory stall per node. A 100× constant factor eats a lot of big-O.
  • Doubling growth keeps total copy work O(n) across n appends (1+2+4+…+n < 2n) — that's what "amortized" means here.
  • Real system sightings: Redis listpack/ziplist — small hashes, lists, and sorted sets are stored as one flat byte array, not node structures, purely for cache locality and memory overhead. Columnar stores (Parquet, ClickHouse, Redshift) are arrays-per-column so analytics scans stream sequentially. Kafka's log is conceptually an append-only array of records.
Real-world analogy

An array is a row of numbered parking bays: slot 42 is instant to find, and walking the row is fast because everything is adjacent. A linked list is a scavenger hunt where each clue tells you the address of the next clue — even if each hop is "O(1)", you're driving across town per element.

Hash maps — the workhorse

Key → bucket via hash function; average O(1) get/put. Two things interviewers actually probe: load factor and collision handling.

  • Load factor = entries / buckets. Past ~0.75 collisions spike, so the map resizes (usually 2×) and rehashes. Resizing a huge map in one shot causes a latency spike — which is why Redis's dict does incremental rehashing: it keeps old and new tables side by side and migrates a few buckets per operation, so no single command pays the whole bill.
  • Chaining: each bucket holds a list (Java HashMap, which upgrades hot buckets to red-black trees). Simple, tolerates high load factors.
  • Open addressing: collisions probe to the next slot in the same array. Better cache locality, no per-entry pointer overhead; degrades hard near full. Used by Python dicts, Rust's hashbrown/SwissTable, Go maps.
  • Sightings: literally every cache (ch08); Redis's core keyspace dict; database hash joins (ch09) — build a hash table on the smaller table, probe with the larger; V8 hidden-class property lookup.
// Fixed-capacity open-addressing map with linear probing.
// Production maps resize past load factor ~0.7; this shows the probe mechanics.
class ProbingMap<V> {
  private keys: (string | undefined)[];
  private vals: (V | undefined)[];
  private size = 0;

  constructor(private capacity = 16) {
    this.keys = new Array(capacity);
    this.vals = new Array(capacity);
  }

  private hash(key: string): number {
    let h = 2166136261;                        // FNV-1a: fast, non-crypto
    for (let i = 0; i < key.length; i++) {
      h ^= key.charCodeAt(i);
      h = Math.imul(h, 16777619);
    }
    return (h >>> 0) % this.capacity;
  }

  set(key: string, value: V): void {
    if ((this.size + 1) / this.capacity > 0.7) {
      throw new Error("load factor exceeded — a real map would resize here");
    }
    let i = this.hash(key);
    while (this.keys[i] !== undefined && this.keys[i] !== key) {
      i = (i + 1) % this.capacity;             // linear probe: try next slot
    }
    if (this.keys[i] === undefined) this.size++;
    this.keys[i] = key;
    this.vals[i] = value;
  }

  get(key: string): V | undefined {
    let i = this.hash(key);
    while (this.keys[i] !== undefined) {       // probe until an empty slot
      if (this.keys[i] === key) return this.vals[i];
      i = (i + 1) % this.capacity;
    }
    return undefined;                          // hit empty slot: key absent
  }
}

Linked lists, stacks, queues — brief but honest

  • Linked list: O(1) insert/delete given a node reference, O(n) to find that node, terrible cache behavior. Honest note: rarely the best structure alone — it shines glued to something else: the doubly-linked recency chain in an LRU cache (ch08), buffer chains in network stacks, free lists in allocators. Kafka-style systems chain fixed-size buffer segments rather than one giant array.
  • Stack (LIFO): call stacks (every stack trace you've read), undo history, DFS, expression/bracket parsing, editor transaction logs.
  • Queue (FIFO) / deque: BFS frontiers, task queues (SQS is a distributed queue, ch12), connection backlogs. A deque powers the sliding-window-maximum trick and work-stealing schedulers (steal from the tail, own work from the head — Go runtime, Tokio).

Heap / priority queue — "give me the most urgent thing"

A binary heap is a complete tree stored in an array (parent at (i-1)/2): O(log n) push/pop, O(1) peek-min. No sorted order overall — just "the top is the extreme".

  • Sightings: OS and Kubernetes schedulers (pick highest-priority runnable task); Dijkstra's frontier; timer queues (Node.js and Go runtimes heap timers; kernels often prefer timer wheels — O(1) but coarser); top-K queries (keep a size-K min-heap, O(n log k), bounded memory); k-way merge of sorted streams — this is exactly how LSM-tree compaction merges SSTables (ch09) and how external sort merges runs (ch27).
// Binary min-heap over an array. `less(a, b)` = a has higher priority.
class MinHeap<T> {
  private a: T[] = [];
  constructor(private less: (x: T, y: T) => boolean) {}
  get size(): number { return this.a.length; }
  peek(): T | undefined { return this.a[0]; }

  push(item: T): void {
    this.a.push(item);
    let i = this.a.length - 1;
    while (i > 0) {                            // sift up toward the root
      const parent = (i - 1) >> 1;
      if (!this.less(this.a[i], this.a[parent])) break;
      [this.a[i], this.a[parent]] = [this.a[parent], this.a[i]];
      i = parent;
    }
  }

  pop(): T | undefined {
    if (this.a.length === 0) return undefined;
    const top = this.a[0];
    const last = this.a.pop()!;
    if (this.a.length > 0) {
      this.a[0] = last;
      let i = 0;
      for (;;) {                               // sift down to restore order
        const l = 2 * i + 1, r = l + 1;
        let min = i;
        if (l < this.a.length && this.less(this.a[l], this.a[min])) min = l;
        if (r < this.a.length && this.less(this.a[r], this.a[min])) min = r;
        if (min === i) break;
        [this.a[i], this.a[min]] = [this.a[min], this.a[i]];
        i = min;
      }
    }
    return top;
  }
}

// Top-K pattern: min-heap of size K; evict the smallest when a bigger score
// arrives. n items -> O(n log k) time, O(k) memory. Works on infinite streams.
Real-world analogy

A heap is an ER triage board: it does not keep every patient in sorted order — it only guarantees the most critical case is always on top, and re-shuffles cheaply when someone new arrives. That "only the top matters" relaxation is why it beats a fully sorted structure.

Trie / prefix tree — autocomplete's skeleton

A tree keyed character-by-character: all strings sharing a prefix share a path. Lookup is O(length of key), independent of how many keys are stored.

  • Sightings: autocomplete/typeahead (walk to the prefix node, DFS a capped number of completions — production systems precompute top-N suggestions per node); IP routing longest-prefix match — routers store CIDR prefixes in a bit-trie and pick the deepest match; spell checkers and T9; Lucene's term dictionary uses a trie-like FST.
interface TrieNode {
  children: Map<string, TrieNode>;
  terminal: boolean;                           // a stored word ends here
}

class Trie {
  private root: TrieNode = { children: new Map(), terminal: false };

  insert(word: string): void {
    let node = this.root;
    for (const ch of word) {
      let next = node.children.get(ch);
      if (!next) {
        next = { children: new Map(), terminal: false };
        node.children.set(ch, next);
      }
      node = next;
    }
    node.terminal = true;
  }

  // Typeahead core: everything under `prefix`, capped for tail latency.
  // Real systems store precomputed top-N per node instead of DFS at query time.
  suggest(prefix: string, limit = 10): string[] {
    let node = this.root;
    for (const ch of prefix) {                 // O(|prefix|) walk
      const next = node.children.get(ch);
      if (!next) return [];
      node = next;
    }
    const out: string[] = [];
    const dfs = (n: TrieNode, acc: string): void => {
      if (out.length >= limit) return;
      if (n.terminal) out.push(acc);
      for (const [ch, child] of n.children) dfs(child, acc + ch);
    };
    dfs(node, prefix);
    return out;
  }
}

Trees: B+trees on disk, red-black in memory

  • B-tree / B+tree (full treatment in ch09): the disk-friendly tree. Nodes are page-sized (typically 16KB in InnoDB) holding hundreds of keys, so a billion-row index is 3–4 levels deep — 3–4 disk reads worst case. Sightings: every RDBMS index (Postgres, MySQL/InnoDB, SQLite), most filesystems (ext4 extents, Btrfs, NTFS), LMDB, DynamoDB's underlying storage. The one-line why: disk I/O is per-page, so you want maximum fan-out per page read.
  • Balanced BSTs (red-black, AVL) in one paragraph: binary trees that self-balance to guarantee O(log n) with sorted iteration — the thing hash maps can't do. You'll rarely implement one; you use them via Java's TreeMap/TreeSet, C++ std::map, and the Linux CFS scheduler's rbtree of runnable tasks ordered by virtual runtime (leftmost node = next to run). Epoll timeouts and nginx timers also sit in rbtrees.

Skip list — Redis's sorted-set engine

A sorted linked list with probabilistic "express lanes": each node gets a random height (coin flips: ~half the nodes reach level 2, a quarter reach level 3…). Search starts on the top lane, skips far, and drops down when it overshoots — expected O(log n) search/insert/delete without any rebalancing logic.

Real-world analogy

A subway line with express trains. The express lane stops only at major stations; you ride it as far as you can without passing your destination, then drop to the local line for the final stops. Multiple express tiers give you log-time travel across the whole line — and the "schedule" is random coin flips, not careful planning.

L2 (express) L1 L0 (every key) H 19 1 H 7 19 31 31 > 25: drop down H 3 7 12 19 25 31 3 2 Search 25: (1) ride L2 to 19 · (2) L1 next is 31 — too far, drop · (3) walk L0 from 19 to 25. Expected O(log n) hops.

Why Redis sorted sets use a skip list, not a red-black tree:

  • Simplicity: no rotation/recoloring cases; insert is "find position, flip coins, splice pointers". Fewer bugs, less code (antirez's stated reason).
  • Range scans are natural: level 0 is already a sorted linked list, so ZRANGEBYSCORE is "find floor, walk forward" — no in-order tree traversal machinery. Redis also adds a span counter per link to answer ZRANK in O(log n).
  • Same expected complexity as a balanced tree; small sorted sets skip the whole thing and use a flat listpack (arrays again). ConcurrentSkipListMap in Java exists because skip lists take lock-free concurrent implementations far more gracefully than rotating trees.
// Skip list sketch: probabilistic levels + range scan (the ZRANGEBYSCORE shape).
const MAX_LEVEL = 16;

interface SkipNode { key: number; next: (SkipNode | null)[]; }

class SkipList {
  private head: SkipNode = { key: -Infinity, next: new Array(MAX_LEVEL).fill(null) };
  private level = 1;

  private randomLevel(): number {
    let lvl = 1;                               // coin flips: P(level >= k) = 2^-(k-1)
    while (Math.random() < 0.5 && lvl < MAX_LEVEL) lvl++;
    return lvl;
  }

  insert(key: number): void {
    const update: SkipNode[] = new Array(MAX_LEVEL).fill(this.head);
    let node = this.head;
    for (let i = this.level - 1; i >= 0; i--) { // descend the express lanes
      while (node.next[i] && node.next[i]!.key < key) node = node.next[i]!;
      update[i] = node;                        // rightmost node before key, per level
    }
    const lvl = this.randomLevel();
    if (lvl > this.level) this.level = lvl;
    const fresh: SkipNode = { key, next: new Array(lvl).fill(null) };
    for (let i = 0; i < lvl; i++) {            // splice into each level — no rebalancing
      fresh.next[i] = update[i].next[i];
      update[i].next[i] = fresh;
    }
  }

  // Range scan: log-time descent to the floor, then walk level 0 linearly.
  range(min: number, max: number): number[] {
    let node = this.head;
    for (let i = this.level - 1; i >= 0; i--) {
      while (node.next[i] && node.next[i]!.key < min) node = node.next[i]!;
    }
    const out: number[] = [];
    for (let n = node.next[0]; n && n.key <= max; n = n.next[0]) out.push(n.key);
    return out;
  }
}

Graphs, bit sets, and composite structures

  • Graph representations: adjacency list (Map<Node, Node[]>) for sparse graphs — the default; adjacency matrix for dense graphs or O(1) edge checks. Sightings: social graphs (Facebook TAO serves the friend graph off MySQL+cache), service dependency graphs (tracing UIs, failure blast-radius analysis), recommendation engines, Neptune/Neo4j.
  • Bit sets: one bit per element; AND/OR across thousands of items per CPU instruction. Sightings: the bit array inside Bloom filters (ch16), permission/feature masks, and Roaring bitmaps — compressed bitmaps used by Lucene/Elasticsearch filter caches, Druid, ClickHouse, and Pilosa to intersect huge ID sets in analytics.
  • LRU cache = hash map + doubly-linked list (ch08): the map gives O(1) lookup of the node, the list gives O(1) move-to-front and evict-from-tail. The canonical "compose two structures" interview question.
  • Merkle tree: a tree of hashes — leaves hash data blocks, parents hash their children. Compare two datasets by comparing roots; on mismatch, descend only into differing subtrees: O(log n) rounds to find the diff. Sightings: DynamoDB/Cassandra anti-entropy repair (replicas exchange Merkle trees to find drifted key ranges without shipping all data), git object graph, Bitcoin/Ethereum block verification, certificate transparency logs.
  • Pointers elsewhere: consistent-hash ring — sorted array of vnode hashes + binary search (ch11); Bloom filters, HyperLogLog, Count-Min Sketch — probabilistic structures (ch16).
Real-world analogy

Merkle sync is two warehouse managers reconciling inventory by phone. They don't read out every SKU — they compare the grand-total summary sheet first; if it matches, done. If not, they compare per-aisle subtotals, then per-shelf, and only count items on the one shelf that disagrees.

Inverted index — how search engines see text

Forward index: doc → words. Inverted index: term → posting list of doc IDs (plus positions and frequencies for ranking). Queries become set operations over posting lists. Sightings: Lucene — and therefore Elasticsearch, OpenSearch, and Solr; Postgres GIN indexes for full-text search and JSONB.

type DocId = number;

class InvertedIndex {
  private postings = new Map<string, Set<DocId>>();

  add(id: DocId, text: string): void {
    for (const term of text.toLowerCase().split(/\W+/).filter(Boolean)) {
      let list = this.postings.get(term);
      if (!list) { list = new Set(); this.postings.set(term, list); }
      list.add(id);          // Lucene: sorted, compressed arrays + positions + tf
    }
  }

  // AND query = intersect posting lists. Start from the rarest term:
  // it has the shortest list, so every other check runs on few candidates.
  search(query: string): DocId[] {
    const lists = query.toLowerCase().split(/\W+/).filter(Boolean)
      .map(t => this.postings.get(t) ?? new Set<DocId>())
      .sort((a, b) => a.size - b.size);
    if (lists.length === 0 || lists[0].size === 0) return [];
    return [...lists[0]].filter(id => lists.every(l => l.has(id)));
  }
}

The big map: structure → system

StructureKey opsReal system sightingReach for it when…
Dynamic arrayindex O(1), append O(1)*, scan O(n) fastRedis listpack, Parquet/ClickHouse columns, Kafka logSequential access, small n, cache-friendly scans
Hash mapget/put O(1) avg, O(n) worstRedis dict (incremental rehash), every cache, DB hash joinsPoint lookups, no ordering needed
Linked listinsert/delete O(1) at node, find O(n)LRU recency chain, allocator free lists, buffer chainsOnly glued to a map or as intrusive chains
Stack / queue / dequepush/pop O(1)Call stacks, SQS, BFS/DFS frontiers, work stealingLIFO/FIFO processing order is the semantics
Binary heappush/pop O(log n), peek O(1)K8s/OS schedulers, timers, Dijkstra, LSM k-way mergeRepeatedly need min/max; top-K; merging sorted streams
Trieops O(key length)Typeahead, router longest-prefix match, Lucene FSTPrefix queries, shared-prefix key sets
B+treeall ops O(log n), few disk pagesEvery RDBMS index, filesystems, LMDBSorted data on disk, range scans
Skip listexpected O(log n), easy range scansRedis ZSET, ConcurrentSkipListMap, MemSQL/HBase memtablesSorted in-memory data, simple or concurrent impl
Red-black treeguaranteed O(log n), sorted iterationJava TreeMap, Linux CFS scheduler, nginx timersIn-memory ordering via your runtime's library
Graph (adj. list)traverse O(V+E)Social graphs (TAO), service dependency maps, NeptuneRelationships are the data
Bit set / roaringset ops ~O(n/64), compressedBloom internals, Lucene filter cache, Druid, permission masksDense ID sets, fast intersections at scale
Inverted indexterm lookup O(1) + list mergeElasticsearch/OpenSearch/Lucene, Postgres GINText search, "which docs contain X"
Merkle treediff O(log n) roundsDynamoDB/Cassandra anti-entropy, git, blockchainsComparing large replicated datasets cheaply
Hash ring / Bloom / HLL / CMSsee ch11 / ch16Dynamo-style partitioning; caches, analyticsPlacement; membership/cardinality at scale

Choosing a structure for an access pattern

Just key → value? yes Hash map no Ordering / ranges? yes Disk: B+tree RAM: skip list / RB-tree no Need min/max next? yes Heap / priority queue no Prefix search? yes Trie / FST no Membership at scale, ~exact is fine? yes Bloom / HLL / bitmap (ch16) no Array + linear scan small n: cache locality wins
AWS mapping
  • ElastiCache / MemoryDB (Redis): hash dict + skip list (ZSET) + listpack, managed. Pick when you need these structures shared across instances instead of in-process.
  • DynamoDB: partitioned hash map on the outside (partition key), B-tree-ordered inside a partition (sort key). Its anti-entropy heritage (Dynamo paper) is Merkle trees + consistent hashing.
  • OpenSearch: managed Lucene — inverted index + roaring-style filter bitmaps + FST term dictionary. Pick over Postgres GIN when you need relevance ranking, faceting, or fan-out scale.
  • Neptune: managed graph store — choose when traversals (friends-of-friends, fraud rings) dominate; don't buy it for one JOIN.
  • Build custom (in-process trie, heap, LRU) when the data fits in one node's memory and a network hop per lookup would dominate — a local structure is ~100ns vs ~1ms to ElastiCache.
Pitfalls
  • Answering "linked list for fast inserts" without mentioning cache locality — at realistic n, the array usually wins; staff answers cite the constant factors.
  • Proposing a balanced BST in a design interview when a sorted structure isn't needed at all — most "sorted" requirements are really top-K (heap) or range scan (B+tree/skip list).
  • Forgetting hash maps give no ordering: "list users alphabetically from the cache" quietly becomes an O(n log n) sort per request.
  • Unbounded hash map as an in-process cache = memory leak; ch08's eviction discussion exists for a reason.
  • Hashing untrusted input with a predictable non-crypto hash invites hash-flooding DoS (all keys collide into one bucket); runtimes mitigate with per-process seeds (SipHash).
Production best practice
  • Default stack: array + hash map solves 90% of problems; add a heap for priority, a trie for prefixes, sorted structures only when ranges are real requirements.
  • Use the platform's structures (Redis ZSET, Java TreeMap, Lucene) and be ready to explain their internals — that's the staff signal, not hand-rolling one in prod.
  • Cap everything: bounded queues, LRU-bounded maps, limit-capped trie DFS. Unbounded structures are outage generators.
  • For typeahead at scale: precompute top-N completions per prefix node offline; query time becomes a single lookup, not a DFS.
What interviewers probe
  • Q: How does Redis implement sorted sets, and why not a red-black tree? A: Skip list + hash map (dict maps member→score; skip list orders by score). Skip list chosen for implementation simplicity (no rotations), natural range scans along level 0, and span counters giving O(log n) rank queries; small sets use a flat listpack instead.
  • Q: Design a typeahead. A: Trie (or FST) with precomputed top-N suggestions per node, built offline from query logs; serve from memory, shard by prefix, update async; client debounces and caches. Mention capping suggestion depth for tail latency.
  • Q: Why B-trees on disk but hash maps in memory? A: Disk charges per page, so maximize fan-out per page read (B+tree, 3–4 I/Os for a billion keys) and keep keys sorted for range scans; in RAM random access is cheap and O(1) point lookup wins — unless you need ordering.
  • Q: Implement an LRU cache. A: Hash map pointing at nodes of a doubly-linked list; get/put move node to head, evict tail — all O(1). Bonus: mention sharded locks or a lock-free variant for concurrency.
  • Q: Two replicas may have drifted — how do you find the diff without shipping all data? A: Merkle trees: exchange roots, recurse into unequal subtrees; O(log n) rounds, bandwidth proportional to the diff. Cassandra repair does exactly this.
  • Q: When would you actually pick a linked list? A: When something else holds a reference to the node and you need O(1) unlink/move — LRU chains, intrusive lists in kernels, free lists. Rarely as a standalone container.
  • Q: What happens when a hash map resizes, and why does Redis care? A: All keys rehash into a bigger table — an O(n) pause. Redis amortizes it: incremental rehashing moves a few buckets per operation while both tables coexist, protecting p99 latency.
References
Part VIII · DS & Algorithms

27. Algorithms in Real Systems

Same deal as ch26: no puzzle grinding. Each algorithm here is mapped to the production system where it actually runs — binary search inside your index lookups, k-way merge inside LSM compaction, topological sort inside your build tool, reservoir sampling inside your tracing pipeline. Interviews reward the mapping, not the memorized template.

Binary search and its load-bearing variant

Everyone knows binary search; the variant that actually ships is lower bound — "first element ≥ target" — because real lookups care about insertion points, floors, and ranges, not just exact hits.

  • Sightings: every B+tree node lookup (binary search within a page); finding the owning vnode on a consistent-hash ring — lower bound over sorted vnode hashes, wrap to index 0 (ch11); git bisect — binary search over commit history for the breaking change; sparse index lookups in Kafka segment files; finding which rate-limit window a timestamp falls into (ch17).
  • Classic bug: (lo + hi) / 2 overflows in fixed-int languages — the JDK shipped this bug in Arrays.binarySearch for nine years. Write lo + (hi - lo) / 2.
// lowerBound: first index where a[i] >= target (= insertion point).
// The variant that powers floor/ceiling, range extraction, and ring lookups.
function lowerBound<T>(
  a: readonly T[],
  target: T,
  cmp: (x: T, y: T) => number,
): number {
  let lo = 0, hi = a.length;                 // hi is exclusive; answer may be a.length
  while (lo < hi) {
    const mid = lo + ((hi - lo) >> 1);       // overflow-safe midpoint
    if (cmp(a[mid], target) < 0) lo = mid + 1;
    else hi = mid;                           // a[mid] >= target: keep it in range
  }
  return lo;
}

// Exact match is just lowerBound + a check:
function binarySearch<T>(a: readonly T[], t: T, cmp: (x: T, y: T) => number): number {
  const i = lowerBound(a, t, cmp);
  return i < a.length && cmp(a[i], t) === 0 ? i : -1;
}

// Hash-ring lookup (ch11): sorted vnode hashes; first vnode clockwise of the key.
function ownerVnode(ringHashes: readonly number[], keyHash: number): number {
  const i = lowerBound(ringHashes, keyHash, (x, y) => x - y);
  return i === ringHashes.length ? 0 : i;    // wrap past 12 o'clock
}

Sorting in practice: you rarely write one

You will almost never implement quicksort at work. What you must know is which sort runs where, and — the interview classic — how to sort data that doesn't fit in memory.

  • Timsort (Python, V8's Array.prototype.sort, Java objects): a merge/insertion hybrid that detects already-sorted runs. Real data is rarely random — logs are nearly time-ordered, appended lists are mostly sorted — so Timsort hits O(n) on them while staying O(n log n) worst case and stable.
  • External merge sort — how a DB sorts 100GB with 1GB of RAM: (1) stream the input in 1GB chunks, sort each in memory, spill ~100 sorted runs to disk; (2) merge all runs in one pass with a min-heap holding one head element per run. Total I/O ≈ 2 reads + 2 writes of the data, memory O(k) for k runs.
  • Sightings: Postgres ORDER BY spilling to disk when work_mem is exceeded; LSM compaction merging sorted SSTables (ch09) — same k-way merge, no sort phase needed since inputs are already sorted; MapReduce/Spark shuffle (sort runs on mappers, merge on reducers); Unix sort(1) on big files.
Real-world analogy

External merge sort is grading 10,000 exams with one small desk. You sort desk-sized stacks of 100 one at a time and pile each sorted stack on the floor. Then you merge: look at the top exam of every stack, repeatedly take the smallest, and you only ever hold one page per stack on the desk. The desk is RAM; the floor is disk.

Phase 1 — run generation (1 GB RAM) 100 GB input unsorted stream Sort 1 GB chunk quicksort in RAM 100 sorted runs 1 GB each, on disk spill ×100 1 Phase 2 — k-way merge (one pass) Run 1 Run 2 Run 100 Min-heap holds 100 head records Sorted 100 GB output pop min, refill from its run 2
// k-way merge of sorted async streams — the heart of external sort, LSM
// compaction, and merging results from sharded queries. Memory: O(k).
// Uses the MinHeap from ch26.
interface HeadEntry<T> {
  value: T;                                  // current head of this stream
  src: AsyncIterator<T>;                     // where to refill from
}

async function* kWayMerge<T>(
  sources: AsyncIterable<T>[],               // each MUST already be sorted
  cmp: (a: T, b: T) => number,
): AsyncGenerator<T> {
  const heap = new MinHeap<HeadEntry<T>>((a, b) => cmp(a.value, b.value) < 0);

  // Prime the heap with the first element of every stream.
  for (const s of sources) {
    const it = s[Symbol.asyncIterator]();
    const first = await it.next();
    if (!first.done) heap.push({ value: first.value, src: it });
  }

  // Invariant: heap top = globally smallest unemitted element.
  while (heap.size > 0) {
    const { value, src } = heap.pop()!;
    yield value;
    const next = await src.next();           // refill from the stream we drained
    if (!next.done) heap.push({ value: next.value, src });
  }
}

// Usage: merge per-shard results of "ORDER BY created_at LIMIT 50" (ch11) —
// each shard returns its own sorted top 50; take the first 50 merged rows.

Hashing algorithms: pick by threat model, not fashion

FamilyExamplesSpeedUse forNever for
Non-cryptoxxHash, MurmurHash3, FNV, CityHash~10+ GB/s (xxHash3)Hash tables, partitioning/sharding keys, Bloom filters, checksums against accidental corruption, Kafka partitionersAnything an attacker can influence for security
CryptoSHA-256, BLAKE3~0.5–2 GB/s (BLAKE3 much faster, parallel)Content addressing (git*, Docker layers), Merkle trees, signatures, dedup where collisions = corruptionHot-path hash tables — 10–50× slower for nothing
Passwordbcrypt, scrypt, Argon2Deliberately ~100msPassword storage only — slowness is the featureEverything else
  • Consistent hashing (ch11) solves "keys → nodes when nodes come and go": only ~1/n of keys move on membership change.
  • Rendezvous (HRW) hashing — the elegant alternative: for key K, compute score = hash(K, node) for every node and pick the max. No ring, no vnodes, no sorted structure; naturally uniform; adding/removing a node reshuffles exactly the keys that scored highest on it. O(n) per lookup, so it fits when node count is modest (tens to hundreds) — used in load balancers (Envoy supports it as "Maglev's simpler cousin") and cache client libraries.
Adversary or integrity proof? yes SHA-256 / BLAKE3 no Mapping keys to a changing node set? yes Consistent (ch11) or rendezvous hashing no Hot path, billions of ops? yes xxHash / MurmurHash3 no Runtime's built-in hash (seeded — SipHash in most langs)

Graph algorithms in production

BFS / DFS — the traversal twins

  • BFS explores by distance layers; sightings: web crawler frontiers, "degrees of separation" in social graphs, shortest unweighted path, finding nearest healthy replica.
  • DFS goes deep first; sightings: dependency resolution, cycle detection, and the GC mark phase — tracing garbage collectors (V8, JVM) traverse the object graph from roots, marking everything reachable; unreachable = garbage.
type Graph = Map<string, string[]>;          // adjacency list (ch26)

// Unweighted shortest path. parent doubles as the visited set.
function bfsShortestPath(g: Graph, start: string, goal: string): string[] | null {
  const parent = new Map<string, string | null>([[start, null]]);
  const queue: string[] = [start];
  for (let head = 0; head < queue.length; head++) {  // index head = O(1) dequeue
    const node = queue[head];
    if (node === goal) {
      const path: string[] = [];                     // walk parents back to start
      for (let cur: string | null = node; cur !== null; cur = parent.get(cur)!) {
        path.push(cur);
      }
      return path.reverse();
    }
    for (const next of g.get(node) ?? []) {
      if (!parent.has(next)) {
        parent.set(next, node);
        queue.push(next);
      }
    }
  }
  return null;                                       // goal unreachable
}

Topological sort — ordering work under dependencies

Sightings: build systems (Bazel, Make, Turborepo task graphs), npm/yarn install order, DAG schedulers (Airflow, AWS Step Functions, dbt), database migration ordering, spreadsheet cell recalculation. Kahn's algorithm doubles as cycle detection — leftover nodes are the cycle, i.e. your circular dependency error message.

// Kahn's algorithm. deps: task -> tasks it requires (every task appears as a key).
type TopoResult =
  | { ok: true; order: string[] }
  | { ok: false; cycle: string[] };                 // discriminated union

function topoSort(deps: Map<string, string[]>): TopoResult {
  const indegree = new Map<string, number>();
  const dependents = new Map<string, string[]>();   // dep -> tasks waiting on it
  for (const [task, requires] of deps) {
    indegree.set(task, requires.length);
    if (!dependents.has(task)) dependents.set(task, []);
  }
  for (const [task, requires] of deps) {
    for (const dep of requires) dependents.get(dep)!.push(task);
  }

  // Start with tasks that require nothing. A priority queue here instead of a
  // FIFO gives "critical path / most-blocked first" scheduling (Bazel-style).
  const ready = [...indegree].filter(([, d]) => d === 0).map(([t]) => t);
  const order: string[] = [];
  while (ready.length > 0) {
    const task = ready.pop()!;
    order.push(task);                               // safe to run now
    for (const t of dependents.get(task) ?? []) {
      const d = indegree.get(t)! - 1;
      indegree.set(t, d);
      if (d === 0) ready.push(t);                   // last dependency satisfied
    }
  }
  return order.length === deps.size
    ? { ok: true, order }
    : { ok: false, cycle: [...deps.keys()].filter(t => !order.includes(t)) };
}

Dijkstra in one paragraph: BFS upgraded for weighted edges — the queue becomes a min-heap keyed on cumulative distance, and a node's distance is final when popped. Runs in O((V+E) log V). Sightings: map routing (Google Maps uses heavily preprocessed variants like contraction hierarchies), network routing — OSPF and IS-IS literally run Dijkstra over the link-state database on every router; negative weights break it (use Bellman-Ford, rarely needed in practice).

Windows, pointers, and one-pass streams

  • Two pointers / sliding window: maintain an invariant over a moving range instead of recomputing it — O(n) instead of O(n²). Sightings: sliding-window rate limiters (ch17), TCP's flow/congestion windows (the window is the algorithm), log analysis ("any 5-minute window with >100 errors"), duplicate detection within a time horizon.
  • Streaming/online algorithms: one pass, bounded memory — the only option on firehoses. Reservoir sampling below; Misra-Gries / majority vote finds heavy hitters (top talkers, hot keys) with k counters instead of a counter per key; the heavy machinery (Bloom, HLL, Count-Min) lives in ch16.
  • Sightings: trace sampling in observability pipelines (ch20), "pick 1000 representative log lines from a billion", A/B assignment audits, hot-key detection in caches.
// Reservoir sampling: uniform k-sample from a stream of UNKNOWN length,
// one pass, O(k) memory. Every item ends up kept with probability k/n — proof
// by induction: item i replaces a slot with prob k/i, survivors keep parity.
function reservoirSample<T>(stream: Iterable<T>, k: number): T[] {
  const reservoir: T[] = [];
  let seen = 0;
  for (const item of stream) {
    seen++;
    if (reservoir.length < k) {
      reservoir.push(item);                  // fill phase: keep the first k
    } else {
      const j = Math.floor(Math.random() * seen);   // uniform in [0, seen)
      if (j < k) reservoir[j] = item;        // keep with probability k/seen
    }
  }
  return reservoir;
}
// "Sample 1% of a firehose fairly": fixed-size reservoir per time bucket, or
// hash(traceId) < threshold for *consistent* sampling across services (ch20).

Divide & conquer — the scaling meta-pattern

Merge sort's shape — split, solve independently, merge — is the architecture of every horizontally scaled query system. This is the explicit bridge interviewers want you to draw:

  • MapReduce/Spark: map = solve per partition, shuffle+reduce = merge.
  • Scatter-gather over shards (ch11): "top 50 orders by date" on a sharded DB = send the query to all shards in parallel (scatter), each returns its sorted top 50, coordinator merges with the kWayMerge above and cuts at 50 (gather). Elasticsearch executes every search this way across index shards.
  • The catch: the merge step must be cheap and the split truly independent. Aggregations like AVG must ship (sum, count) pairs, not averages; percentiles don't merge exactly — hence t-digest sketches (ch16).
  • Latency note: scatter-gather p99 is the max over shards — fan-out amplifies tail latency (ch03's "tail at scale" problem). Hedged requests mitigate.
Real-world analogy

A national census: no one counts the whole country. Each district counts itself in parallel (map/scatter), then totals roll up through regional offices (reduce/gather). The design constraint is identical: district results must be combinable by addition — you can't roll up "median household income" by averaging medians.

Backoff, jitter, and compression — small algorithms, big blast radius

  • Exponential backoff with full jitter (ch13/ch19 for the retry-storm context): sleep = random(0, min(cap, base × 2^attempt)). The jitter is not decoration — after a shared failure, plain exponential backoff makes every client retry at t=1s, 2s, 4s in synchronized waves that re-kill the recovering service. Full jitter smears the wave flat; AWS's analysis showed it needs fewer total calls than equal-jitter variants to complete the same work.
  • Compression in one paragraph: RLE collapses repeats (great in columnar data); dictionary/LZ-family (LZ77) replaces repeated byte sequences with back-references; entropy coding (Huffman/ANS) squeezes the rest. Sightings: Kafka compresses whole message batches (bigger window = better ratio); Parquet applies RLE + dictionary per column then zstd; HTTP uses gzip/brotli; zstd is the modern default — near-gzip-beating ratios at several times the speed, with negotiable levels. Trade-off to name: CPU vs bytes-on-wire — compress when the network or storage is the bottleneck, skip for already-compressed payloads (JPEG, encrypted blobs).

Complexity in practice — the staff-level caveat

Big-On = 1M, ~opsFeels likeThe caveat that matters
O(1)1Hash lookup…plus a possible cache miss (~100ns) or network hop (~1ms) — 10,000× apart
O(log n)~20B-tree descent20 disk pages ≠ 20 comparisons; count the I/Os, not the compares
O(n)10⁶Array scanSequential scan streams at GB/s — often beats "smarter" pointer-chasing structures for small-to-mid n
O(n log n)~2×10⁷SortCPU sort of 1M rows: ~100ms. One extra network round trip per row: 16 minutes.
O(n²)10¹²Nested loopFine at n=1,000 (1M ops, ~1ms). The bug is when n grows silently.
  • Constants matter: a linear scan over a 64-element array beats a red-black tree lookup — cache lines and branch prediction eat the log. Redis listpacks and B-tree in-page search exploit exactly this.
  • The network is the constant that eats your big-O: an O(n) algorithm doing one RPC per element loses catastrophically to an O(n log n) algorithm running local. Batch the I/O first; optimize asymptotics second.
  • Staff framing in interviews: state the big-O, then immediately state the dominant real cost (disk pages, RPCs, lock contention). That second sentence is the differentiator.

The big map: algorithm → system

AlgorithmComplexityReal system sightingInterview trigger phrase
Binary search / lower boundO(log n)B-tree pages, hash-ring vnode lookup, git bisect, Kafka sparse index"sorted", "find the shard/segment for key X"
TimsortO(n)–O(n log n), stableV8 / Python / Java sort of real-world partially-sorted data"why is sorting nearly-sorted data fast"
External merge sortO(n log n), ~2 passes of I/OPostgres ORDER BY spill, Unix sort, MapReduce shuffle"sort 100GB with 1GB RAM"
k-way merge (heap)O(n log k), O(k) memoryLSM compaction, merging shard results, log interleaving"combine k sorted streams"
Non-crypto hashingO(len), ~GB/sPartitioners, Bloom filters, hash tables"how do you pick the partition"
Rendezvous hashingO(nodes) per keyEnvoy LB policies, cache client node choice"consistent hashing without the ring"
BFSO(V+E)Crawlers, degrees of separation, GC reachability"shortest path, unweighted", "connected?"
Topological sort (Kahn)O(V+E)Bazel/Make, npm install, Airflow DAGs, migrations"order tasks with dependencies", "detect cycles"
DijkstraO((V+E) log V)Maps routing, OSPF/IS-IS link-state routing"shortest path, weighted"
Sliding windowO(n), O(w) memoryRate limiters, TCP windows, log burst detection"over the last N seconds/items"
Reservoir samplingO(n), O(k) memoryTrace/log sampling pipelines (ch20)"sample fairly from a stream of unknown size"
Misra-GriesO(n), O(k) memoryHot-key / top-talker detection"most frequent items, can't store all counts"
Scatter-gatherparallel + merge; p99 = max of shardsSharded SQL, Elasticsearch fan-out, MapReduce"query across all shards"
Backoff + full jitterO(1) per retryEvery AWS SDK, gRPC retry policy"thundering herd", "retry storm"
LZ / zstd compression~O(n), tunableKafka batches, Parquet, HTTP bodies"reduce bandwidth/storage cost"
AWS mapping
  • Step Functions: your topological sort as a managed service — express the DAG, it handles ordering, retries, and backoff. Pick over hand-rolled orchestration for multi-step workflows (ch13's saga machinery).
  • EMR / Glue / Athena: managed divide-and-conquer — external sort, shuffle, and k-way merge at cluster scale. Athena scatter-gathers over Parquet in S3.
  • Kinesis Data Analytics / managed Flink: sliding windows and streaming aggregation as a service; you declare the window, it runs the online algorithm.
  • AWS SDKs: ship exponential backoff with jitter by default — configure, don't reimplement; align budgets with your own retry layers to avoid multiplication (ch19).
  • Build custom when the algorithm must run in-process for latency (in-memory top-K, local sampling decisions) — a Kinesis round trip to compute a heap insert is the network eating your big-O.
Pitfalls
  • Retry loops without jitter: synchronized retry waves that re-down the service you're recovering. Full jitter, capped, with a retry budget.
  • Merging shard results wrong: averaging averages, "merging" medians, or taking each shard's top-K of a different sort key. Merge only what's algebraically mergeable.
  • Sorting in app code what the database index already stores sorted — you paid for the B+tree; use ORDER BY on the indexed column.
  • (lo + hi) / 2 overflow, and off-by-one at binary search boundaries (inclusive vs exclusive hi) — state your invariant out loud in interviews.
  • "Sample the first 10,000 events" instead of reservoir sampling — the head of a stream is never representative (deploys, cache warmup, diurnal skew).
  • Quoting big-O while ignoring that each "operation" is a 1ms RPC — interviewers notice when your cost model has no units.
Production best practice
  • Prefer the platform's algorithm: DB sorts with spill logic, SDK backoff, Flink windows, library Timsort — bring your own only at the edges (in-process top-K, custom merge of shard cursors).
  • Make fan-out bounded and observable: cap parallelism, set per-shard timeouts, and track p99-of-max — the slowest shard is your latency.
  • Sample consistently in distributed tracing: decide by hash(traceId), not per-service coin flips, so a trace is kept or dropped whole (ch20).
  • Default to zstd for new pipelines; keep gzip/brotli for browser-facing HTTP where client support decides.
What interviewers probe
  • Q: Sort 100GB with 1GB of RAM. A: External merge sort — sort ~100 spill runs of 1GB in memory, then one k-way merge pass with a min-heap of 100 head records. ~2 read + 2 write passes of the data; note this is exactly what Postgres does when ORDER BY exceeds work_mem.
  • Q: How does a build system order tasks? A: Topological sort of the dependency DAG (Kahn's); nodes left over = circular dependency error. Add a priority queue for critical-path-first and run independent ready tasks in parallel.
  • Q: Sample 1% of a firehose fairly. A: Reservoir sampling for fixed-size uniform samples with unknown stream length; hash-based sampling (hash(id) < threshold) when the same entity must be sampled consistently across services, as in trace sampling.
  • Q: How do sharded queries aggregate results? A: Scatter-gather: parallel per-shard queries, each returning sorted/partial results; coordinator k-way merges (order/limit) or combines algebraic partials (sum, count — not avg-of-avgs); percentiles need mergeable sketches like t-digest. Call out tail latency = max over shards.
  • Q: Why does every retry guide scream "jitter"? A: A shared failure synchronizes clients; deterministic backoff makes them retry in waves that re-overload the dependency. Full jitter — uniform random in [0, exp cap] — decorrelates the herd.
  • Q: When is O(n) faster than O(log n)? A: Small n with contiguous memory: a linear scan of ~64 elements beats a tree lookup on cache locality and branch prediction alone — which is why Redis uses flat listpacks for small collections and B-trees scan within pages.
  • Q: Dijkstra vs BFS? A: BFS suffices when all edges cost the same (queue); weighted edges need Dijkstra (min-heap keyed on distance, node final when popped); negative weights break the greedy invariant — Bellman-Ford.
References
  • Martin Kleppmann, Designing Data-Intensive Applications — ch3 (sorting/SSTables/LSM), ch6 (partitioning), ch10 (batch: sort-merge, MapReduce).
  • Marc Brooker, "Timeouts, Retries, and Backoff with Jitter" (Amazon Builders' Library) and the Exponential Backoff and Jitter analysis.
  • Tim Peters, listsort.txt — the Timsort design document, in the CPython repo.
  • Jeffrey Dean & Luiz Barroso, "The Tail at Scale" (CACM 2013) — why scatter-gather p99 is the max over fan-out.
  • Jeffrey Vitter, "Random Sampling with a Reservoir" (ACM TOMS 1985) — Algorithm R and its optimizations.
  • Thaler & Ravishankar, "Using Name-Based Mappings to Increase Hit Rates" (IEEE/ACM ToN 1998) — rendezvous/HRW hashing.
  • Knuth, TAOCP Vol. 3: Sorting and Searching — external sorting's original treatment; RFC 8878 — Zstandard.