28 min read

Chapter 3 — Isolation and Blast Radius

Bulkheads, cells and shuffle sharding: making one failure reach only the users it has to

Prerequisites: Chapter 1 — Timeouts and Deadlines and Chapter 2 — Retries and Idempotency. Deep chapters this one summarises: SD QA Q30 — Circuit breaker, JS Chapter 7, Part 8 — building a breaker, Debugging Chapter 13, Case 7 — one slow dependency took everything down, Q16 — Connection pooling. Node internals: JS Chapter 8 (event loop, libuv threadpool, workers).


How to read this chapter

Every design shares things: a connection pool, an event loop, a queue's workers, a database, a region. Failure spreads through what is shared. When one dependency slows down, one tenant floods you, or one deploy is bad, the question the interviewer is really asking is: who else does this reach? That set of affected users and requests is the blast radius, and isolation is everything you do to make it smaller.

The chapter goes from small to large: one process (bulkheads), then one fleet (tenants, cells, shuffle sharding), then zones and regions. Circuit breakers are covered deeply elsewhere, so Part 2 is a summary. Bulkheads and blast radius are thin elsewhere in the reader, so they get most of the length. The Node bulkhead demo and the Postgres fairness queries in this chapter were run on Node 22 and Postgres 18; the numbers quoted are from those runs.

TermMeaning
Blast radiusThe users, requests or data affected by one failure. Measure it as share of users × duration × severity
Cascading failureOne part's failure increases waiting or load on others until they fail too
BulkheadA fixed share of a resource reserved for one dependency, one class of work or one tenant, so it cannot drain the rest. Named after a ship's watertight compartments
Fail fastReject at once when a resource is full, instead of waiting for it
Circuit breakerStops calling a dependency that keeps failing, then tests it with a few trial calls before trusting it again
Noisy neighbourA tenant or workload that uses so much of a shared resource that others on it slow down
QuotaA per-tenant cap on rate, concurrency, storage or cost
Fair shareWhen a resource is contended, each tenant gets an equal (or weighted) part; when it is idle, anyone may use it
CellA complete, independent copy of the stack serving a fixed subset of tenants
Cell routerThe thin layer that maps a tenant to its cell
Shuffle shardingGiving each tenant a random small set of nodes out of a large fleet, so two tenants rarely share the same set
Static stabilityThe system keeps working through a failure without having to change anything (no scaling up, no config push)
Little's LawRequests in flight = arrival rate × time each one takes (L = λW)

Part 1: Failure spreads through shared, fixed-size things

Start with Little's Law, because it turns "slow" into "full".

/home receives 50 requests per second and calls the recommendations service. At its normal 80 ms, that is 50 × 0.08 = 4 calls in flight. Recommendations slows to 5 s. Now it is 50 × 5 = 250 calls in flight. Nothing failed, and you need 60 times the slots. Any fixed-size thing those calls hold (a pool, sockets, memory, a worker) fills up, and then every other user of that thing waits too. That is the whole mechanism of a cascading failure: latency becomes concurrency, and concurrency fills shared resources.

So the first step in any isolation answer is to list what is shared. In a typical Node service:

Shared, fixed-size thingWhat fills itWho else is stuck when it is full
A connection pool (pg.Pool, max defaults to 10)Slow queries, or a connection held across a network callEvery route that touches the database, including /health if it queries
Outbound sockets / an HTTP agentA slow downstreamEvery call that goes through the same agent or concurrency limit
The event loop (one per process)CPU work: JSON of a 20 MB body, a regex, sync crypto, image resizingEvery request in that process
The libuv threadpool (4 threads by default)fs, dns.lookup, async crypto, zlibAll file reads, all new outbound connections that resolve a hostname (JS Ch 8, Part 4)
Process memoryUnbounded queues, big responses buffered in memoryThe whole process, when it is OOM-killed
A queue's workersOne tenant's 100,000 jobs, a poison messageEvery tenant whose jobs sit behind them
A provider rate limit (LLM, email, payments)Background jobs using the same API key as the user pathThe user path, which now gets 429s
The database itselfA reporting query, a backfill, one tenant's huge accountEveryone
A zone or regionPower, network, a bad config pushEveryone pinned there

Each row is a place where a bulkhead, a quota or a cell can go. An isolation design names the shared resource and says how much of it each party may hold.


Part 2: Circuit breakers (summary)

The full treatment is in Q30 and JS Chapter 7, Part 8, which builds one. What you must be able to say in the room:

  • Three states. Closed: calls pass, failures are counted over a window. Open: calls are rejected immediately, without touching the dependency. Half-open: after a cooldown, a small number of trial calls pass; success closes it, failure reopens it.
  • Why. A slow failure holds resources for the whole timeout; a fast failure frees them at once. The breaker turns slow failures into fast ones, and gives the dependency a quiet period to recover.
  • Count timeouts as failures, and do not count 4xx from bad input as failures, or one bad client trips it for everyone.
  • Pair it with a fallback: the page without the optional strip, a cached value, the next provider.
  • Breakers are per process. Thirty pods means thirty breakers, each tripping on its own evidence. That is usually fine; say so.

Breaker vs bulkhead. A breaker reacts after it has seen enough failures, so it needs a failure signal and some seconds. A dependency that is slow but not failing (every call succeeds after 4 s) may never trip a breaker set on errors. A bulkhead caps the damage from the first second, whether the dependency is failing, slow, or merely popular. You want both: the bulkhead limits how much the dependency can take while the breaker is still deciding.


Part 3: Bulkheads in depth

What a bulkhead is

A bulkhead has three parts:

  1. A limit: at most N in flight for this dependency (or this class of work, or this tenant).
  2. A rule for "full": reject at once, or wait in a small queue with a short timeout.
  3. A fallback for the rejection: render without it, serve a cached value, return 503.

Without the second part it is not a bulkhead. A pool with an unbounded wait queue (pg.Pool's connectionTimeoutMillis defaults to 0, meaning wait forever) just moves the pile-up from the dependency to your queue.

A bulkhead in Node

js
export class BulkheadFullError extends Error {
  constructor(name, why) {
    super(`bulkhead ${name}: ${why}`);
    this.code = 'BULKHEAD_FULL';
  }
}

export class Bulkhead {
  #active = 0;
  #queue = [];
  constructor({ name, limit, maxQueue = Infinity, queueTimeoutMs = Infinity }) {
    Object.assign(this, { name, limit, maxQueue, queueTimeoutMs });
  }

  async run(fn) {
    if (this.#active >= this.limit) {
      if (this.#queue.length >= this.maxQueue) throw new BulkheadFullError(this.name, 'queue full');
      await new Promise((resolve, reject) => {
        const waiter = { resolve, timer: null };
        if (Number.isFinite(this.queueTimeoutMs)) {
          waiter.timer = setTimeout(() => {
            this.#queue.splice(this.#queue.indexOf(waiter), 1);
            reject(new BulkheadFullError(this.name, 'waited too long'));
          }, this.queueTimeoutMs);
        }
        this.#queue.push(waiter);
      });
      // the releaser handed its slot straight to us, so #active already counts it
    } else {
      this.#active++;
    }
    try {
      return await fn();
    } finally {
      const next = this.#queue.shift();
      if (next) { clearTimeout(next.timer); next.resolve(); }
      else this.#active--;
    }
  }
}

It is the semaphore from JS Chapter 7, Part 3 with a bounded, timed queue added. The release hands the slot straight to the next waiter, so a newcomer cannot slip in between, and the release is in finally, so an error cannot leak a slot. One per dependency:

js
const payments = new Bulkhead({ name: 'payments', limit: 12, maxQueue: 20, queueTimeoutMs: 500 });
const recs = new Bulkhead({ name: 'recs', limit: 8, maxQueue: 0 }); // optional: never wait

app.get('/home', async (req, res) => {
  let strip = null;
  try {
    strip = await recs.run(() => fetch(RECS_URL, { signal: AbortSignal.timeout(2000) }).then((r) => r.json()));
  } catch { /* full, slow or down: render without it */ }
  res.json({ feed: await loadFeed(req), recommendations: strip });
});

Sizing: Little's Law again

Size each bulkhead from the dependency's normal load, not from its worst case: limit ≈ peak rate × p99 latency × 2 (the factor of 2 is headroom).

  • Payments: 20 req/s × 50 ms = 1 in flight. A limit of 12 leaves room for a payment-provider slowdown up to about 600 ms before anything is rejected.
  • Recommendations: 50 req/s × 120 ms p99 = 6. Limit 8.

When recommendations goes to 5 s, it would need 250 slots. The bulkhead gives it 8. Those 8 calls wait out their 2 s timeout; the other 242 calls a second are rejected in under a millisecond and the page renders without the strip. The bulkhead decides in advance how much of your capacity a dependency may waste when it goes bad.

Measured: one slow dependency, shared pool vs bulkheads

The demo: a Node API with /checkout (calls payments, 20 ms) at 20 req/s and /home (calls recommendations, 80 ms) at 50 req/s. Five seconds in, recommendations starts taking 5 s. The recommendations call has a 2 s timeout, payments 1 s, and the client gives up after 3 s. First, every outbound call shares one limit of 20 with an unbounded wait queue (what you get from one shared pool or one shared concurrency limiter). Then each dependency gets its own bulkhead, as in the code above.

Window/checkout, shared pool of 20/checkout, bulkheads/home, bulkheads
0–5 s (healthy)98/98 ok, p99 28 ms98/98 ok, p99 29 msall with recommendations, p50 85 ms
5–10 s17/99 ok, those took 1.6 s99/99 ok, p99 27 msall served without the strip, p50 2 ms
10–15 s0/99 ok99/99 ok, p99 25 mssame
15–20 s0/98 ok99/99 ok, p99 25 mssame

Two things to say from this table:

  • Timeouts were set and it still fell over. With a 2 s timeout, recommendations needs 50 × 2 = 100 slots; the shared pool had 20. The wait queue grew by about 40 a second, and checkout requests sat behind it until the client gave up. A timeout bounds how long one call holds a slot. It does not bound how many slots a dependency takes.
  • With bulkheads, the cost of the failure was one strip on one page. Checkout's p99 did not move.

Where the bulkheads go in a Node system

LevelBulkheadNotes
Outbound HTTPOne http.Agent per dependency with maxSockets, or a concurrency limit (the class above) per dependencyTested on Node 22: maxSockets is per origin, so a slow host does not block calls to another; maxTotalSockets is a limit shared across hosts, so slow calls to one host delay calls to the next. The default agent has no socket limit. fetch uses undici internally; to bulkhead it, pass each dependency its own undici Agent with a connections limit as the dispatcher option (from the undici package)
DatabaseSeparate pg.Pools per workload: API requests, background jobs, reporting. Set connectionTimeoutMillis so waiting for a connection fails after, say, 1 sOn the Postgres side, ALTER ROLE reporting CONNECTION LIMIT 5 and ALTER ROLE reporting SET statement_timeout = '30s', and send reports to a read replica
Event loopCPU-heavy work in a worker pool (worker_threads), or a separate deploymentA blocked loop is a pool of size 1 shared by every request in the process. A bulkhead for CPU has to be another thread or another process
libuv threadpoolUV_THREADPOOL_SIZE set in the environment, plus a concurrency limit in front of heavy crypto/zlibEight concurrent pbkdf2 calls make an unrelated fs.readFile wait for both waves (JS Ch 8)
Job queuesSeparate queues with separate workers: interactive (user is waiting) vs bulk (backfills, exports). In BullMQ, a Worker per queue with its own concurrencyA 2-million-job backfill must not sit in front of a user's single job
Provider quotasA separate API key, or a reserved share of the rate limit, for the user path vs batch jobsOtherwise a nightly batch gets the 429s and so does your user
ProcessesThe same code deployed as separate services per traffic class: public API, webhook ingest, admin, workersA crash, OOM, memory leak or hot loop in one does not take the others, and each scales on its own

Bulkheads by priority, not only by dependency

A bulkhead can also reserve capacity for the work that matters most. With one pool of 20 connections:

js
const RESERVED = 4; // only critical work may use the last 4 slots
function canStart(priority, active, limit = 20) {
  return priority === 'critical' ? active < limit : active < limit - RESERVED;
}

Checkout and the health check are critical; browsing and search are not. When browsing floods the pool, checkout still finds a slot. The health check deserves its own path entirely: a shallow check (the process is up and its loop is responsive) that uses no shared pool, so a slow dependency cannot make the load balancer remove every instance at once (Debugging Ch 13, Case 7).

The sim: a limit sized to the dependency is also faster

A per-dependency limit also helps when the dependency enforces its own concurrency limit, as LLM providers do. Here a provider serves 5 requests at a time and returns 429 Retry-After: 1 above that. Predict which finishes 30 calls first, then drag Pool size K and Provider limit R.

Loading simulation…

Promise.all spends its time collecting 429s and sleeping; the pool of 5 never gets one. A bulkhead set at the dependency's real capacity wastes nothing.

Bulkhead mistakes interviewers look for

  • Holding a pooled resource across a network call. Taking a DB connection, then calling recommendations, means recommendations' latency now fills the database pool. Release it first.
  • Per-process limits multiplied by the fleet. A bulkhead of 8 per pod is 240 concurrent calls at 30 pods. If the dependency can take 100, the limit per pod is 100 ÷ pods, and it has to change when you autoscale (or live in a shared limiter).
  • Bulkheads that add up to more than the database can take. Five pools of 20 per pod at 30 pods is 3,000 connections; Postgres's default max_connections is 100. Bulkheads protect your workloads from each other; they do not protect the database from you. Chapter 4 covers that.
  • A queue with no timeout. It is a waiting room that never closes.
  • One bulkhead for everything. That is just a pool.

Part 4: Blast radius per tenant

Noisy neighbours

In a multi-tenant system, the biggest risk to most tenants is another tenant. One customer imports 2 million rows, runs a report across five years of data, or has a bug that calls you in a loop. Nothing is broken, and everyone else on that shared resource is slower.

The defence is a quota on every shared resource the tenant can fill, not only on request rate:

QuotaWhat it capsExample
Request rateRequests per second per tenant (token bucket, Chapter 4)100 req/s, burst 200
ConcurrencyRequests or jobs in flight per tenant10 concurrent API calls, 5 running jobs
CostUnits of work, not count: rows scanned, LLM tokens, credits per minute200k tokens/min per tenant
SizePayload, page size, export size10 MB uploads, 1,000 rows per page
StorageData the tenant can holdPlan limits

Concurrency quotas matter more than rate quotas when request costs differ. Ten requests a second is nothing if each takes 20 ms, and is 300 connections if each takes 30 s.

The hot tenant on a shared shard

When data is sharded by tenant (or by key), one very hot tenant lands on one shard, and everyone else on that shard shares its fate. In this sim one key draws 30% of all traffic. Predict, press Play, then add the two shards when the button unlocks. After the reveal, try Cache post:42.

Loading simulation…

Adding shards does not move the hot key: hashing spreads keys, and this is one key. The fix has to target that one tenant: cache it, give it its own shard, or move it to its own cell. Your biggest tenant sets the size of your shards and cells, so know who it is.

Fair share in a Postgres job queue

A job queue claimed with FOR UPDATE SKIP LOCKED and ORDER BY created_at is first-in-first-out across all tenants. If tenant A enqueues 2 million jobs, tenant B's single job waits behind them. The fix is to choose the next job by tenant, not only by age.

The test: six workers, 100 ms jobs. Tenant A enqueued 40 jobs, then B and C enqueued 5 each. Four ways to claim the next job:

sql
-- Fair share: prefer the tenant with the fewest jobs running, then the oldest job.
WITH next AS (
  SELECT j.id
  FROM jobs j
  WHERE j.status = 'queued'
  ORDER BY (SELECT count(*) FROM jobs r
            WHERE r.tenant_id = j.tenant_id AND r.status = 'running'),
           j.created_at
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs SET status = 'running', started_at = now()
FROM next WHERE jobs.id = next.id
RETURNING jobs.id, jobs.tenant_id;
Claim ruleB's first job startsA's most jobs running at onceA's 40 jobs done
FIFO (ORDER BY created_at)0.89 s61.0 s
Soft cap: WHERE (count running) < 20.15 s6 (cap was 2)2.4 s
Fair share (query above)0.00 s2 while B and C had work; 6 once they were done1.3 s
Hard cap: lock the tenant's row in a tenant_slots table with FOR UPDATE SKIP LOCKED, increment running in the same statement0.00 s22.8 s

What the runs show:

  • FIFO scales the wait with the other tenant's backlog. 40 jobs ahead cost B 0.9 s. Two million jobs ahead at 60 jobs a second is about 9 hours.
  • The soft cap does not hold. Six workers ran the claim at the same moment; each statement counted A's running jobs from its own snapshot, saw fewer than 2, and claimed one. It is check-then-act across concurrent transactions, the same race as in JS Chapter 5. Fine as a rough limit; not a guarantee.
  • The hard cap holds, at a cost. Locking the tenant row serialises claims per tenant, so A never exceeded 2. But when B and C finished, four workers sat idle while A waited. A hard cap is not work-conserving: it leaves capacity unused.
  • Fair share is usually the right default: contended capacity is split, idle capacity is used. Add a hard cap only where a tenant could hurt a shared downstream (an LLM provider's rate limit, a third-party API).

At scale, the per-row count(*) gets expensive; keep a running count per tenant in its own table (and fix it from a reaper when a worker dies mid-job).

Diagnosing a noisy neighbour

InvestigationTime spent: 0 min
Symptom
p99 latency for every tenant on the API doubled at 14:00. Error rate is flat. Nothing was deployed.
1What do you check first?

Part 5: Cells — blast radius as a fraction of tenants

A cell is a full copy of the stack (API, workers, queue, database, cache) that serves a fixed subset of tenants. A thin cell router maps tenant to cell.

With 10 cells, anything that goes wrong inside a cell reaches about 10% of tenants: a database failover, a poison tenant, a runaway query, a full disk, a bad deploy (if you deploy one cell at a time and watch it before the next).

The rules that make cells work:

  • The router is as simple as possible. It is the one shared part, so it reads a small, cached tenant → cell map and does nothing else. If it needs a database to route, that database is your new single point of failure.
  • Cells are the same size and the same shape, so capacity planning, dashboards and runbooks are the same for each.
  • The largest tenant sets the cell size. A tenant too big for a cell has to get a cell of its own.
  • Deploy cell by cell, starting with a small canary cell, with a soak time between waves.
  • Moving a tenant between cells is a data migration, not a config change. Design it once and practise it.

What cells cost and what they don't isolate:

CostWhy
N copies of every componentEach cell needs its own headroom; utilisation is lower
Cross-tenant features are harderGlobal search, analytics, "all users" admin views need a separate pipeline that reads from every cell
Global state is still sharedLogin, billing, the tenant → cell map. Keep these small and very reliable, or they become the blast radius you were trying to avoid
More moving parts to operateTen databases to back up, upgrade and fail over

For a startup with 50 tenants, cells are usually overkill; separate pools, queues and quotas give most of the benefit. Say that, and say at what point you would move to cells: when one tenant's incident hurting everyone becomes a contract problem, or when one database can't hold everyone.


Part 6: Shuffle sharding

Cells give each tenant a fixed group. Shuffle sharding gives each tenant a random small set of nodes from the whole fleet. Two tenants rarely get the same set, so a tenant that kills its nodes (a poison request, a flood) rarely takes anyone else down completely.

The numbers, computed and then simulated:

FleetPlain groupsShuffle sharding
8 nodes, 2 per tenant4 fixed pairs. A poison tenant kills its pair: 25% of tenants are fully downC(8, 2) = 28 possible pairs. Another tenant has the exact same pair with probability 1/28 ≈ 3.6%. 46% share one node and still have the other
100 nodes, 5 per tenant20 fixed groups: 5% fully downC(100, 5) ≈ 75 million sets. In a simulation of 100,000 tenants, 0 lost all 5 nodes; 23% lost at least one but kept others

Shuffle sharding only works if three things are true:

  1. Clients retry on the other nodes of their own shard, so losing one node is invisible to them.
  2. A failing request is not retried across the whole fleet. If the poison request bounces to every node, it kills every node, and the shard boundary means nothing. Cap retries to the shard.
  3. Nodes are interchangeable: stateless workers, or replicas of the same data.

It suits stateless request-serving fleets (API front ends, workers pulling from per-tenant queues). For data, you get the same idea from placing each tenant's replicas on different, randomly chosen nodes.


Part 7: Blast radius per zone and region, and per feature

Zones and regions

An availability zone is one or more data centres with independent power and networking, close enough to its siblings for synchronous traffic. A region is a group of zones. Rules that keep a zone failure small:

  • Static stability. Run three zones, each with enough capacity to carry the load if one zone disappears: each runs at no more than about 66% at peak. Don't plan to scale up during the failure; the control plane you would scale with may be the thing that is struggling.
  • Keep requests in their zone. A request that crosses zones on every hop fails when any zone has trouble. Prefer zone-local replicas and caches where consistency allows.
  • The database is the hard part. A primary in one zone with a synchronous standby in another fails over without losing commits, but managed services typically take from tens of seconds to a couple of minutes to do it; Chapter 5 covers failover.
  • Regions are a bigger step: data residency, replication across tens of milliseconds, and deciding which region owns each tenant's writes. A region failure's blast radius is every tenant whose writes live there.

Features

One optional feature should not take down the core path. Give optional features their own bulkheads (pools, queues, provider keys), their own deployments if they are heavy, and a kill switch that turns them off without a deploy (Chapter 6). An "AI summary" button that shares checkout's database pool and LLM quota is a design where a feature launch can stop orders.

One slow instance

Blast radius also applies inside one fleet. A single slow server behind round robin still gets its full share of requests, and each of them becomes a p99 sample for someone. Predict, then switch the algorithm.

Loading simulation…

One server in four was slow, and everyone's p99 rose. Least-connections routing or outlier ejection (remove instances whose latency or errors stand out from the rest) shrinks that blast radius to the few requests already on it.


When the Interviewer Breaks It

Say the answer out loud before opening each one.

Break 1: "Recommendations now takes 10 seconds"

The design: a home feed API calls five services, including recommendations, which is optional.

Answer

"First, who else does this reach? At 50 requests a second and 10 s, recommendations would hold 500 calls in flight. If those share a pool with anything else, the whole page goes, checkout included. So: recommendations gets its own bulkhead of about 8 concurrent calls, sized from normal load, with no wait queue, and a timeout of about 300 ms from our latency budget. Anything over the limit fails in under a millisecond and the page renders without the strip. A circuit breaker on top stops even those 8 calls once the failure rate is clear, and half-opens to find recovery. Checkout's p99 should not move, and that's the metric I'd watch."

Follow-up: "Recommendations is now 50% errors, not slow. Does anything change?" Fast errors don't hold slots, so the bulkhead matters less and the breaker matters more. The danger is retries: one retry layer at most, with a budget, or the 50% errors become 100% more load on a struggling service (Chapter 2).

Weak answer: "Add a timeout." A 2 s timeout still needs 100 slots at 50 req/s; the measured shared pool of 20 served zero checkouts.

Break 2: "One tenant just sent 100× their normal traffic"

Answer

"Three layers. At the edge, a per-tenant token bucket rejects the excess with 429 and Retry-After, so most of it never reaches the app. Inside, a per-tenant concurrency quota, because rate alone doesn't catch expensive requests. In the job queue, fair-share claiming: the next job goes to the tenant with the fewest running, so their 100× backlog only slows them. I'd also check whether it's a bug on their side, a retry loop, before treating it as real demand, and tell their account owner. If their traffic is legitimate and big, they're a candidate for their own cell or a higher plan with reserved capacity."

Follow-up: "Your rate limiter runs in each of 30 pods." Then a limit of 100 req/s per pod is 3,000 per tenant. Use a shared counter (Redis) or a gateway limiter; JS Chapter 8, Part 7 shows the per-process trap.

Weak answer: "Autoscale." You'd pay to serve one tenant's flood, and the shared database still saturates.

Break 3: "A bug in today's deploy crashes the process on one tenant's data"

Answer

"Every pod that serves that tenant crashes, restarts, gets the request again, and crashes. With one fleet, that is everyone. Two things limit it. Deploy in waves, by cell if we have cells, otherwise a canary with a small share of traffic, and roll back automatically on crash rate. And make sure a crashing request isn't retried across the whole fleet: retries go to at most one other instance. With shuffle sharding, a poison tenant can only kill its own few nodes; with 100 nodes and 5 per tenant, essentially no other tenant loses all of theirs."

Follow-up: "How do you find the poison tenant fast?" Log the tenant and request ID at the start of each request, so the last line before each crash names them; count crashes by tenant. Then block that tenant's request type with a kill switch while the fix ships.

Weak answer: "Roll back." Right, but it doesn't reduce the blast radius before the rollback finishes, and the interviewer asked about design.

Break 4: "One availability zone just went dark"

Answer

"We run three zones, each sized so two can carry peak, about 66% each at peak. The load balancer's health checks stop sending traffic to the dead zone within seconds. The database primary, if it was there, fails over to the synchronous standby in another zone: no lost commits, but up to a minute or two of failed writes on most managed services. During that window writes fail, so the API returns 503 with Retry-After for writes and keeps serving reads from replicas in the healthy zones. We don't depend on scaling up during the outage, because the capacity is already there."

Follow-up: "What if you're only in two zones?" Then each zone must carry 100% alone, so each runs at 50% at peak. That is the cost of zone redundancy; three zones is cheaper per unit of safe capacity.

Weak answer: "The cloud handles it." Managed services fail over, but your capacity plan, your cross-zone calls and your write path during failover are yours.

Break 5: "You autoscaled from 10 to 60 pods and the payment provider started rejecting you"

Answer

"The bulkheads are per pod. Twelve concurrent payment calls per pod was 120 at 10 pods and is 720 at 60, and the provider allows maybe 200. Per-process limits multiply with the fleet. Either derive the per-pod limit from the provider's limit divided by the pod count, updated on scale events, or put the limit in one shared place: a Redis-based limiter or an egress proxy in front of the provider. I'd also stop scaling on CPU for a service whose bottleneck is a downstream."

Follow-up: "Which one would you pick?" The shared limiter for a hard external limit, since breaking it costs real failures; per-pod division for soft internal ones, where approximate is fine.

Weak answer: "Raise the bulkhead." That makes the provider's rejections worse.


The Causal Chains

Self-test: predict each consequence before revealing it.
  • If an optional dependency slows from 80 ms to 5 s
  • If a pool's wait queue has no timeout
  • If you hold a DB connection while calling another service
  • If a job queue is FIFO across tenants
  • If a per-tenant cap is a count(*) check inside the claim query
  • If bulkheads are per pod
  • If a crashing request is retried on every node
  • If the health check shares a pool with real traffic

Checklist: isolation for any design

  1. List the shared fixed-size resources on the request path: pools, sockets, event loop, threadpool, workers, provider quotas, the database.
  2. Give each external dependency its own bulkhead: a limit from Little's Law, a small queue with a short timeout or none, and a fallback.
  3. Never hold a pooled connection across a network call.
  4. Reserve capacity for critical work (checkout, health) inside shared pools, and keep the health check shallow.
  5. Separate queues and workers for interactive and bulk work; separate deployments for different traffic classes.
  6. Per-tenant quotas on rate, concurrency and cost; fair-share scheduling in the job queue.
  7. Know your biggest tenant, and what happens to everyone on its shard.
  8. Check per-process limits against the fleet size and the downstream's real limit.
  9. For larger systems: cells with a thin router, deploys cell by cell; shuffle sharding for stateless fleets; three zones sized for one loss.
  10. Optional features get their own resources and a kill switch.

What's next

Isolation decides who a failure reaches. Chapter 4 — Overload Protection handles the case where the failure is simply too much demand: rate limits, backpressure, and load shedding by priority and deadline, with measured goodput for each.