System Design NotesRohit’s interview study guide
Chapter 09

Reliability, Security and Operations

Designing for the bad day: limiting blast radius, surviving slow and failing dependencies, degrading gracefully, seeing what is happening, shipping safely, recovering data, and the security questions every design review asks.

7 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Failure modes and blast radius

Added

At any real scale, something is always broken. With a few thousand machines, disks die every day, a few instances are always mid-restart, one dependency is always slower than usual, and someone is always deploying. So the reliability question in a design interview is never "what if it fails?" but "when this fails, who notices, and how many of them?" That second number is the blast radius: the share of users, requests or tenants affected by a single fault.

The mental model is a ship's watertight compartments. You can't promise the hull will never be holed, so you divide it so that one hole floods one compartment, not the ship. Redundancy (two engines) protects against a component dying; compartments protect against a fault spreading. Most large outages are spreading problems: a bad config pushed everywhere at once, a "query of death" that crashes every replica it reaches, a retry storm that turns one slow service into ten.

It helps to name failures precisely, because each one has a different defence:

FailureWhat it looks likeMain defence
Crash or lost instanceConnection refused, health check failsRedundancy behind a load balancer, fast replacement
Slow or hanging dependencyRequests pile up, threads and sockets run outTimeouts, circuit breakers, bulkheads (next topic)
OverloadLatency climbs, then errors everywhereLoad shedding, backpressure, autoscaling
Bad deploy or config pushErrors start right after a changeCanaries, staged rollout one cell at a time, fast rollback
Poison request ("query of death")One input crashes every server that handles itShuffle sharding, input limits, quarantining the caller
Third-party outagePayments or email provider downFallbacks, queue the work and retry later
Zone or region lossA whole location disappearsMulti-zone by default, multi-region for the critical path
Data corruption or operator errorBad migration, DELETE without WHEREPoint-in-time backups, guard rails, two-person review
Correlated failureEvery server hits the same certificate expiry, leap second or shared-library bug at onceStaggered expiry and rollout, monitoring of expiry dates, diversity

Two of these deserve a special warning. Gray failures — a server that is slow, or that fails one request type in ten — are worse than crashes, because health checks still pass and load balancers keep sending traffic. And correlated failures defeat redundancy completely: three replicas running the same buggy build crash together, so "we have three of them" is no defence against a bad deploy or a poison request.

Shrinking the blast radius

There is a ladder of techniques, each limiting spread a little more:

  • Redundancy (N+1, multi-zone): survives a machine or zone dying. Doesn't help when every copy has the same bug or receives the same poison request.
  • Sharding by customer: split customers across independent groups of servers. A fault in one shard hits only its customers — 1/shards of them.
  • Cells: take sharding all the way: every cell is a complete, independent copy of the stack (load balancer, app servers, cache, database) serving a fixed slice of customers. A thin cell router in front sends each request to its customer's cell. Cells are also the unit of deployment, so a bad release goes to one cell first.
  • Shuffle sharding: instead of fixed groups, give every customer its own random combination of a few servers. Two customers rarely share the same whole combination, so one customer's poison request can only take out the servers that customer touches.
Clients customer id Cell router customer → cell no business logic as thin as possible Control plane assigns, migrates Cell 1 · customers A–H LB app × 6 cache database Cell 2 · customers I–P ✕ bad deploy: 1/3 affected LB app × 6 ✕ cache database Cell 3 · customers Q–Z LB app × 6 cache database
Each cell is a whole stack for a slice of customers; a fault inside one cell stays inside it.

The AWS whitepaper Reducing the Scope of Impact with Cell-Based Architecture (checked October 2026) describes the same shape: a routing layer that sends each request to a cell by a partition key (customer id, account id, resource id), cells that ideally share nothing, and a router kept as simple as possible because it is the one component every request still passes through.

Now shuffle sharding, with numbers. Take 8 workers. With plain sharding you'd split them into 4 fixed pairs and give each customer a pair, so a poison customer that crashes both workers in its pair takes out every other customer on that pair — a quarter of everyone. With shuffle sharding each customer gets its own random pair, out of all the pairs that exist. The model below computes the odds and then simulates them with a seeded random generator:

shuffle-shard.mjs
// Shuffle sharding: how often do two customers share ALL of their workers?
const choose = (n, k) => { let r = 1; for (let i = 1; i <= k; i++) r = (r * (n - k + i)) / i; return r; };

function mulberry32(seed) { // small seeded PRNG, so the simulation prints the same numbers every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
function randomShard(workers, size, rand) { // pick `size` distinct workers
  const pool = [...Array(workers).keys()];
  for (let i = 0; i < size; i++) { const j = i + Math.floor(rand() * (workers - i)); [pool[i], pool[j]] = [pool[j], pool[i]]; }
  return pool.slice(0, size);
}

// 1. The maths: 8 workers, each customer gets 2.
const shards = choose(8, 2);
console.log(`8 workers, shard of 2: ${shards} possible shards`);
console.log(`  plain sharding (4 fixed pairs): a poison customer takes out 1/4 = 25.0% of customers`);
console.log(`  shuffle sharding: P(another customer has the SAME pair) = 1/${shards} = ${(100 / shards).toFixed(1)}%`);
console.log(`  P(shares exactly one worker) = ${2 * 6}/${shards} = ${((100 * 12) / shards).toFixed(1)}%, P(shares none) = ${choose(6, 2)}/${shards} = ${((100 * choose(6, 2)) / shards).toFixed(1)}%`);

// 2. Simulate it: 10,000 customers, customer 0 sends a request that crashes every worker it touches.
const rand = mulberry32(7);
const customers = Array.from({ length: 10_000 }, () => randomShard(8, 2, rand));
const poisoned = new Set(customers[0]);
let all = 0, some = 0;
for (const shard of customers.slice(1)) {
  const hit = shard.filter((w) => poisoned.has(w)).length;
  if (hit === shard.length) all++;
  else if (hit > 0) some++;
}
console.log(`simulated (seeded), 9,999 other customers: ${all} lose every worker (${((100 * all) / 9999).toFixed(1)}%), ${some} lose one of two`);

// 3. Bigger fleets make full overlap vanishingly rare.
for (const [n, k] of [[100, 5], [2048, 4]]) {
  console.log(`${n} workers, shard of ${k}: ${choose(n, k).toLocaleString("en-US")} shards, P(same shard) = ${(1 / choose(n, k)).toExponential(2)}`);
}
Result
8 workers, shard of 2: 28 possible shards
  plain sharding (4 fixed pairs): a poison customer takes out 1/4 = 25.0% of customers
  shuffle sharding: P(another customer has the SAME pair) = 1/28 = 3.6%
  P(shares exactly one worker) = 12/28 = 42.9%, P(shares none) = 15/28 = 53.6%
simulated (seeded), 9,999 other customers: 361 lose every worker (3.6%), 4373 lose one of two
100 workers, shard of 5: 75,287,520 shards, P(same shard) = 1.33e-8
2048 workers, shard of 4: 730,862,190,080 shards, P(same shard) = 1.37e-12
What’s happening
  1. choose(8, 2) counts the distinct pairs you can make from 8 workers: 8 × 7 / 2 = 28. With shuffle sharding each customer is assigned one of those 28 pairs, instead of one of 4 fixed pairs.
  2. Plain sharding puts a quarter of all customers on each fixed pair, so when a poison customer crashes its pair, 25% of customers lose service. With shuffle sharding, another customer loses both of its workers only if it was dealt exactly the same pair: probability 1/28 = 3.6% — the "1/28th" scope of impact the AWS Builders' Library article on shuffle sharding quotes for this example (checked October 2026).
  3. The other outcomes matter too. A customer shares exactly one worker with the poison pair with probability 12/28 = 42.9% (2 poisoned workers × 6 others), and shares none with probability C(6,2)/28 = 15/28 = 53.6%. A customer that lost one of its two workers is still served — if its clients retry on the other worker. Shuffle sharding only pays off with fault-tolerant clients.
  4. The simulation deals random pairs to 10,000 customers using a seeded mulberry32 generator (so the output is identical every run) and poisons customer 0's pair. Of the other 9,999, 361 (3.6%) lose both workers and 4,373 (43.7%) lose one — matching the 3.6% and 42.9% from the formula, give or take sampling noise.
  5. Bigger fleets make full overlap vanish. With 100 workers and 5 per customer there are 75,287,520 possible shards. With 2,048 workers and shards of 4 there are 730,862,190,080 — the "730 billion" AWS quotes for Route 53, which arranges its capacity into 2,048 virtual name servers and gives every customer domain four of them.
  6. What this demonstrates: redundancy alone (every customer on every server) gives a poison request a blast radius of 100%; fixed shards give 1/shards; shuffle sharding gives roughly 1/C(n, k), at the same hardware cost. The price is complexity: the assignment must be stored, and clients must know their workers and fail over between them.
AdvancedDesigning cells

A cell-based design raises a handful of decisions, and an interviewer who hears "cells" will probe them:

  • Partition key. Route by something every request carries and that keeps related data together: tenant id for B2B SaaS, user id for consumer apps, account id for payments. Requests that must span cells (an admin report across all customers) go through a separate, slower path, never through the cells' hot path.
  • Cell size. Smaller cells mean a smaller blast radius and cheaper load tests (you can test the maximum size of a cell, then add cells to grow), but more cells to operate and more fixed cost per cell. A common approach is a fixed maximum cell size, and new customers go to new cells.
  • The router is the shared fate. Every request still crosses it, so it should do one thing — look up the cell — from data it caches locally, so it keeps routing even if the control plane that assigns customers to cells is down. This is static stability: the data plane keeps working with the last known configuration when the control plane fails.
  • Moving customers. A large tenant may outgrow its cell. Migration is copy data, dual-write or freeze writes briefly, flip the mapping, clean up — the same steps as resharding (see Resharding and hot partitions).
  • Deploys go cell by cell. The first cell to get a release is the canary for the rest, so put internal or low-risk customers there.
AdvancedControl plane vs data plane

The data plane does the real-time work (serving requests, routing, replicating); the control plane changes configuration (creating resources, assigning customers, scaling). Control planes are more complex and usually less available, so a resilient design keeps the data plane working when the control plane is down, and avoids control-plane actions in its recovery plan. AWS's disaster recovery whitepaper (checked October 2026) makes exactly this point: use data-plane operations for failover, because data planes have higher availability goals than control planes.

Shuffle sharding with 16 workers

You have 16 workers and give each customer a shuffle shard of 4. One customer sends requests that crash every worker they reach. What fraction of other customers lose all four of their workers? What fraction would lose service with plain sharding into 4 fixed groups of 4?

Show answer

About 0.055% (1 in 1,820) with shuffle sharding, versus 25% with fixed groups.

What’s happening
  1. The number of possible shards is C(16, 4) = 16 × 15 × 14 × 13 / 24 = 1,820.
  2. Another customer loses all four workers only if it was dealt the same four, so the probability is 1/1820 ≈ 0.00055 = 0.055%.
  3. With 4 fixed groups of 4, a quarter of all customers sit on the poison customer's group and all of them go down: 25%.
  4. Customers that share some but not all workers with the poison shard stay up if their clients retry on their surviving workers — which is why shuffle sharding goes together with client-side failover.

#Timeouts, retries, circuit breakers and bulkheads

Added

When service A calls service B over the network, A has to decide four things: how long to wait (timeout), whether to try again (retry), when to stop calling B at all (circuit breaker), and how much of A's own capacity B is allowed to tie up (bulkhead). Get them wrong and a slow dependency turns into an outage of everything that calls it. This is the most common way one sick service takes down a whole system: callers wait, their threads, sockets and memory fill up, their own callers time out and retry, and traffic grows exactly as capacity shrinks — a cascading failure.

The mental model is a restaurant kitchen with one supplier who has stopped answering the phone. A sensible kitchen hangs up after a minute (timeout), redials once or twice with pauses (retry with backoff), stops calling for an hour once it's clear the supplier is closed and takes that dish off the menu (circuit breaker and fallback), and never lets one stuck order take every cook off the line (bulkhead). The Node.js course builds these in code, with Express, AbortSignal and opossum; here the focus is the design decisions and their numbers.

ToolProtects againstThe design decision
Timeout / deadlineWaiting forever on a hung callHow long, set from the callee's p99 and the caller's own deadline
Retry with backoff and jitterTransient blips (a reset connection, one bad instance)Which errors, how many times, at which layer, within what budget
Circuit breakerHammering a dependency that is clearly downWhat counts as failure, the threshold, the cool-down, the fallback
BulkheadOne slow dependency using up all shared capacityHow many concurrent calls each dependency may have

Timeouts come first, because the others depend on them: a call that never finishes never counts as a failure, never triggers a retry, never trips a breaker. Set each timeout a little above the callee's normal p99 latency (not its average), and make sure the whole chain fits inside the user's deadline. Retries fix transient faults but multiply load, and they multiply across layers:

Every layer retries 3 times (4 attempts) Client Gateway Orders Payments Database 1 ≤ 4 ≤ 16 ≤ 64 calls on each hop for ONE user request, while the database is failing Retry at one layer only (next to the failing call) 1 1 1 ≤ 4 upper layers fail fast and pass the error up
Retries multiply across layers: 4 × 4 × 4 = 64 attempts at the bottom for one click.

Google's SRE book (chapter "Handling Overload", checked October 2026) gives two rules that address exactly this: a per-request retry budget of at most three attempts, after which the failure bubbles up to the caller, and a per-client retry budget, where a request is only retried while retries are below 10% of that client's requests. The same chapter adds the layering rule: retry only at the layer immediately above the one that is rejecting requests, and the "Addressing Cascading Failures" chapter repeats it ("avoid amplifying retries by issuing retries at multiple levels").

Circuit breakers handle the case retries can't: a dependency that is down for minutes. The breaker watches recent results; when too many fail it opens and calls fail immediately (serving a fallback) without touching the dependency; after a cool-down it goes half-open and lets a few trial calls through; if they succeed it closes again. The model below runs a rate-based breaker in simulated time against a seeded flaky dependency: one request every 100 ms for 60 seconds, 2% random errors, and a 20-second stretch (10 s to 30 s) where the dependency hangs until the 1,000 ms timeout.

breaker-sim.mjs
// A rate-based circuit breaker in simulated time, against a seeded flaky dependency.
function mulberry32(seed) {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(42);
const blips = Array.from({ length: 600 }, () => rand() < 0.02); // the same 2% random errors for both runs
// The dependency answers in 40 ms; from t=10 s to t=30 s it hangs until our 1,000 ms timeout.
function dependency(t) {
  if (t >= 10_000 && t < 30_000) return { ok: false, ms: 1_000 };
  return { ok: !blips[t / 100], ms: 40 };
}

function makeBreaker({ window = 20, minCalls = 10, maxErrorRate = 0.5, openMs = 5_000, trialCalls = 3 }, log) {
  let state = "CLOSED", results = [], openedAt = 0, trials = 0;
  const go = (next, t, why) => { log(`t=${(t / 1000).toFixed(1).padStart(4)}s ${state.padEnd(9)} -> ${next.padEnd(9)} ${why}`); state = next; };
  return {
    allow(t) {
      if (state === "OPEN" && t - openedAt >= openMs) { go("HALF_OPEN", t, "cool-down over, letting trial calls through"); trials = 0; }
      return state !== "OPEN" && !(state === "HALF_OPEN" && trials >= trialCalls);
    },
    record(t, ok) {
      if (state === "HALF_OPEN") {
        if (!ok) { go("OPEN", t, "a trial call failed"); openedAt = t; return; }
        if (++trials === trialCalls) { go("CLOSED", t, `${trialCalls} trial calls succeeded`); results = []; }
        return;
      }
      results = [...results, ok].slice(-window);
      const rate = results.filter((r) => !r).length / results.length;
      if (results.length >= minCalls && rate >= maxErrorRate) { go("OPEN", t, `error rate ${(rate * 100).toFixed(0)}% over last ${results.length} calls`); openedAt = t; }
    },
  };
}

for (const useBreaker of [false, true]) {
  const breaker = makeBreaker({}, (line) => useBreaker && console.log(line));
  let calls = 0, fastFails = 0, waitedMs = 0, ok = 0;
  for (let t = 0; t < 60_000; t += 100) { // one request every 100 ms for 60 s
    if (useBreaker && !breaker.allow(t)) { fastFails++; continue; } // served from a fallback in ~0 ms
    const r = dependency(t);
    calls++; waitedMs += r.ms; if (r.ok) ok++;
    if (useBreaker) breaker.record(t, r.ok);
  }
  console.log(`${useBreaker ? "with breaker" : "no breaker  "}: ${calls} calls to the dependency, ${ok} ok, ${fastFails} fast-fails, ${(waitedMs / 1000).toFixed(1)} s spent waiting\n`);
}
Result
no breaker  : 600 calls to the dependency, 391 ok, 0 fast-fails, 216.0 s spent waiting

t=10.9s CLOSED    -> OPEN      error rate 50% over last 20 calls
t=15.9s OPEN      -> HALF_OPEN cool-down over, letting trial calls through
t=15.9s HALF_OPEN -> OPEN      a trial call failed
t=20.9s OPEN      -> HALF_OPEN cool-down over, letting trial calls through
t=20.9s HALF_OPEN -> OPEN      a trial call failed
t=25.9s OPEN      -> HALF_OPEN cool-down over, letting trial calls through
t=25.9s HALF_OPEN -> OPEN      a trial call failed
t=30.9s OPEN      -> HALF_OPEN cool-down over, letting trial calls through
t=31.1s HALF_OPEN -> CLOSED    3 trial calls succeeded
with breaker: 404 calls to the dependency, 382 ok, 196 fast-fails, 28.6 s spent waiting
What’s happening
  1. The world is fixed before either run: blips holds the same seeded 2% random errors (generated with mulberry32(42)) for both runs, and the outage window is 10–30 s. Without a breaker, all 600 requests reach the dependency; 200 of them land in the outage and each waits the full 1,000 ms timeout, and 9 more hit random blips, so 391 succeed. "Spent waiting" adds up how long every request held a connection and a worker: 216 s, of which 200 s was waiting on a dependency that was never going to answer.
  2. CLOSED → OPEN. The breaker keeps the last 20 results and needs at least 10 before it judges. When the outage starts at 10.0 s, failures pile into the window; at 10.9 s the tenth failure makes it 10 out of 20 = 50%, which meets maxErrorRate, and the breaker opens. From then on requests are rejected in ~0 ms and served from a fallback.
  3. OPEN → HALF_OPEN → OPEN. Every 5 s (openMs) the breaker lets trial calls through. At 15.9, 20.9 and 25.9 s the dependency is still down, so the first trial fails and the breaker reopens immediately. Each probe costs one request one timeout, instead of 50 requests per 5 s.
  4. HALF_OPEN → CLOSED. The dependency recovers at 30.0 s, but the breaker only finds out at the next probe, 30.9 s. Three trials (30.9, 31.0, 31.1 s) succeed and it closes. The 9 requests between 30.0 and 30.9 s were fast-failed although the dependency was healthy — that is the price of the cool-down.
  5. The totals: with the breaker, 404 calls reached the dependency and 382 succeeded (9 fewer than without, the ones from the recovery gap), 196 requests got the fallback instantly, and total waiting fell from 216 s to 28.6 s. Those 187 seconds of saved waiting are threads, sockets and memory the caller keeps for its other work, which is what stops the failure cascading.
  6. Design choices visible in the code: a rate over a window (with a minimum call count) rather than "3 failures in a row", so a busy service doesn't trip on noise and a quiet one doesn't trip on two errors; several trial calls before closing, so one lucky success doesn't slam a still-sick service with full traffic. If you changed openMs to 30 s, recovery would be noticed up to 30 s late; at 1 s, the hung dependency would get a probe every second.
AdvancedDeadlines, not just timeouts

A timeout per hop isn't enough when calls are chained. If the gateway waits 2 s, but orders waits 3 s per attempt for payments with 2 retries, and payments waits 5 s for the bank, the user gets a 504 at 2 s while the services below keep working on the request for many seconds more. Deadline propagation fixes this: the edge sets an absolute deadline, every hop forwards "time left" (gRPC does this with its deadline header; over HTTP you send your own), and no hop starts an attempt it can't finish.

deadlines.mjs
// gateway -> orders -> payments -> bank, and the bank hangs. Who keeps working, and for how long?
const callers = [ // each hop's own settings for the call it makes downstream
  { name: "gateway", timeoutMs: 2_000, attempts: 1 },
  { name: "orders", timeoutMs: 3_000, attempts: 3 }, // 3 s timeout, 2 retries
  { name: "payments", timeoutMs: 5_000, attempts: 1 }, // its call to the hung bank
];
const MARGIN = 50; // each hop keeps 50 ms to build its own error response

// One hop handling one request that starts at `start`. `deadline` is absolute (or Infinity if not propagated).
function handle(i, start, deadline, busy) {
  const hop = callers[i];
  let t = start;
  for (let a = 0; a < hop.attempts; a++) {
    const giveUpAt = Math.min(t + hop.timeoutMs, deadline);
    if (giveUpAt <= t) break; // no time left: don't start another attempt
    if (i + 1 < callers.length) handle(i + 1, t, deadline === Infinity ? Infinity : giveUpAt - MARGIN, busy);
    t = giveUpAt; // the callee never answers, so every attempt runs to its timeout
  }
  busy.push({ name: hop.name, from: start, to: t });
}

for (const propagate of [false, true]) {
  const busy = [];
  handle(0, 0, propagate ? 2_000 : Infinity, busy);
  const userAnswer = busy.find((b) => b.name === "gateway").to;
  console.log(`${propagate ? "propagated deadline" : "fixed per-hop timeouts"}: the user gets a 504 at ${userAnswer} ms`);
  const order = (b) => callers.findIndex((c) => c.name === b.name);
  busy.sort((x, y) => order(x) - order(y) || x.from - y.from);
  for (const b of busy) console.log(`  ${b.name.padEnd(9)} busy ${String(b.from).padStart(4)} -> ${String(b.to).padStart(5)} ms`);
  const total = busy.reduce((s, b) => s + (b.to - b.from), 0);
  const wasted = busy.reduce((s, b) => s + Math.max(0, b.to - Math.max(b.from, userAnswer)), 0);
  console.log(`  ${total} ms of work held open, ${wasted} ms of it after the user already had the 504\n`);
}
Result
fixed per-hop timeouts: the user gets a 504 at 2000 ms
  gateway   busy    0 ->  2000 ms
  orders    busy    0 ->  9000 ms
  payments  busy    0 ->  5000 ms
  payments  busy 3000 ->  8000 ms
  payments  busy 6000 -> 11000 ms
  26000 ms of work held open, 20000 ms of it after the user already had the 504

propagated deadline: the user gets a 504 at 2000 ms
  gateway   busy    0 ->  2000 ms
  orders    busy    0 ->  1950 ms
  payments  busy    0 ->  1900 ms
  5850 ms of work held open, 0 ms of it after the user already had the 504
What’s happening
  1. handle(i, start, deadline, busy) simulates one hop handling the request: for each attempt it waits until min(start + timeout, deadline), because the bank never answers, and it calls the next hop for each attempt. It records how long the hop was busy.
  2. Fixed timeouts (deadline = Infinity): the gateway gives up at 2,000 ms and the user sees a 504. But orders doesn't know, and keeps going: three attempts of 3 s, busy until 9,000 ms. Each attempt started a fresh payments call that waits 5 s for the bank: busy 0–5,000, 3,000–8,000 and 6,000–11,000 ms.
  3. That is 26,000 ms of held-open work, and 20,000 ms of it happened after the user already had their error — capacity spent on an answer nobody will read, during an incident when capacity is scarcest.
  4. Propagated deadline: the gateway sets 2,000 ms. Orders receives 1,950 (keeping 50 ms to build its error response), so its first attempt is capped at 1,950 ms, and it doesn't retry because no time is left. Payments receives 1,900. Everyone stops by 2,000 ms: 5,850 ms of work, 0 wasted.
  5. The rule to say out loud: a timeout is "how long I'll wait"; a deadline is "when the answer stops being useful", and it should travel with the request. Retries must fit inside it too — a retry that can't finish before the deadline is pure waste.
AdvancedRetry storms and retry budgets

Retries can do worse than waste work: they can keep a system down after the original problem has gone. This is a metastable failure (the name comes from Bronson et al., "Metastable Failures in Distributed Systems", HotOS 2021): a temporary trigger pushes the system into overload, and a sustaining effect — here, retries — keeps it there even after the trigger is removed. The fluid model below has 900 new requests per second against a service that can serve 1,000. From second 10 to 19 capacity drops to 400 (half the fleet lost). Rejecting a request isn't free — accepting the connection, TLS and parsing cost about 20% of serving it — so overload eats into useful capacity.

retry-storm.mjs
// Retry storms: a fluid model, one tick = 1 second. 900 new requests/s arrive at a service that can serve 1,000/s.
// From tick 10 to 19 capacity drops to 400/s (half the fleet is lost). Every rejected attempt still costs the
// server 20% of a real request (accept, TLS, parse, auth), so overload eats into useful capacity.
const NEW = 900, REJECT_COST = 0.2;
const capacityAt = (t) => (t >= 10 && t < 20 ? 400 : 1_000);

function run({ maxAttempts, budget = Infinity }) {
  let waiting = [NEW]; // waiting[k] = attempts this tick that are a request's (k+1)th try
  const rows = [];
  for (let t = 0; t < 60; t++) {
    const offered = waiting.reduce((a, b) => a + b, 0);
    const cap = capacityAt(t);
    // Served s and rejected r share the capacity: s + REJECT_COST * r = cap, with s + r = offered.
    const served = offered <= cap ? offered : Math.max(0, (cap - REJECT_COST * offered) / (1 - REJECT_COST));
    const failShare = 1 - served / offered;
    let retries = waiting.slice(0, maxAttempts - 1).map((n) => n * failShare); // failures that may try again
    const wanted = retries.reduce((a, b) => a + b, 0);
    const allowed = Math.min(wanted, budget * NEW); // a retry budget caps retries at a share of new requests
    if (wanted > 0) retries = retries.map((n) => (n * allowed) / wanted);
    rows.push({ t, offered, served });
    waiting = [NEW, ...retries];
  }
  return rows;
}

const policies = {
  "no retries": { maxAttempts: 1 },
  "3 retries": { maxAttempts: 4 },
  "3 retries + 10% budget": { maxAttempts: 4, budget: 0.1 },
};
const results = Object.fromEntries(Object.entries(policies).map(([name, p]) => [name, run(p)]));
console.log("tick  capacity | " + Object.keys(policies).map((n) => n.padStart(22)).join(" | "));
console.log("               | " + Object.keys(policies).map(() => "offered   served".padStart(22)).join(" | "));
for (const t of [5, 10, 15, 19, 20, 21, 25, 30, 59]) {
  const cells = Object.values(results).map((rows) => `${Math.round(rows[t].offered).toString().padStart(13)} ${Math.round(rows[t].served).toString().padStart(8)}`);
  console.log(`${String(t).padStart(4)}  ${String(capacityAt(t)).padStart(8)} | ${cells.join(" | ")}`);
}
Result
tick  capacity |             no retries |              3 retries | 3 retries + 10% budget
               |       offered   served |       offered   served |       offered   served
   5      1000 |           900      900 |           900      900 |           900      900
  10       400 |           900      275 |           900      275 |           900      275
  15       400 |           900      275 |          3600        0 |           990      253
  19       400 |           900      275 |          3600        0 |           990      253
  20      1000 |           900      900 |          3600      350 |           990      990
  21      1000 |           900      900 |          3338      416 |           900      900
  25      1000 |           900      900 |          2732      567 |           900      900
  30      1000 |           900      900 |          2498      625 |           900      900
  59      1000 |           900      900 |          2319      670 |           900      900
What’s happening
  1. Each tick, offered is the 900 new requests plus every retry scheduled from the previous tick's failures. When offered exceeds capacity, the server serves s and rejects r such that s + 0.2 × r = capacity: the more it rejects, the less it serves. With 900 offered and capacity 400, it serves (400 − 0.2 × 900) / 0.8 = 275.
  2. No retries: during the outage 275 of 900 succeed and the rest fail outright. At tick 20 capacity returns and the service is immediately back to 900 of 900. Painful, but it ends when the trigger ends.
  3. 3 retries: failures come back. By tick 15 the offered load is 3,600/s (every request on its fourth attempt or on its way), and the server spends its whole capacity rejecting: served drops to 0. At tick 20 capacity is back to 1,000, but 3,600 attempts arrive, so it still serves only 350.
  4. Then the trap: offered load drifts down (3,338, 2,732, 2,498…) but settles around 2,300/s, with only about 670 served per second. At tick 59 — 40 seconds after the trigger ended — the system is still failing about a quarter of requests. Nothing is broken; the retries alone sustain the overload. Real systems in this state often need traffic to be cut (drained, rate-limited at the edge) before they recover.
  5. Retry budget: retries are capped at 10% of new requests (budget * NEW = 90/s), as in the SRE book's per-client budget. During the outage the server sees at most 990/s, it serves 253, and at tick 20 it serves 990 — the backlog of retries — and is back to normal at tick 21.
  6. What this shows: per-request retry limits ("3 retries") bound the work for one request, but not the total; under heavy failure they multiply load by up to 4. A budget bounds the total extra load (here to 1.1×), so retries help when failures are rare and switch themselves off when failures are common, which is exactly when retrying hurts.
AdvancedBulkheads: sizing concurrency per dependency

A bulkhead gives each dependency its own limited pool of concurrent calls (connections, threads, semaphore slots), named after a ship's watertight walls. Size it with Little's law: requests in flight = arrival rate × time each one takes.

bulkhead-size.mjs
// Sizing bulkheads with Little's law: requests in flight = arrival rate × time each one takes.
const deps = [
  { name: "payments", rps: 200, p99Ms: 300, slowMs: 5_000 },
  { name: "recommendations", rps: 800, p99Ms: 120, slowMs: 5_000 },
  { name: "reviews", rps: 600, p99Ms: 80, slowMs: 5_000 },
];
const HEADROOM = 2; // room for bursts above the average rate
console.log("dependency        normal in-flight   pool size   in-flight if it hangs for 5 s");
for (const d of deps) {
  const normal = (d.rps * d.p99Ms) / 1000; // Little's law with p99 latency (pessimistic)
  const pool = Math.ceil(normal * HEADROOM);
  const hung = (d.rps * d.slowMs) / 1000;
  console.log(`${d.name.padEnd(17)} ${normal.toFixed(0).padStart(16)} ${String(pool).padStart(11)} ${hung.toFixed(0).padStart(31)}`);
}
const poolOf = (d) => Math.ceil(((d.rps * d.p99Ms) / 1000) * HEADROOM);
const shared = deps.reduce((sum, d) => sum + poolOf(d), 0);
const reviews = deps[2];
console.log(`\none shared pool of ${shared}: a hung "reviews" alone wants ${(reviews.rps * reviews.slowMs) / 1000} slots and starves payments.`);
console.log(`separate pools: a hung "reviews" fills its own ${poolOf(reviews)} slots; extra calls fail fast, payments keep theirs.`);
Result
dependency        normal in-flight   pool size   in-flight if it hangs for 5 s
payments                        60         120                            1000
recommendations                 96         192                            4000
reviews                         48          96                            3000

one shared pool of 408: a hung "reviews" alone wants 3000 slots and starves payments.
separate pools: a hung "reviews" fills its own 96 slots; extra calls fail fast, payments keep theirs.
What’s happening
  1. Payments gets 200 calls/s with a p99 of 300 ms, so in normal operation about 200 × 0.3 = 60 calls are in flight. Using p99 instead of the average is deliberately pessimistic; doubling it (HEADROOM = 2) for bursts gives a pool of 120.
  2. Recommendations: 800 × 0.12 = 96 in flight, pool 192. Reviews: 600 × 0.08 = 48, pool 96.
  3. Now suppose reviews hangs: every call takes the full 5 s timeout. Little's law says it would want 600 × 5 = 3,000 concurrent calls. In one shared pool of 408, reviews alone fills every slot within a second, and payments — the call that earns money — can't get a connection.
  4. With separate pools, reviews fills its own 96 slots; extra reviews calls are rejected instantly (the page hides the reviews section), and payments keeps its 120. That's the bulkhead: a hang can only exhaust its own compartment.
  5. If you changed nothing but the timeout from 5 s to 500 ms, a hung reviews would want 300 slots instead of 3,000: short timeouts and bulkheads work together.
AdvancedHow the four fit together

From the inside out, a typical outbound call is wrapped like this: a timeout (derived from the remaining deadline) on each attempt; a circuit breaker around the call; a retry with exponential backoff and full jitter around that, which treats "breaker open" as not retryable and draws from a retry budget; and a bulkhead limiting concurrent calls to that dependency. Only idempotent operations are retried, or operations that carry an idempotency key (see Idempotency keys for APIs). A service mesh (Envoy, Istio, Linkerd) can apply timeouts, retries, outlier ejection and concurrency limits uniformly without code changes — but then make sure the application isn't retrying too, or you're back to 4 × 4.

Worst case at the bottom

A request passes through a gateway, an orders service and an inventory service before reaching the database. The gateway, orders and inventory each retry failed calls 2 times. The database is down. How many database calls can one user request cause? What changes if only inventory retries?

Show answer

27 calls with retries at every layer; 3 if only inventory retries.

What’s happening
  1. Retrying 2 times means up to 3 attempts per call at each layer.
  2. The gateway makes up to 3 calls to orders. Each of those makes up to 3 calls to inventory: 3 × 3 = 9. Each inventory call makes up to 3 database calls: 9 × 3 = 27.
  3. If only inventory (the layer next to the failing database) retries, the gateway and orders make 1 call each, and inventory makes up to 3: 3 database calls.
  4. During an outage the multiplied number is the one that matters, because every user request is failing at once: 1,000 requests per second become 27,000 calls per second against a database that is trying to recover.

#Graceful degradation and fallbacks

Added

Graceful degradation means deciding, in advance, what your system gives up when it can't do everything — so that under failure or overload it does less, instead of doing nothing. A product page whose recommendations service is down should still show the product and the buy button; a checkout under heavy load should keep taking payments while it stops computing "people also bought".

The mental model is a hospital in a power cut. The generator can't run everything, so the wiring was planned years ago: operating theatres and life support are on the generator circuit, the cafeteria and the gift shop are not. Nobody decides during the blackout. In software, the equivalent planning is: rank features by how critical they are, give every non-critical dependency a fallback, and when capacity runs short, shed the least important work first.

The usual fallbacks, from best to worst user experience:

FallbackExampleWatch out for
Stale data from a cacheLast known recommendations, yesterday's exchange ratesHow stale is acceptable? Never for balances, stock counts at checkout, permissions
A static or generic defaultBestsellers instead of personalised picks; "usually ships in 2 days"Must not look like real, personal data
Hide the featureNo reviews section, no "trending" widgetLayout must not break when a section is missing
Reduced qualityLower video bitrate, fewer search results, no spell-correctionUsers should still be able to do the main task
Accept now, finish later"Order received, confirmation email to follow"; queue the writeNeeds a durable queue and idempotent processing
Read-only modeBrowsing works, edits are paused, with a bannerSay so clearly; don't let writes silently fail
Fail clearly"Payments are unavailable, please try again in a few minutes"The right answer when a fallback would be wrong (money, auth, safety)

Degradation has two triggers. A dependency fails — then a circuit breaker or error handler serves the fallback (previous topic). Or demand exceeds capacity — then the service must choose which requests to refuse, which is load shedding. Google's SRE book (chapter "Handling Overload", checked October 2026) tags every request with a criticality — CRITICAL_PLUS, CRITICAL, SHEDDABLE_PLUS, SHEDDABLE — and sheds the lowest first; requests from production jobs default to CRITICAL, batch jobs to SHEDDABLE_PLUS. The model compares that with shedding at random:

shed.mjs
// Load shedding: when demand exceeds capacity, WHO gets turned away?
const CAPACITY = 1_000; // requests per second the service can really serve
const mix = [ // share of traffic per priority, most important first
  { name: "critical (checkout, login)", share: 0.3 },
  { name: "default (browse, search)", share: 0.5 },
  { name: "sheddable (recs, prefetch)", share: 0.2 },
];

// Without priorities the server drops at random: every class loses the same fraction.
const randomShed = (load) => mix.map(() => Math.min(1, CAPACITY / load));

// With priorities: admit classes in order until capacity is used up.
function priorityShed(load) {
  let left = CAPACITY;
  return mix.map((c) => {
    const demand = load * c.share;
    const admitted = Math.min(demand, left);
    left -= admitted;
    return admitted / demand;
  });
}

const pct = (x) => `${Math.round(x * 100)}%`.padStart(5);
console.log("        |  random drop      |  by priority");
console.log("load/s  |  crit  dflt  shed |  crit  dflt  shed");
for (const load of [800, 1_200, 1_600, 2_500, 4_000]) {
  const r = randomShed(load).map(pct).join(" ");
  const p = priorityShed(load).map(pct).join(" ");
  console.log(`${String(load).padStart(6)}  | ${r} | ${p}`);
}
Result
        |  random drop      |  by priority
load/s  |  crit  dflt  shed |  crit  dflt  shed
   800  |  100%  100%  100% |  100%  100%  100%
  1200  |   83%   83%   83% |  100%  100%   17%
  1600  |   63%   63%   63% |  100%   65%    0%
  2500  |   40%   40%   40% |  100%   20%    0%
  4000  |   25%   25%   25% |   83%    0%    0%
What’s happening
  1. The service can serve 1,000 requests per second. Traffic is 30% critical (checkout, login), 50% default (browsing, search) and 20% sheddable (recommendations, prefetches, analytics beacons).
  2. Random dropping (what happens if the server simply rejects whatever arrives when it's full): every class gets the same success rate, 1000 / load. At 1,600 req/s that's 63% — so 37% of checkouts fail, while the server is busy serving prefetches nobody asked for.
  3. Priority shedding admits classes in order until capacity runs out. At 1,200 req/s critical (360) and default (600) all fit, and sheddable gets the remaining 40 of its 240: 17%. At 1,600, critical takes 480, default gets 520 of 800 (65%), sheddable gets nothing.
  4. At 2,500 req/s critical still gets 100% (750 of 750); default gets the last 250 of 1,250 (20%). Only at 4,000 req/s, when critical traffic alone (1,200) exceeds capacity, does checkout start to fail (83%). Priority shedding moved the point where the business is hurt from "just over capacity" to "four times capacity".
  5. In a real system the "admit in order" step is a per-request decision based on current load, for example: shed sheddable requests above 70% utilisation or when the queue holds more than 100 ms of work, default above 90%, critical only when the server would otherwise fall over. The criticality travels with the request (a header set at the edge), so a backend can shed a sheddable request even though it arrived through a critical frontend.
AdvancedWhat fallbacks do to availability

Fallbacks don't just feel better — they change the arithmetic of availability. If a page needs six services and any of them failing breaks it, the page's availability is the product of all six. If only two are truly critical and the other four have fallbacks, only the two count:

page-availability.mjs
// A product page calls six services, each 99.9% available. How available is the page?
const deps = [
  { name: "catalog", availability: 0.999, critical: true },
  { name: "pricing", availability: 0.999, critical: true },
  { name: "inventory", availability: 0.999, critical: false }, // fallback: "usually ships in 2 days"
  { name: "reviews", availability: 0.999, critical: false }, // fallback: hide the section
  { name: "recommendations", availability: 0.999, critical: false }, // fallback: bestsellers list
  { name: "personalisation", availability: 0.999, critical: false }, // fallback: generic banner
];
const MIN_PER_MONTH = 30 * 24 * 60;
const product = (xs) => xs.reduce((p, d) => p * d.availability, 1);

const hardDeps = product(deps); // any failure breaks the page
const withFallbacks = product(deps.filter((d) => d.critical)); // only critical failures break it
const fullExperience = hardDeps; // everything working, nothing degraded

for (const [label, a] of [
  ["every dependency is hard", hardDeps],
  ["fallbacks for the 4 non-critical", withFallbacks],
]) {
  console.log(`${label.padEnd(33)} ${(a * 100).toFixed(3)}%  ~${Math.round((1 - a) * MIN_PER_MONTH)} min/month broken`);
}
console.log(`with fallbacks, the page is fully personalised ${(fullExperience * 100).toFixed(3)}% of the time and degraded but working ${((withFallbacks - fullExperience) * 100).toFixed(3)}%`);
Result
every dependency is hard          99.401%  ~259 min/month broken
fallbacks for the 4 non-critical  99.800%  ~86 min/month broken
with fallbacks, the page is fully personalised 99.401% of the time and degraded but working 0.399%
What’s happening
  1. With every dependency hard, the page works only when all six do: 0.999⁶ = 99.401%, about 259 minutes a month broken (30 days × 1,440 min × 0.006). Six "three nines" services make a page that's barely above two nines.
  2. Give the four non-critical services fallbacks and only catalog and pricing can break the page: 0.999² = 99.800%, about 86 minutes a month — three times fewer broken minutes, without making any service more reliable.
  3. The difference doesn't disappear: for 0.399% of the time the page works but is degraded (generic banner, bestsellers instead of picks). That's the trade you're making, and it's usually an easy one.
  4. The model assumes failures are independent and that fallbacks themselves never fail. Neither is quite true: a shared database outage takes several services at once, and a fallback that's never exercised often doesn't work when it's finally needed (see the gotcha below).
AdvancedDesigning degradation in practice
  • Write the degradation table during design. For each dependency: is it critical? What does the user see when it's down? What's the fallback, and how stale can it be? Interviewers respond well to this being explicit.
  • Static stability. Keep the things you need during an incident already in place: pre-provisioned capacity rather than "autoscaling will add it", cached config rather than "fetch it from the config service on start-up". A system that needs a control plane to recover is fragile exactly when the control plane is busy.
  • Kill switches. A feature flag per expensive or risky feature lets operators switch it off in seconds during an incident (an expensive search facet, a new ranking model). See Deployments: blue-green, canary and feature flags.
  • Brownouts. Some teams deliberately degrade non-critical features for short periods (or for a small share of traffic) to prove the fallbacks work and to see how much capacity they free.
  • Shed early and cheaply. Reject at the edge or at the front of the queue, before doing expensive work. A request rejected after a 200 ms database query wasted the 200 ms. Return 503 or 429 with Retry-After so well-behaved clients back off (see Backpressure and load shedding).
  • Clients degrade too. The frontend should render the page skeleton without the failed widget, keep the last data it had, and show a clear, non-alarming message — a degraded backend with a frontend that crashes on a missing field is not degraded, it's down.

#Observability: logs, metrics and traces

Added

Observability is how well you can answer new questions about what your system is doing from the data it emits — without shipping new code. Monitoring tells you that something is wrong ("checkout error rate is 4%"); observability lets you find why ("only for card payments, only through the payments-v2 pods, only since 14:02, and the time is spent waiting on the bank API").

The data comes in three kinds, and a good mental model is a car. Metrics are the dashboard gauges: cheap numbers aggregated over time (speed, temperature) that tell you at a glance something is off. Logs are the mechanic's notes: detailed records of individual events, expensive to keep for everything but invaluable when you know what you're looking for. Traces are a dashcam of one journey: the path of a single request through every service, with timings, so you can see where the time went.

SignalWhat it isGood forCost and limits
MetricsCounters, gauges and histograms, aggregated per time window and labelsDashboards, alerts, trends, SLOsCheap per event; cardinality (number of label combinations) is the cost — never label by user id
LogsOne structured record per event (JSON), with request id and contextDebugging specific requests, audits, rare errorsVolume grows with traffic; sample or tier noisy logs; never log secrets or raw personal data
TracesA tree of spans (one per operation) sharing a trace id, propagated across servicesLatency breakdown across services, finding the slow hop, dependency mapsUsually sampled; needs context propagation through every hop, including queues

Three short checklists say which metrics to collect:

  • The four golden signals (Google SRE book, "Monitoring Distributed Systems", checked October 2026): latency (track successful and failed requests separately — "a slow error is even worse than a fast error"), traffic, errors (including wrong answers with a 200), and saturation (how full the most constrained resource is).
  • RED for every request-driven service (Tom Wilkie): Rate, Errors, Duration.
  • USE for every resource (Brendan Gregg): Utilisation, Saturation, Errors — for CPU, memory, disks, connection pools, queues.
trace 4bf92f35…4736 · POST /checkout · 420 ms 0 100 200 300 400 ms gatewayPOST /checkout authverify token orderscreate order orders-dbINSERT inventoryreserve paymentscharge bank-apiauthorise 309 ms: the slow hop kafkapublish traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-<parent span id>-01
One trace, eight spans: the waterfall shows at a glance that the bank call is most of the request.

Latency must be measured as a distribution, not an average. The SRE book's example: a 100 ms average at 1,000 requests per second can hide 1% of requests taking 5 seconds. Users feel the tail, and at scale "1% of requests" is thousands of people a minute. The model shows what an average hides, and the most common dashboard mistake with percentiles:

percentiles.mjs
// Why dashboards show p50/p99 from histograms, not averages. Latencies are seeded, so the output is stable.
function mulberry32(seed) {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(1);
const normal = () => Math.sqrt(-2 * Math.log(1 - rand())) * Math.cos(2 * Math.PI * rand());
const healthy = () => 40 * Math.exp(0.35 * normal()); // around 40 ms, a little spread
const gcHost = () => (rand() < 0.06 ? 600 + 400 * rand() : healthy()); // 6% of requests hit a GC pause

// Four hosts behind a load balancer: 10,000 requests each; host D has the GC problem.
const hosts = { A: healthy, B: healthy, C: healthy, D: gcHost };
const samples = Object.fromEntries(Object.entries(hosts).map(([h, f]) => [h, Array.from({ length: 10_000 }, f)]));

const pct = (xs, p) => { const s = [...xs].sort((a, b) => a - b); return s[Math.ceil((p / 100) * s.length) - 1]; };
const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
const all = Object.values(samples).flat();
const ms = (x) => `${Math.round(x)} ms`.padStart(7);

console.log(`all requests: mean ${ms(mean(all))}  p50 ${ms(pct(all, 50))}  p99 ${ms(pct(all, 99))}  p99.9 ${ms(pct(all, 99.9))}`);
for (const [h, xs] of Object.entries(samples)) console.log(`  host ${h}: mean ${ms(mean(xs))}  p99 ${ms(pct(xs, 99))}`);

// Averaging per-host p99s is a common dashboard bug: percentiles don't average.
const avgOfP99 = mean(Object.values(samples).map((xs) => pct(xs, 99)));
console.log(`average of the four p99s: ${ms(avgOfP99)}  (wrong: not the p99 of anything)`);

// Histograms DO merge: add bucket counts across hosts, then read the percentile off the merged buckets.
const bounds = [10, 20, 40, 80, 160, 320, 640, 1280]; // roughly exponential bucket upper bounds, in ms
const histogram = (xs) => bounds.map((b, i) => xs.filter((x) => x <= b && (i === 0 || x > bounds[i - 1])).length);
const merged = Object.values(samples).map(histogram).reduce((m, h) => m.map((c, i) => c + h[i]));
let seen = 0;
const p99Bucket = bounds.find((b, i) => (seen += merged[i]) >= 0.99 * all.length);
console.log(`merged histogram buckets: ${merged.join(", ")}`);
console.log(`p99 from the merged histogram: in the bucket up to ${p99Bucket} ms (exact p99: ${Math.round(pct(all, 99))} ms)`);
Result
all requests: mean   54 ms  p50   40 ms  p99  732 ms  p99.9  970 ms
  host A: mean   43 ms  p99   90 ms
  host B: mean   42 ms  p99   91 ms
  host C: mean   42 ms  p99   87 ms
  host D: mean   88 ms  p99  931 ms
average of the four p99s:  300 ms  (wrong: not the p99 of anything)
merged histogram buckets: 1, 938, 18648, 18920, 895, 3, 77, 518
p99 from the merged histogram: in the bucket up to 1280 ms (exact p99: 732 ms)
What’s happening
  1. Four hosts serve 10,000 requests each, with latencies from a seeded generator so the output is stable. Hosts A–C are healthy (around 40 ms with some spread); host D pauses for garbage collection on 6% of its requests, which then take 600–1,000 ms.
  2. Across all 40,000 requests the mean is 54 ms — it looks fine. The median (p50) is 40 ms, the typical request. But p99 is 732 ms and p99.9 is 970 ms: 1.5% of all requests (6% of a quarter) are slow, and the mean averaged them away.
  3. Per host, A–C have p99 around 90 ms and D has 931 ms. That's how you'd find the cause: break the percentile down by host, version or endpoint.
  4. A dashboard that averages the four per-host p99s shows 300 ms — a number that is the p99 of nothing. Percentiles can't be averaged or summed: the true p99 of the combined traffic is 732 ms. This is why you can't pre-compute p99 per host and roll it up.
  5. Histograms can be merged: each host counts requests into the same buckets (10, 20, 40… 1,280 ms upper bounds, roughly exponential as the SRE book recommends), the counts are simply added, and a percentile is read off the merged buckets. The merged counts are 1, 938, 18648, 18920, 895, 3, 77, 518; the 99th percentile falls in the 640–1,280 ms bucket, consistent with the exact 732 ms. The price is resolution: you know the bucket, not the exact value, so pick bucket bounds around your SLO threshold.
  6. The lesson for a design review: alert and set SLOs on percentiles computed from merged histograms (Prometheus histograms, OpenTelemetry exponential histograms), never on means, and never on averaged percentiles.
AdvancedTracing across services

A trace starts at the edge: the first service creates a 16-byte trace id, and every call carries it onward along with the id of the calling span. The W3C Trace Context standard (a W3C Recommendation, checked October 2026) defines the traceparent header for this: version-traceid-parentid-flags, for example 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01, where the last byte's lowest bit says whether the trace is sampled. OpenTelemetry SDKs create spans and propagate the header for you; the design work is making sure every hop propagates it — including messages on a queue, where the trace context must travel in the message headers. The model rebuilds the waterfall from the spans services report and asks where the time went:

trace.mjs
// One trace = a tree of spans sharing a trace id. Print the waterfall and find where the time went.
const traceId = "4bf92f3577b34da6a3ce929d0e0e4736"; // 16 bytes, made once at the edge
const spans = [ // what each service reports (times in ms from the start of the request)
  { id: "a1", parent: null, service: "gateway", name: "POST /checkout", start: 0, end: 420 },
  { id: "b2", parent: "a1", service: "auth", name: "verify token", start: 4, end: 22 },
  { id: "c3", parent: "a1", service: "orders", name: "create order", start: 25, end: 412 },
  { id: "d4", parent: "c3", service: "orders-db", name: "INSERT orders", start: 30, end: 58 },
  { id: "e5", parent: "c3", service: "inventory", name: "reserve items", start: 60, end: 118 },
  { id: "f6", parent: "c3", service: "payments", name: "charge card", start: 60, end: 396 },
  { id: "g7", parent: "f6", service: "bank-api", name: "authorise", start: 72, end: 381 },
  { id: "h8", parent: "c3", service: "kafka", name: "publish OrderCreated", start: 398, end: 405 },
];
const children = (id) => spans.filter((s) => s.parent === id);
const SCALE = 420 / 40; // 40 characters wide

function print(span, depth = 0) {
  const bar = " ".repeat(Math.round(span.start / SCALE)) + "█".repeat(Math.max(1, Math.round((span.end - span.start) / SCALE)));
  const label = `${"  ".repeat(depth)}${span.service}: ${span.name}`;
  console.log(`${label.padEnd(34)} ${String(span.end - span.start).padStart(4)} ms |${bar.padEnd(40)}|`);
  for (const c of children(span.id)) print(c, depth + 1);
}
const root = spans.find((s) => !s.parent);
print(root);

// Self time = a span's duration minus the time covered by its children (overlapping children counted once).
function selfTime(span) {
  let covered = 0, until = span.start;
  for (const c of children(span.id).sort((x, y) => x.start - y.start)) {
    covered += Math.max(0, c.end - Math.max(c.start, until));
    until = Math.max(until, c.end);
  }
  return span.end - span.start - covered;
}
// Critical path: walk back from the end; at each point take the child that finished last before it.
function criticalPath(span) {
  const path = [span];
  let t = span.end;
  for (const c of children(span.id).sort((x, y) => y.end - x.end)) {
    if (c.end <= t) { path.push(...criticalPath(c)); t = c.start; }
  }
  return path;
}
const onPath = criticalPath(root);
console.log("\ncritical path, by self time: " + onPath.sort((a, b) => selfTime(b) - selfTime(a)).map((s) => `${s.service} ${selfTime(s)}`).join(", "));
console.log("off the critical path: " + spans.filter((s) => !onPath.includes(s)).map((s) => s.service).join(", "));
console.log(`traceparent sent by orders to payments: 00-${traceId}-${"c3".padStart(16, "0")}-01`);
Result
gateway: POST /checkout             420 ms |████████████████████████████████████████|
  auth: verify token                 18 ms |██                                      |
  orders: create order              387 ms |  █████████████████████████████████████ |
    orders-db: INSERT orders         28 ms |   ███                                  |
    inventory: reserve items         58 ms |      ██████                            |
    payments: charge card           336 ms |      ████████████████████████████████  |
      bank-api: authorise           309 ms |       █████████████████████████████    |
    kafka: publish OrderCreated       7 ms |                                      █ |

critical path, by self time: bank-api 309, orders-db 28, payments 27, auth 18, orders 16, gateway 15, kafka 7
off the critical path: inventory
traceparent sent by orders to payments: 00-4bf92f3577b34da6a3ce929d0e0e4736-00000000000000c3-01
What’s happening
  1. Each service reports its spans independently (to a collector); all they share is the trace id and a parent link. print rebuilds the tree from those links and draws each span as a bar on a 40-character time axis — the same waterfall a tracing UI shows.
  2. selfTime is a span's duration minus the time covered by its children, counting overlapping children once. Orders lasted 387 ms, but its children covered 371 ms of that (28 + 58 + 278 of payments not overlapped by inventory + 7), so orders itself only spent 16 ms.
  3. criticalPath walks backwards from the end of each span, picking the child that finished last before the current point: kafka, then payments (and inside it the bank call), then the insert. Inventory is off the critical path because it runs in parallel with payments — making it faster would not make the checkout faster at all.
  4. Sorted by self time, the critical path is dominated by bank-api at 309 ms of the 420; everything else together is 111 ms. That's the actionable answer: optimise or cache nothing else until the bank call is dealt with (a faster bank endpoint, an async authorisation, a timeout and a "pending" state).
  5. The last line is the header orders sends when it calls payments: the same trace id, plus orders' span id as the parent (real span ids are 8 random bytes; the model pads its short ids to 16 hex characters), with flag 01 meaning sampled.
AdvancedSampling, cardinality and cost

At scale you can't keep everything. Head sampling decides at the start of the request (keep 1%), which is cheap but keeps 1% of the errors too. Tail sampling buffers the whole trace in a collector and decides once it's complete, so it can keep every error and every slow request; it costs memory, and every span of a trace must reach the same collector (route spans by trace id). For metrics, the cost driver is cardinality: a metric labelled by endpoint × status × region has hundreds of series; add user_id and it has millions. Put high-cardinality detail (user id, order id) in logs and traces, not metric labels.

For logs: structured (JSON with consistent field names), every line carrying the request id / trace id so logs and traces join up, levels used consistently, no secrets, tokens or raw personal data, and different retention for debug logs (days) and audit logs (years).

AdvancedAlerting on SLOs with burn rates

Alerts should page a human only when users are being hurt badly enough to need one now. The SRE Workbook's approach ("Alerting on SLOs", checked October 2026) is to alert on the burn rate — how fast you're spending the error budget relative to plan (see Availability, SLAs, SLOs and SLIs):

burn-rate.mjs
// Error budgets and burn rates for a 99.9% availability SLO over a 30-day window.
const SLO = 0.999, WINDOW_H = 30 * 24;
const budget = 1 - SLO; // 0.1% of requests may fail
console.log(`error budget: ${(budget * 100).toFixed(1)}% of requests = ${(budget * WINDOW_H * 60).toFixed(1)} minutes of total outage in 30 days`);

// Burn rate = observed error rate / budgeted error rate. At burn rate 1 the budget lasts exactly 30 days.
for (const errorRate of [0.001, 0.006, 0.0144, 0.05, 1]) {
  const burn = errorRate / budget;
  const hoursLeft = WINDOW_H / burn;
  const shown = hoursLeft >= 48 ? `${(hoursLeft / 24).toFixed(1)} days` : hoursLeft >= 1 ? `${hoursLeft.toFixed(1)} hours` : `${(hoursLeft * 60).toFixed(0)} minutes`;
  console.log(`errors ${(errorRate * 100).toString().padStart(5)}% -> burn rate ${burn.toFixed(1).padStart(6)} -> whole budget gone in ${shown}`);
}

// Multiwindow alerts (SRE Workbook): page only if BOTH the long and the short window burn too fast.
const alerts = [
  { severity: "page", longH: 1, shortMin: 5, burn: 14.4 },
  { severity: "page", longH: 6, shortMin: 30, burn: 6 },
  { severity: "ticket", longH: 72, shortMin: 360, burn: 1 },
];
for (const a of alerts) {
  const consumed = (a.burn * a.longH) / WINDOW_H;
  console.log(`${a.severity.padEnd(6)} burn > ${String(a.burn).padEnd(4)} over ${String(a.longH).padStart(2)} h and ${String(a.shortMin).padStart(3)} min = ${(consumed * 100).toFixed(0)}% of the budget gone in that window`);
}

// Is it paging right now? Error rates measured over each window:
const observed = { "1h": 0.016, "5m": 0.019, "6h": 0.004, "30m": 0.012 };
const firing = alerts.slice(0, 2).filter((a) => {
  const long = observed[`${a.longH}h`] / budget, short = observed[`${a.shortMin}m`] / budget;
  return long > a.burn && short > a.burn;
});
console.log(`observed burn: 1h ${(observed["1h"] / budget).toFixed(0)}, 5m ${(observed["5m"] / budget).toFixed(0)}, 6h ${(observed["6h"] / budget).toFixed(0)}, 30m ${(observed["30m"] / budget).toFixed(0)} -> ${firing.length ? `PAGE (${firing.map((a) => a.longH + "h").join(", ")} rule)` : "quiet"}`);
Result
error budget: 0.1% of requests = 43.2 minutes of total outage in 30 days
errors   0.1% -> burn rate    1.0 -> whole budget gone in 30.0 days
errors   0.6% -> burn rate    6.0 -> whole budget gone in 5.0 days
errors  1.44% -> burn rate   14.4 -> whole budget gone in 2.1 days
errors     5% -> burn rate   50.0 -> whole budget gone in 14.4 hours
errors   100% -> burn rate 1000.0 -> whole budget gone in 43 minutes
page   burn > 14.4 over  1 h and   5 min = 2% of the budget gone in that window
page   burn > 6    over  6 h and  30 min = 5% of the budget gone in that window
ticket burn > 1    over 72 h and 360 min = 10% of the budget gone in that window
observed burn: 1h 16, 5m 19, 6h 4, 30m 12 -> PAGE (1h rule)
What’s happening
  1. A 99.9% SLO allows 0.1% of requests to fail. Over 30 days (43,200 minutes) that's the equivalent of 43.2 minutes of total outage — the error budget.
  2. Burn rate = observed error rate ÷ budgeted error rate. At 0.1% errors the burn rate is 1 and the budget lasts exactly 30 days. At 1.44% it's 14.4 and the budget is gone in 2.1 days; a total outage (burn rate 1,000) uses it all in 43 minutes.
  3. The Workbook's recommended alerts for a 99.9% SLO: page if the burn rate exceeds 14.4 over 1 hour (2% of the monthly budget gone in that hour) or 6 over 6 hours (5%), and open a ticket at 1 over 3 days (10%). The model recomputes the percentages: 14.4 × 1 h ÷ 720 h = 2%.
  4. Each alert also has a short window (1/12 of the long one: 5 minutes for 1 hour). Both must be over the threshold. The long window stops one bad minute from paging anyone; the short window makes the alert stop firing soon after the problem is fixed, instead of an hour later.
  5. The last line evaluates the rules on observed error rates: the 1-hour burn rate is 16 and the 5-minute is 19, both above 14.4, so it pages on the 1-hour rule; the 6-hour rule (burn 4) stays quiet.
How fast does this burn?

Your SLO is 99.95% availability over 30 days. For one hour, 2% of requests fail. What's the burn rate, and how much of the month's error budget did that hour use?

Show answer

Burn rate 40; the hour used about 5.6% of the monthly budget.

What’s happening
  1. The budgeted error rate is 1 − 0.9995 = 0.05% = 0.0005.
  2. Burn rate = 0.02 / 0.0005 = 40: the budget is being spent 40 times faster than plan.
  3. An hour at burn rate 40 spends as much budget as 40 hours at burn rate 1. The window is 30 × 24 = 720 hours, so that hour used 40 / 720 ≈ 5.6% of the budget.
  4. That's above the 14.4-in-1-hour page threshold by far, so the fast-burn alert would have fired within minutes of the short window catching up.

#Deployments: blue-green, canary and feature flags

Added

Most outages are self-inflicted. Google's SRE book (checked October 2026) puts it at roughly 70% of outages caused by changes to a live system — a deploy, a config push, a schema migration, a flag flip. So a design review asks not just "how does it run?" but "how does a change reach production, and how fast can you undo it?" The goal is to make every change small, gradual, observable and reversible, so a bad one hurts few users for a short time.

The mental model is a theatre's understudy and a test audience. Blue-green keeps a complete second cast ready backstage: when the new show is ready you swap casts in one go, and if it goes badly you swap back. A canary is a preview screening: show the new version to a small audience first, compare their reactions with the regular audience, and widen the release only if they look the same. Feature flags separate opening night from the rehearsal: the code is on stage (deployed) but the scene is cut (released) until you decide to turn it on.

StrategyHow traffic movesRollbackCost and caveats
RollingInstances replaced a few at a time; old and new serve togetherRoll forward or re-deploy the old version (minutes)Cheap; both versions live at once, so APIs and schemas must be compatible
Blue-greenA full copy (green) runs beside production (blue); the router flips 100% at onceFlip back (seconds), as long as blue is keptDouble capacity during the switch; a bad version hits everyone at the flip; database is usually shared
CanaryA small share (1% → 5% → 25% → 100%) goes to the new version; each step is checked against a baselineSend the canary's share back to 0% (seconds)Needs good metrics and automated analysis; small canaries need time to gather evidence
Feature flagCode is deployed dark; a flag turns it on for chosen users or a percentageTurn the flag off (seconds), no deployFlags are code paths to test and clean up; flag service becomes a dependency
Blue-green: all traffic moves at once 100% 0% green deployed, smoke-tested at 0% router flips to green blue kept running, idle rollback = flip back (seconds) time → Canary: traffic moves in checked steps 100% 0% 1% 5% 25% 50% 100% ✓ ✓ ✓ ✓ ✓ = canary vs baseline + bake time any failed check → back to 0% time →
Blue-green swaps everything at once with an instant way back; a canary exposes a few users first and grows only while the numbers look the same.

The heart of a canary is the comparison. "The canary's error rate is 0.7% and the fleet's is 0.5%" is not yet evidence: with a few thousand requests that gap can be pure noise. Automated canary analysis compares the canary against a baseline — the old version, deployed fresh at the same time and given the same share of traffic, so both run under identical conditions (no warm caches or long uptime on one side) — and applies a statistical test. Spinnaker's Kayenta, for example, uses a Mann-Whitney U test per metric (Spinnaker docs, checked October 2026). The model uses the simplest version of the idea, a two-proportion z-test on error rates, checked every minute:

canary.mjs
// Canary analysis: is the canary's error rate really worse than the baseline's, or is it noise?
function mulberry32(seed) {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const FLEET_RPM = 20_000; // requests per minute across the whole service
const BASELINE_ERR = 0.005; // 0.5% of requests fail today
const Z_FAIL = 3; // fail the canary only if it is worse by more than 3 standard errors

// Two-proportion z-test: how many standard errors apart are the two error rates?
function zScore(errC, nC, errB, nB) {
  const pooled = (errC + errB) / (nC + nB);
  const se = Math.sqrt(pooled * (1 - pooled) * (1 / nC + 1 / nB));
  return se === 0 ? 0 : (errC / nC - errB / nB) / se;
}

// One canary run: check after every minute; return the minute it failed, or null if it passed.
function runCanary({ share, canaryErr, seed, minutes = 60 }) {
  const rand = mulberry32(seed);
  const perMin = Math.round(FLEET_RPM * share); // canary and a same-size baseline each get this much
  let nC = 0, eC = 0, nB = 0, eB = 0;
  for (let m = 1; m <= minutes; m++) {
    for (let i = 0; i < perMin; i++) {
      nC++; if (rand() < canaryErr) eC++;
      nB++; if (rand() < BASELINE_ERR) eB++;
    }
    if (zScore(eC, nC, eB, nB) > Z_FAIL) return m;
  }
  return null;
}

const cases = [
  ["good build, 1% canary", { share: 0.01, canaryErr: 0.005 }],
  ["bad build (errors x2), 1% canary", { share: 0.01, canaryErr: 0.01 }],
  ["bad build (errors x2), 5% canary", { share: 0.05, canaryErr: 0.01 }],
  ["bad build (errors x1.2), 5% canary", { share: 0.05, canaryErr: 0.006 }],
];
const RUNS = 200; // repeat each experiment with 200 different seeds
for (const [label, c] of cases) {
  const failedAt = Array.from({ length: RUNS }, (_, seed) => runCanary({ ...c, seed })).filter((m) => m !== null).sort((a, b) => a - b);
  const median = failedAt.length ? `, median minute ${failedAt[Math.floor(failedAt.length / 2)]}` : "";
  console.log(`${label.padEnd(35)} failed in ${String(failedAt.length).padStart(3)}/${RUNS} runs${median}`);
}

// How many requests per side before a doubling (0.5% -> 1%) is 3 standard errors? Solve z = 3 for n.
const p1 = BASELINE_ERR, p2 = 0.01, pbar = (p1 + p2) / 2;
const n = Math.ceil((Z_FAIL ** 2 * 2 * pbar * (1 - pbar)) / (p2 - p1) ** 2);
console.log(`\nexpected requests per side to see a 0.5% -> 1% jump at z = 3: ${n.toLocaleString("en-US")}`);
console.log(`  at 1% canary (${FLEET_RPM * 0.01}/min): ~${Math.ceil(n / (FLEET_RPM * 0.01))} min;  at 5% (${FLEET_RPM * 0.05}/min): ~${Math.ceil(n / (FLEET_RPM * 0.05))} min`);
Result
good build, 1% canary               failed in   1/200 runs, median minute 20
bad build (errors x2), 1% canary    failed in 183/200 runs, median minute 24
bad build (errors x2), 5% canary    failed in 200/200 runs, median minute 6
bad build (errors x1.2), 5% canary  failed in  80/200 runs, median minute 36

expected requests per side to see a 0.5% -> 1% jump at z = 3: 5,360
  at 1% canary (200/min): ~27 min;  at 5% (1000/min): ~6 min
What’s happening
  1. The service handles 20,000 requests a minute with a 0.5% error rate. A 1% canary and a same-size baseline each get 200 requests a minute; a 5% canary gets 1,000. Each experiment is repeated with 200 different seeds, so the output is reproducible and shows how often each outcome happens, not one lucky run.
  2. zScore asks how many standard errors apart the two observed error rates are. The canary fails when it's worse by more than 3 standard errors — a strict bar, because the check runs every minute and each look is another chance for noise to cross the line.
  3. Good build at 1%: the canary fails 1 time in 200 — a 0.5% false-alarm rate. Real systems get a few of these, which is why a failed canary triggers a rollback and a human look, not a ban on the build.
  4. Bad build (errors doubled, 0.5% → 1%) at 1%: caught in 183 of 200 runs, with a median of 24 minutes. In the other 17 runs a build that doubles errors passed an hour-long canary — 200 requests a minute just isn't much evidence. At 5% the same bad build is caught every time, in a median of 6 minutes.
  5. The last lines solve the z-test for the sample size: about 5,360 requests per side are needed for a 0.5% → 1% jump to reach z = 3, which is ~27 minutes at 200/min and ~6 minutes at 1,000/min — close to the simulated medians of 24 and 6 minutes.
  6. Bad build (errors × 1.2) at 5%: caught only 80 times in 200, with a median of 36 minutes. Small regressions need far more traffic to detect (the required sample grows with 1 / difference²). That's the core trade-off: a smaller canary exposes fewer users but takes longer to notice anything, and subtle regressions slip through any short canary.
AdvancedFeature flags and percentage rollouts

A feature flag is a runtime switch around a code path: if (flags.isOn("new-checkout", user)) …. Flags decouple deploy from release: code ships dark, turned off, and is released later — to employees, then 1% of users, then everyone — without a deploy. Turning a flag off is the fastest rollback there is. The trick for percentage rollouts is choosing users deterministically, so the same user gets the same answer on every request and every server without storing anything:

flag-rollout.mjs
// Percentage rollouts: hash (flag, user) into one of 10,000 buckets; a user is "in" if bucket < percent × 100.
import { createHash } from "node:crypto";

const bucket = (flag, userId) => createHash("sha256").update(`${flag}:${userId}`).digest().readUInt32BE(0) % 10_000;
const isOn = (flag, userId, percent) => bucket(flag, userId) < percent * 100;

const users = Array.from({ length: 100_000 }, (_, i) => `user-${i}`);
const inAt = (flag, percent) => new Set(users.filter((u) => isOn(flag, u, percent)));

// 1. The share switched on is close to the target, with no state stored anywhere.
for (const p of [1, 5, 20, 50]) console.log(`new-checkout at ${String(p).padStart(2)}%: ${inAt("new-checkout", p).size.toLocaleString("en-US").padStart(6)} of 100,000 users`);

// 2. Sticky and monotonic: everyone in at 5% is still in at 20%, so nobody flips back and forth.
const at5 = inAt("new-checkout", 5), at20 = inAt("new-checkout", 20);
console.log(`in at 5% and still in at 20%: ${[...at5].filter((u) => at20.has(u)).length} of ${at5.size}`);
const b = bucket("new-checkout", "user-42");
console.log(`user-42 is always bucket ${b}: off until the rollout passes ${b / 100}%, on from then on`);

// 3. The flag name is part of the hash, so different flags pick different users.
const other = inAt("dark-mode", 5);
const overlap = [...at5].filter((u) => other.has(u)).length;
console.log(`users in BOTH new-checkout@5% and dark-mode@5%: ${overlap} (independent flags would give ~${Math.round(100_000 * 0.05 * 0.05)})`);
Result
new-checkout at  1%:  1,007 of 100,000 users
new-checkout at  5%:  5,001 of 100,000 users
new-checkout at 20%: 19,908 of 100,000 users
new-checkout at 50%: 49,928 of 100,000 users
in at 5% and still in at 20%: 5001 of 5001
user-42 is always bucket 6800: off until the rollout passes 68%, on from then on
users in BOTH new-checkout@5% and dark-mode@5%: 237 (independent flags would give ~250)
What’s happening
  1. bucket hashes "flag:user" with SHA-256, reads the first 4 bytes as a number and takes it modulo 10,000. A user is on when their bucket is below percent × 100, so a 5% rollout means buckets 0–499. No database lookup, no per-user state: any server computes the same answer.
  2. The share switched on tracks the target closely: 1,007 users at 1%, 5,001 at 5%, 19,908 at 20%, 49,928 at 50% out of 100,000 — the hash spreads users evenly.
  3. Monotonic ramp: all 5,001 users in at 5% are still in at 20%, because raising the percentage only adds buckets. Nobody sees the new checkout on Monday and loses it on Tuesday. User-42 is permanently bucket 6800, so they get the feature once the rollout passes 68%.
  4. Independent flags: the flag name is part of the hash, so dark-mode at 5% picks a different 5% of users. Only 237 users are in both 5% groups, close to the ~250 you'd expect by chance. Without the flag name in the hash, the same unlucky 5% would be the guinea pigs for every experiment.
  5. Real flag services (LaunchDarkly, Unleash, OpenFeature-compatible SDKs) use the same idea plus targeting rules (employees, a country, an account tier). The design points: evaluate flags locally from a cached rule set so the flag service isn't on the request path (static stability again), log which variant served each request so metrics can be split by flag, and delete flags once a rollout is complete.
AdvancedChanges that aren't code
  • Config is code. A config push that reaches every server at once is the classic global outage. Give config the same pipeline as code: review, validation, staged rollout (one cell or region first), and automatic rollback.
  • Database migrations can't be flipped back. Use expand and contract: add the new column or table (expand), deploy code that writes both and reads the new one, backfill, then remove the old column (contract) in a later release. Every step is compatible with the code before and after it, so any deploy can be rolled back.
  • Bake time. Many problems take time to appear (memory leaks, daily batch jobs, cache expiry). Wait between stages, and stage by region or cell so a problem is confined to one before it reaches the next.
  • Roll back first, debug second. When a deploy correlates with an incident, revert before investigating. Pipelines that can only roll forward turn a five-minute incident into an hour-long one.

#Disaster recovery: RPO, RTO and backups

Added

Disaster recovery (DR) is the plan for the failures that redundancy doesn't cover: a whole region lost, the primary database corrupted, a bad migration, an engineer's DELETE, ransomware. It's defined by two numbers that the business, not the engineers, should choose:

  • RPO (recovery point objective): how much data you can afford to lose, measured in time. "RPO 5 minutes" means after a disaster you can get back to the state of at most 5 minutes before it.
  • RTO (recovery time objective): how long you can afford to be down. "RTO 1 hour" means service is back within an hour of the disaster.

The mental model is writing a long document. RPO is how often you press Save — if the laptop dies, you lose everything since the last save. RTO is how long it takes to get working again on a borrowed laptop: find the file, open it, get back to where you were. Saving more often (lower RPO) costs a little all the time; keeping a second laptop open and synced (lower RTO) costs a lot all the time.

backup backup last backup ✕ disaster service back data lost ≤ RPO downtime ≤ RTO RPO is set by how often data is copied; RTO by how fast you can restore and switch over.
RPO looks backwards from the disaster (lost writes); RTO looks forwards (time to recover).

AWS's whitepaper Disaster Recovery of Workloads on AWS (checked October 2026) lays out four strategies on one cost-versus-recovery scale, and they apply to any cloud:

StrategyWhat runs in the recovery regionRPO / RTO (AWS's figure)Cost
Backup and restoreNothing; backups are copied there, infrastructure is rebuilt from code after the eventHours$
Pilot lightData is replicated live; app servers are defined but switched off10s of minutes$$
Warm standbyA scaled-down but fully working copy, scaled up on failoverMinutes$$$
Multi-site active/activeFull capacity in each region, all serving trafficReal-time (near-zero data loss)$$$$

The model turns a backup schedule into RPO and RTO numbers for a 2 TB database. The throughput figures are assumptions for illustration — the whole point of a restore drill is to replace them with measured ones:

rpo-rto.mjs
// RPO and RTO for a 2 TB database under four backup schedules. Every rate below is an assumption to
// replace with numbers from your own restore drill.
const DB_GB = 2_000;
const CHANGES_GB_PER_HOUR = 1; // incremental backup / WAL volume written per hour
const RESTORE_MB_S = 400; // copying a full backup back onto fresh disks
const REPLAY_MB_S = 50; // re-applying incrementals or WAL is slower than copying
const FIXED_MIN = 15 + 10; // provision the new instance + verify and switch traffic over

const min = (gb, mbPerS) => (gb * 1000) / mbPerS / 60;
const schemes = [
  { name: "nightly full snapshot", rpoMin: 24 * 60, replayGB: 0 },
  { name: "nightly full + hourly incremental", rpoMin: 60, replayGB: 23 * CHANGES_GB_PER_HOUR },
  { name: "nightly full + WAL shipped every 5 min", rpoMin: 5, replayGB: 24 * CHANGES_GB_PER_HOUR },
  { name: "hot standby replica (+ WAL archive)", rpoMin: 0, replayGB: 0, failoverMin: 2 },
];

console.log("scheme                                   worst RPO   RTO (restore at the worst moment)");
for (const s of schemes) {
  // Worst moment: just before the next full backup, so the most incrementals/WAL must be replayed.
  const rto = s.failoverMin ?? FIXED_MIN + min(DB_GB, RESTORE_MB_S) + min(s.replayGB, REPLAY_MB_S);
  const rpo = s.rpoMin >= 60 ? `${s.rpoMin / 60} h` : `${s.rpoMin} min`;
  console.log(`${s.name.padEnd(40)} ${rpo.padStart(9)}   ${Math.round(rto)} min`);
}

// Logical damage (a bad migration deletes rows at 14:00, noticed at 16:00) replicates to the standby too.
const badAt = 14 * 60, noticedAt = 16 * 60;
const pitrRto = FIXED_MIN + min(DB_GB, RESTORE_MB_S) + min(14 * CHANGES_GB_PER_HOUR, REPLAY_MB_S);
console.log(`\nbad DELETE at 14:00, noticed at 16:00: the replica has the same damage.`);
console.log(`point-in-time restore to 13:59 takes ~${Math.round(pitrRto)} min; the ${(noticedAt - badAt) / 60} h of good writes since 14:00 must be re-applied by hand or lost`);
Result
scheme                                   worst RPO   RTO (restore at the worst moment)
nightly full snapshot                         24 h   108 min
nightly full + hourly incremental              1 h   116 min
nightly full + WAL shipped every 5 min       5 min   116 min
hot standby replica (+ WAL archive)          0 min   2 min

bad DELETE at 14:00, noticed at 16:00: the replica has the same damage.
point-in-time restore to 13:59 takes ~113 min; the 2 h of good writes since 14:00 must be re-applied by hand or lost
What’s happening
  1. Nightly full snapshot: the worst moment to fail is just before the next snapshot, so up to 24 hours of writes are lost. Recovery copies 2 TB at the assumed 400 MB/s — 2,000,000 MB / 400 / 60 ≈ 83 minutes — plus 25 minutes to provision and switch over: 108 minutes.
  2. Nightly full + hourly incrementals: RPO drops to 1 hour. The RTO goes up slightly, to 116 minutes, because at the worst moment 23 incrementals (23 GB at the slower 50 MB/s replay rate, ~8 minutes) must be applied on top of the full restore. Better RPO, not better RTO.
  3. Nightly full + WAL shipped every 5 minutes: shipping the database's write-ahead log (or MySQL binlog) to object storage continuously gives point-in-time recovery and an RPO of 5 minutes (the shipping interval). RTO is still ~116 minutes — it's dominated by copying 2 TB, not by the schedule. To improve RTO you need a different strategy, not more frequent backups.
  4. Hot standby replica: a replica that's always in sync can be promoted in about 2 minutes (an assumption; AWS's DR whitepaper says promoting an RDS read replica takes a few minutes, and an Aurora global database secondary can take over in under a minute). With synchronous replication RPO is 0; with asynchronous replication it's the replication lag, usually seconds. This is the pilot-light / warm-standby end of the table, and it costs a second database running all the time.
  5. The last scenario is why replicas don't replace backups: a bad migration deletes rows at 14:00 and someone notices at 16:00. The replica faithfully applied the delete. The only way back is a point-in-time restore to 13:59 (~113 minutes here), and the 2 hours of legitimate writes after 14:00 must be replayed from logs or lost. For logical damage, the effective RPO is the time it takes to notice.
AdvancedBackups that actually restore
  • Replication is not backup. Replicas copy mistakes and malicious deletes instantly. You need point-in-time copies the live system can't modify.
  • The 3-2-1 rule of thumb: three copies of the data, on two different kinds of storage, one of them off-site. The ransomware-era extension, 3-2-1-1-0, adds one copy that's offline or immutable (object lock, write-once storage) and zero errors in restore tests.
  • Separate the blast radius of credentials. If the same admin credentials can delete production and the backups, one compromised account destroys both. Keep backups in a separate account with separate access; AWS's DR whitepaper recommends cross-account backup copies for exactly this.
  • A backup you haven't restored is a hope. Schedule automatic restores into a scratch environment, verify the data (row counts, checksums, a smoke test), and record how long it took — that measured time is your RTO.
  • Back up more than data. Infrastructure as code, configuration, secrets (in their own secure store), machine images, and the runbook itself, stored somewhere that doesn't depend on the region that just failed.
  • Practise failover. Game days that fail over a region on purpose find the DNS TTL nobody lowered, the quota that's too small in the recovery region and the hard-coded endpoint. AWS's guidance is to fail over with data-plane operations (health-check-driven DNS, routing switches), not control-plane ones that may be impaired during the event.
Which schedule meets the requirement?

The business asks for RPO 15 minutes and RTO 30 minutes for a 4 TB database. Your restore drill measured 500 MB/s for a full restore. Does "nightly snapshot + WAL shipping every 5 minutes" meet it?

Show answer

It meets the RPO but not the RTO: the restore alone takes over two hours.

What’s happening
  1. WAL shipped every 5 minutes means at most 5 minutes of writes are lost: RPO 5 min ≤ 15 min. ✓
  2. A full restore copies 4 TB = 4,000,000 MB at 500 MB/s = 8,000 s ≈ 133 minutes, before any log replay or switch-over.
  3. 133 minutes is far beyond the 30-minute RTO. More frequent backups can't fix that, because RTO here is dominated by data volume ÷ restore speed.
  4. To meet 30 minutes you need the data already restored somewhere: a standby replica you can promote (warm standby), or storage-level snapshots that restore lazily. Keep the WAL archive as well, for point-in-time recovery from logical damage.

#Security in a design review

Added

Security in a system design interview isn't about naming a cipher. It's about showing that you know where the trust boundaries are — the points where data or requests cross from something you don't control to something you do — and that every boundary has a check. An interviewer is listening for whether security is part of the design or an afterthought bolted on at the end.

The mental model is an office building. The front door checks who you are (authentication: your badge). Each room checks whether you may enter this room (authorisation: your badge opens the server room only if you're on the list). Corridors are watched even inside (internal traffic is encrypted and authenticated — zero trust), filing cabinets are locked (encryption at rest) and the keys are held by security, not taped to the drawer (a key management service). Staff get only the keys their job needs (least privilege), and every door logs who opened it (audit). Security guards at the entrance turn away a mob before it reaches reception (DDoS protection at the edge).

private network · zero trust: every call still authenticated Internet users bots, attackers Edge CDN · anycast DDoS absorption WAF rules TLS 1.3 ends here API gateway authN: verify token rate limits size limits schema checks Services authZ per object mTLS between least-privilege role validate input Data encrypted at rest keys in KMS backups locked PII minimised Secrets manager short-lived, rotated Audit log who did what every arrow is a trust boundary
Each layer checks something different; none of them trusts the layer before it to have checked everything.

The checklist to walk through in a review, layer by layer:

ConcernWhat to say in the design
Authentication (who are you?)Users sign in through an identity provider (OIDC); the gateway verifies short-lived tokens (signature, expiry, audience); services get the verified identity, not the raw password or a token they must re-parse
Authorisation (may you do this?)Every service checks the caller against the object: owner, tenant, role. Deny by default. One policy function or service, not checks scattered through handlers
In transitTLS 1.3 at the edge; mTLS (or a service mesh) between services, so the internal network isn't trusted just for being internal
At restDisks, databases, backups and object storage encrypted; keys in a KMS; envelope encryption for sensitive fields
SecretsIn a secrets manager, fetched at runtime with the service's identity, short-lived and rotated — never in the repo, the image, or a log
Least privilegeOne identity per service with only the permissions it needs (this service may read this bucket); humans get access just in time, logged
Input and abuseValidate at the boundary (schema, sizes); parameterised queries; rate limits per user and per IP; protect expensive endpoints
DDoSAbsorb at the edge (CDN, anycast), filter (WAF, bot rules), rate-limit, and make the origin unreachable except through the edge
Data minimisation and auditDon't store what you don't need; mask personal data in logs; audit-log every privileged action to an append-only store

Authentication is not authorisation, and confusing them is the most common serious API flaw. OWASP's API Security Top 10 (2023 edition, checked October 2026) puts Broken Object Level Authorization (BOLA) at number one: an endpoint that checks the token is valid, then returns whatever object id the caller asked for. The model contrasts an authentication-only handler with a policy function that checks the object:

authz.mjs
// Authentication says WHO is calling. Authorization decides whether THIS caller may do THIS to THIS object.
const orders = {
  1001: { owner: "alice", tenant: "acme", total: 120 },
  1002: { owner: "bob", tenant: "acme", total: 75 },
  2001: { owner: "carol", tenant: "globex", total: 300 },
};

// Version 1: the token is valid, so the handler returns whatever id was asked for.
const authnOnly = (principal, action, order) => principal !== null;

// Version 2: one policy function every handler calls, checked against the object itself.
function can(p, action, order) {
  if (!p) return false;
  if (p.tenant !== order.tenant) return false; // never cross tenants, whatever the role
  if (action === "read") return order.owner === p.sub || p.roles.includes("support");
  if (action === "refund") return p.roles.includes("refunds") && order.total <= p.refundLimit;
  return false; // deny by default: unknown actions are refused
}

const alice = { sub: "alice", tenant: "acme", roles: [] };
const agent = { sub: "sam", tenant: "acme", roles: ["support", "refunds"], refundLimit: 100 };
const requests = [
  ["alice", alice, "read", 1001],
  ["alice", alice, "read", 1002], // changes the id in the URL: BOLA
  ["support agent", agent, "read", 1002],
  ["support agent", agent, "refund", 1002],
  ["support agent", agent, "refund", 2001], // other tenant
  ["support agent", agent, "delete", 1001],
  ["no token", null, "read", 1001],
];
console.log("caller          action  order  authN only  with policy");
for (const [who, p, action, id] of requests) {
  const show = (ok) => (ok ? "allow" : "DENY").padEnd(11);
  console.log(`${who.padEnd(15)} ${action.padEnd(7)} ${String(id).padEnd(6)} ${show(authnOnly(p, action, orders[id]))} ${show(can(p, action, orders[id]))}`.trimEnd());
}
Result
caller          action  order  authN only  with policy
alice           read    1001   allow       allow
alice           read    1002   allow       DENY
support agent   read    1002   allow       allow
support agent   refund  1002   allow       allow
support agent   refund  2001   allow       DENY
support agent   delete  1001   allow       DENY
no token        read    1001   DENY        DENY
What’s happening
  1. authnOnly is what many handlers effectively do: if there's a valid token, proceed. It allows everything except the request with no token at all.
  2. Row 2 is BOLA: Alice is properly signed in and changes 1001 to 1002 in the URL. Authentication-only returns Bob's order. The policy says read requires order.owner === p.sub or the support role, so it's DENY. Random, non-sequential ids make guessing harder, but they are not a substitute for this check.
  3. The support agent may read Bob's order (role support) and refund it, because the agent has the refunds role and the order total (75) is within the agent's refundLimit of 100 — authorisation can depend on attributes of the object, not just roles (attribute-based access control).
  4. The refund on order 2001 is denied before any role is considered: it belongs to tenant globex, and the tenant check comes first. In a multi-tenant system that single line is the wall between customers, so it lives in the policy, not in each handler.
  5. delete isn't a known action, so it falls through to return false. Deny by default means a new endpoint that forgot to add a rule fails closed, not open. In production this function becomes a policy library or service (OPA, Cedar, an in-house policy service) that every service calls with the same inputs: principal, action, resource.
AdvancedEncryption at rest with envelope encryption

"Encrypted at rest" usually means the disk or database is encrypted with a key held by a key management service (KMS). For sensitive fields (card data, health records, government ids) you often encrypt in the application too, with envelope encryption: each record gets its own random data key (DEK) which encrypts the data; the DEK is itself encrypted by a key-encryption key (KEK) that never leaves the KMS, and the encrypted DEK is stored next to the data.

envelope.mjs
// Envelope encryption: a data key (DEK) per record encrypts the data; a key-encryption key (KEK), which
// never leaves the KMS, encrypts the DEK. The KEK is simulated here with a local key.
import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";

function seal(key, plaintext) { // AES-256-GCM: confidentiality + an integrity tag
  const iv = randomBytes(12);
  const c = createCipheriv("aes-256-gcm", key, iv);
  const data = Buffer.concat([c.update(plaintext), c.final()]);
  return { iv, data, tag: c.getAuthTag() };
}
function open(key, { iv, data, tag }) {
  const d = createDecipheriv("aes-256-gcm", key, iv);
  d.setAuthTag(tag);
  return Buffer.concat([d.update(data), d.final()]);
}

const kms = { kek: randomBytes(32), wrap(dek) { return seal(this.kek, dek); }, unwrap(w) { return open(this.kek, w); } };

function encryptRecord(text) {
  const dek = randomBytes(32); // fresh data key, used once, then forgotten
  return { body: seal(dek, Buffer.from(text)), wrappedDek: kms.wrap(dek) }; // store both, side by side
}
const decryptRecord = (r) => open(kms.unwrap(r.wrappedDek), r.body).toString();

const record = encryptRecord("card ending 4242, Jane Doe, 1 Main St");
console.log("decrypts:", decryptRecord(record));
console.log("stored ciphertext bytes:", record.body.data.length, "| wrapped key bytes:", record.wrappedDek.data.length);

// Tampering: flip one bit of the stored ciphertext. GCM's tag check refuses to decrypt.
record.body.data[0] ^= 1;
try { decryptRecord(record); } catch (err) { console.log("tampered record:", err.message); }
record.body.data[0] ^= 1;

// KEK rotation: unwrap each DEK with the old KEK and wrap it with the new one. The data is not touched.
const oldKek = kms.kek;
const dek = open(oldKek, record.wrappedDek);
kms.kek = randomBytes(32);
record.wrappedDek = kms.wrap(dek);
console.log("after KEK rotation:", decryptRecord(record), `(re-encrypted ${record.wrappedDek.data.length} bytes, not the record)`);

// Crypto-shredding: delete the record's DEK and nothing can read it any more, in backups too.
// Simulate the deleted key by trying any other key: GCM refuses it.
try { open(randomBytes(32), record.body); } catch (err) { console.log("without its DEK:", err.message); }
Result
decrypts: card ending 4242, Jane Doe, 1 Main St
stored ciphertext bytes: 37 | wrapped key bytes: 32
tampered record: Unsupported state or unable to authenticate data
after KEK rotation: card ending 4242, Jane Doe, 1 Main St (re-encrypted 32 bytes, not the record)
without its DEK: Unsupported state or unable to authenticate data
What’s happening
  1. encryptRecord creates a fresh 32-byte DEK, encrypts the text with AES-256-GCM, and asks the "KMS" to wrap the DEK with the KEK. Both results are stored together; the plaintext DEK is thrown away. Decryption asks the KMS to unwrap the DEK (an API call that is authorised and audit-logged), then decrypts locally.
  2. The record's 37 bytes of text become 37 bytes of ciphertext (GCM doesn't pad) plus a 12-byte IV and a 16-byte authentication tag; the wrapped DEK is 32 bytes plus its own IV and tag. Bulk data never travels to the KMS — only small keys do, which is why envelope encryption scales.
  3. Flipping one bit of the stored ciphertext makes decryption throw Unsupported state or unable to authenticate data. GCM is authenticated encryption: it detects tampering instead of returning silently corrupted plaintext.
  4. Rotating the KEK only re-wraps the 32-byte DEKs. The records themselves are not re-encrypted, so rotating the master key over a billion records means a billion tiny re-wraps — or, with a KMS that keeps old key versions, no immediate work at all.
  5. Crypto-shredding: delete a record's DEK and the ciphertext is unreadable everywhere, including in every backup — the model shows any other key being refused. That's a practical way to honour "delete my data" requests when the data is spread across immutable backups.
AdvancedSecrets, identity and least privilege

Every service should run with its own identity (a cloud IAM role, a Kubernetes service account, a SPIFFE id) and get only the permissions its job needs: the orders service may write to the orders table and publish to one topic; it may not read the payments database. If it's compromised, the attacker gets what the service had, no more — least privilege is blast-radius thinking applied to security. Secrets (database passwords, API keys) live in a secrets manager, are fetched at start-up with that identity, are short-lived where possible (dynamically issued database credentials that expire in hours), and are rotated automatically. Humans get production access just in time, for a stated reason, and every privileged action goes to an audit log they can't edit.

AdvancedDDoS and abuse

Volumetric attacks are now far bigger than any origin can absorb: Cloudflare's Q4 2025 DDoS report described a record 31.4 Tbps attack from the Aisuru (Kimwolf) botnet of compromised devices, and its H1 2026 report counted over 800 network-layer attacks above 1 Tbps in Q2 2026 alone (both as reported in news coverage of Cloudflare's reports, checked October 2026). The design answer is layered: absorb at a CDN or anycast edge with far more capacity than any attack; filter with WAF rules and bot detection; rate-limit per IP, per user and per API key (see Rate limiting algorithms); keep the origin reachable only through the edge; cap autoscaling so an attack can't turn into an unbounded bill; and protect expensive endpoints (search, login, password reset) with stricter limits, because application-layer attacks aim at the most expensive request you serve, not the biggest pipe.

Written fresh for interviews (there was no Google Doc for this one). Runnable models were run on Node 26; figures and limits were checked against sources in October 2026.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.