Timeouts and Bulkheads

The recommendations strip at the bottom of the product page was the least important thing we shipped. For about ninety minutes on a Tuesday it was the only thing that mattered, because it was the reason nobody could check out.

The page fetched recommendations inline while it rendered. No timeout. That afternoon the recs service wedged itself: a database failover on their side left a pile of half-open connections, so the TCP handshake to it started completing and then going silent. Not refused. Not a 500. Just a socket that accepted your bytes and never answered. A fetch in that state does not fail. It waits. The operating system will not give up on a silent connection for over a minute, often two.

Here is the part that turns a nothing-feature into an outage. Every hung fetch held a request handler open, and that handler had already checked out a pooled connection for the earlier part of the page, so the connection was held too, and the worker running it. Requests stacked behind the wedged ones and the pool drained. Then the cart page, which never touches recommendations, could not get a connection either. The health check timed out, so the balancer pulled the instance, so its traffic shifted to the siblings, which drained faster.

Nothing crashed. Nothing threw. Every dashboard was green except the one measuring whether customers could give us money.

The fix that day was one line: a 300ms deadline on the recs call, and a catch that returned an empty strip. Recommendations blinked out for a few seconds here and there. Nobody noticed. The site stopped falling over.

Two patterns would have prevented it outright, and neither is clever. Put a deadline on everything that can wait. Isolate resources so one wedged dependency cannot drain the pool everyone shares. They are the two most boring entries in the stability-patterns catalogue that Michael Nygard wrote down in Release It!, and between them they prevent more production outages than anything fancier you will read about this year.

The absence of a timeout is a latent outage

Any call that can wait is a call that can wait forever. A network request. A database query. Acquiring a lock. A blocking read off a queue. Every one of them, left without a deadline, is an outage that has not happened yet, sitting in your code waiting for the day its dependency gets slow instead of fast.

The failure is always the same shape. One call hangs. Because it hangs, it keeps holding whatever it grabbed on the way in: the request slot, a pooled connection, a worker’s attention, the memory of everything half-built. More requests arrive and hang the same way. The held things run out. Now requests that have nothing to do with the slow dependency cannot get a connection or a worker, so they fail too. The service stops serving. It did not crash. It is pinned.

No timeoutone hang pins the whole chainrequestshandlerconnectionworkerrecs APIaccepts, never answersheld until it answers, which is neverpool 8 / 8 heldnew arrivals queue behind503 for cart, login, healthTimeout 1sthe hang becomes a fast errorrequestshandlerconnectionworker1srecs APIstill silent, abandonedpool 0 / 8 heldslots freed at 1sretry, fall back, or trip a breakera timeout does not fix the slow dependency; it stops the slow dependency from taking everything else with it
Without a deadline one hung call pins a whole chain of resources, and as arrivals stack the pool exhausts. A timeout cuts the chain at a known point and hands back a fast error.

The circuit breaker in the previous lesson is about the repeat problem: once a dependency is clearly down, stop calling it. This is the layer underneath. Before you can notice a call is failing, the call has to actually fail, and a call with no deadline never does. A hang is the one failure your monitoring cannot see, because from the outside it looks exactly like slow, healthy work. The timeout is what converts an invisible infinite wait into a countable, handleable error. Everything else in this chapter is built on that conversion.

The messy version ships every day

None of this code looks wrong. That is the problem. Here is the product page from the story, more or less:

// render the product page
const [product, reviews, recs] = await Promise.all([
  fetch(productUrl).then((r) => r.json()),
  fetch(reviewsUrl).then((r) => r.json()),
  fetch(recsUrl).then((r) => r.json()),   // no deadline; this is the one that hung
]);

Promise.all waits for the slowest of the three. That is its job. So the whole page is now hostage to whichever dependency is having the worst day, and the one having the worst day happened to be the one you cared about least. There is no timeout anywhere in that block, and there is no timeout anywhere in fetch by default either. fetch will wait as long as the socket stays open. People assume there is some sane built-in limit. There is not.

The database side is worse, because the dangerous default is written down and still ships:

import { Pool } from "pg";

const pool = new Pool();               // connectionTimeoutMillis defaults to 0
const { rows } = await pool.query(sql); // no per-query deadline either

connectionTimeoutMillis: 0 means “wait forever to even acquire a connection from the pool”. So when the pool is drained by hung queries, the next caller does not fail fast. It joins the queue and waits forever too, which is precisely how the pin spreads from one slow dependency to your entire app. The query itself has no server-side deadline unless you set one.

Timeout everything

The good news is that the modern fix is genuinely one line. AbortSignal.timeout(ms) gives you a signal that aborts itself after ms, and fetch already understands signals:

const recs = await fetch(recsUrl, { signal: AbortSignal.timeout(300) })
  .then((r) => r.json());

When 300ms passes, the fetch aborts and the promise rejects with a TimeoutError (a DOMException whose name is "TimeoutError", distinct from the "AbortError" you get from a manual cancel). That distinction matters: you can tell “I gave up on time” apart from “the user navigated away”, and treat them differently.

Because recommendations are optional, the caller decides the hang is not worth a failed page:

async function getRecs(recsUrl) {
  try {
    const res = await fetch(recsUrl, { signal: AbortSignal.timeout(300) });
    return res.ok ? await res.json() : [];
  } catch (err) {
    if (err.name === "TimeoutError") return [];   // slow recs are no recs
    throw err;                                     // a real bug: let it surface
  }
}

That is the entire fix from the war story. Deadline plus a fallback for the optional thing. The load balancer never sees a slow page again, because the page is structurally incapable of waiting longer than 300ms on that call.

For anything that does not take a signal

Not every waitable thing accepts an AbortSignal. When you only have a promise, you race it against a timer. The naive version leaks, so get this shape right once:

class TimeoutError extends Error {
  constructor(message) {
    super(message);
    this.name = "TimeoutError";
  }
}

function withTimeout(promise, ms, label = "operation") {
  let timer;
  const bell = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new TimeoutError(label + " exceeded " + ms + "ms")), ms);
  });
  return Promise.race([promise, bell]).finally(() => clearTimeout(timer));
}

The .finally(() => clearTimeout(timer)) is not tidiness. Without it, when promise wins the race the timer is still pending, and under load you accumulate live Timeout objects (they are objects holding references, not integers) until they each fire. The finally kills the loser the instant a winner is decided.

Choosing the number is the actual work

Adding the timeout is easy. The number is where judgement lives, and both ways of getting it wrong are real.

Set it too short and you kill healthy calls. A dependency whose normal p99 is 400ms will occasionally, legitimately, take 500ms under a GC pause or a cold cache. A 300ms timeout turns those healthy-but-slow responses into errors, and if you retry them (see retries and backoff) you have built a machine that adds load to a service precisely when it is already struggling. A too-tight timeout is a self-inflicted denial of service.

Set it too long and you have barely helped. A 30-second timeout on a call that should take 40ms does technically prevent the infinite hang, but 30 seconds of holding a connection under load will exhaust your pool long before the timeout ever fires. The timeout has to be short enough that your pool survives a burst of them.

The number is not a vibe. It comes from the dependency’s own latency distribution:

  • Base it on the dependency’s real p99 plus headroom, not on a round number you like. If p99 is 400ms, something like 800ms to 1000ms leaves room for a bad-but-normal day without waiting on a corpse.
  • Give each dependency its own timeout. The payment gateway and the avatar service do not deserve the same patience. A single global HTTP_TIMEOUT=5000 is how the slow-but-important call gets killed and the fast-but-optional one gets babied.
  • Remember that retries multiply. Three attempts at a 1-second timeout is a 3-second worst case, and that whole budget has to fit inside whatever your own caller is willing to wait. Which brings up the thing most systems get wrong.

Deadlines have to travel

A single call with a timeout is easy. A request is not a single call. It is a tree of them, and the trouble is that a fresh timeout at every node ignores the time already spent above it.

Picture a 2-second budget at the edge. The gateway calls service A, which calls service B, which queries the database. If each hop starts its own fresh 2-second timeout, the worst case is not 2 seconds. It is 8. The user gave up and closed the tab at 2, and your servers are still busy for six more seconds producing an answer nobody will ever read. That wasted work is holding the exact resources the next user needs.

Fresh timeout each hopthe worst case is the sumgatewaytimeout 2sservice Atimeout 2sservice Btimeout 2sdatabasetimeout 2sclient gave up at 2sup to 8s of workRemaining budget passed downbounded by the deadlinegatewayleft: 2000msservice Aleft: 1850msservice Bleft: 1200msdatabaseleft: 600mswhole tree fits in 2seach hop takes the smaller of what it wants and what is left; when the budget hits zero, everyone stops at once
Give every hop a fresh full timeout and the worst case is their sum, long after the client has left. Pass the remaining budget down and the whole tree is bounded by the deadline set once at the edge.

The rule is simple: a call gets the smaller of the timeout it would like and the time the request has left. You carry a deadline, not a duration, and each hop computes its own remaining budget from it. A request-scoped store keeps that deadline out of every function signature, the same AsyncLocalStorage trick used for request ids:

import { AsyncLocalStorage } from "node:async_hooks";

const deadline = new AsyncLocalStorage();

// at the edge: stamp the budget once
app.use((req, res, next) => {
  deadline.run({ at: Date.now() + 2000 }, next);
});

// anywhere downstream, no plumbing: how long do I have?
function budget(idealMs) {
  const d = deadline.getStore();
  const left = d ? d.at - Date.now() : Infinity;
  return Math.max(0, Math.min(idealMs, left));
}

// every call clamps its own timeout to the budget
const recs = await fetch(recsUrl, { signal: AbortSignal.timeout(budget(300)) });

Now no call can ever push the request past its edge deadline, and a request whose budget is already spent fails immediately instead of starting work it has no time to finish.

Bulkheads: stop one leak from sinking the ship

Timeouts bound each call. They do not, on their own, stop a flood of slow-but-not-yet-expired calls to one dependency from eating a resource that everything else also needs. Give the recs service a 1-second timeout, then send it enough traffic while it is at 900ms, and it can still hold every connection in a shared pool for that whole second, starving the login page that shares the pool. The timeout capped each call. It did not isolate them.

The fix is the oldest idea in shipbuilding. A hull is divided into watertight compartments by bulkheads, so a breach floods one compartment and the ship stays up. Without them, one hole below the waterline fills the whole hull and she goes down. The Titanic had bulkheads; they just did not reach high enough, so the water poured over the top from one compartment to the next. Size and placement are the whole game, in steel and in software both.

One shared pool · no bulkheadsone open hold: every dependency shares itrecs floods, and there is nothing to stop itsinksallA pool per dependency · bulkheadspaymentsdrysearchdryrecsfloodedemaildrythe recs compartment fills to its limit and no further; payments, search and email keep serving
One shared hold means a single breach floods everything and the ship goes down. Divide the hull into compartments and the same breach floods one, while the rest stay dry and afloat.

In software the compartments are resource pools. You give each dependency its own bounded allocation so that when one saturates, it saturates its own allocation and stops, instead of draining a shared one. There are three levels this shows up at, coarse to fine:

  • Separate connection pools. One pg Pool for your latency-critical transactional queries and a different, smaller Pool for the heavy reporting queries, so a pile of slow reports cannot consume the connections your checkout needs. On the HTTP side, a separate undici Agent per upstream (with its own connections cap) keeps one slow API from eating the socket budget of another.
  • Separate worker pools or processes. The heaviest isolation. Run the flaky, slow integration on its own set of workers so it cannot touch the request path at all.
  • A concurrency limit per dependency. The lightweight one, and the one you will reach for most. No new pool, no new process, just a cap on how many calls to a given dependency can be in flight at once.

A semaphore is a bulkhead you can write in a sitting

The concurrency limit is a semaphore: a counter of available slots, plus a queue for callers who arrive when the slots are full. It is small enough to build from a closure, and building it once makes the pattern obvious.

function bulkhead(limit) {
  let active = 0;
  const queue = [];

  const pump = () => {
    if (active >= limit || queue.length === 0) return;
    active++;
    const { fn, resolve, reject } = queue.shift();
    Promise.resolve()
      .then(fn)
      .then(resolve, reject)
      .finally(() => {
        active--;
        pump();          // a slot freed up; admit the next waiter
      });
  };

  return (fn) =>
    new Promise((resolve, reject) => {
      queue.push({ fn, resolve, reject });
      pump();
    });
}

Wrap each dependency in its own instance and the isolation is done:

const recsLimit = bulkhead(8);       // at most 8 concurrent recs calls
const searchLimit = bulkhead(20);    // search gets its own, larger budget

const recs = await recsLimit(() =>
  fetch(recsUrl, { signal: AbortSignal.timeout(budget(300)) }),
);

Now a recs meltdown can pin at most 8 in-flight calls. The ninth waits in the recs queue, and search, with its own separate searchLimit, never notices. The blast radius of a wedged recs service is exactly 8 slots wide.

One noisy tenant should not starve the rest

The same isolation, turned sideways, solves the loudest problem in multi-tenant systems. One customer runs a runaway script, or imports a million rows, or just has a genuinely huge account, and their traffic consumes a shared pool while every other tenant sits behind them getting nothing. It is the recs meltdown again, except the “dependency” is a tenant, and the fix is a bulkhead per tenant instead of per dependency.

Shared pooltenant C floods it, A and B starveABC firehosepool 6 / 6, all held by Cno free slot for A or BA, B: 503punished for CA budget per tenantC saturates only CABC firehosecap 2cap 22 / 2 fullA, B: servedunaffected by CC throttled to its sharethe noisy tenant still gets its fair slice; it simply cannot borrow everyone else’s
Share one pool and a firehose tenant fills it, leaving nothing for anyone else. Give each tenant its own slot budget and the firehose saturates only its own, while the quiet tenants keep being served.

The code is the bulkhead closure again, one instance per tenant, created on demand:

const perTenant = new Map();

function tenantLimit(tenantId) {
  let limiter = perTenant.get(tenantId);
  if (!limiter) {
    limiter = bulkhead(4);            // each tenant gets 4 concurrent slots
    perTenant.set(tenantId, limiter);
  }
  return limiter;
}

app.use((req, res) => tenantLimit(req.tenantId)(() => handle(req, res)));

This is concurrency isolation, and it pairs with rate limiting, which is throughput isolation: the bulkhead caps how many of a tenant’s requests run at once, the rate limiter caps how many run per second. Different questions, both worth answering.

Watch a flood stay in its compartment

Traffic streams in across three dependencies. Make recs slow and watch what happens with bulkheads off: recs calls hold their slots for seconds, fill the shared pool, and search and payments stop being served even though nothing is wrong with them. Their served counters freeze. Now switch bulkheads on. Recs saturates its own two slots and backs up its own queue, and search and payments keep climbing, untouched. Same flood, contained.

interactiveA flood, with and without bulkheads

The frozen counters under the shared pool are the whole point. Those are real requests to healthy services, failing for a reason that has nothing to do with them. Flip bulkheads on and the recs card is the only one that ever says STALLED. That is a compartment doing its job.

Defence in depth: they stack

Timeouts, bulkheads, retries and the circuit breaker are not competing choices. They are layers around one call, and each catches what the others miss. Order them from the call outward:

retry · give up fast, backoff + jittercircuit breaker · stop repeating a known outagebulkhead · cap concurrency, isolatetimeout · bound this one callthe calleach layercatches whatthe inner oneslet through:timeout → the hangbulkhead → the floodbreaker → the repeatretry → the blip
The four patterns nest around a single call. Innermost is the timeout on the call itself, then the bulkhead capping concurrency, then the breaker stopping repeats, then retry on the very outside giving up quickly.

The timeout sits closest to the call, because everything else depends on a hang becoming a countable failure. The bulkhead wraps it, so even before the timeout fires a flood cannot consume more than its slice. The breaker wraps that, so once the failures are undeniable you stop paying even the timeout. Retry goes on the very outside, so a genuine one-off blip gets a second chance but gives up the moment the breaker is open. Put the retry on the inside instead and it multiplies your load against an already-sick service, which is exactly the mistake the breaker lesson warns about.

// conceptually: retry(breaker(bulkhead(timeout(call))))
const guardedRecs = retry(
  breaker(
    recsLimit,                                          // bulkhead: cap concurrency
    () => fetch(recsUrl, { signal: AbortSignal.timeout(budget(300)) }), // timeout: bound the call
  ),
  { attempts: 2, backoff: "jitter" },                   // retry: outermost, gives up fast
);

Wiring these four together cleanly, in the right order, with shared metrics, is its own topic. Composing stability patterns picks up exactly here.

Where these go wrong

Both patterns have failure modes, and being honest about them is the difference between resilience and cargo-culting.

A timeout with no plan for the error is just a faster way to show a 500. The deadline converts a hang into an error, but an unhandled error is still a broken page. Decide, per call, what the timeout means: retry it, serve a cached or default fallback, degrade the feature, or surface a clean failure. A timeout is a question (“what do we do when this is too slow?”), not an answer.

A client timeout without server cancellation wastes the work, it does not save it. Push the deadline all the way through: AbortSignal into fetch, statement_timeout into Postgres. A timeout that abandons work instead of cancelling it hides your load, it does not reduce it.

Retrying a write after a timeout can double it. A timeout tells you that you stopped waiting. It does not tell you whether the work succeeded; the request may have completed on the server a millisecond after you gave up. Retrying a non-idempotent write on timeout is how you charge a card twice. If you retry writes, make them safe to repeat with an idempotency key.

One bulkhead around everything is not a bulkhead. If a single limiter guards all your outbound calls, one saturated dependency fills it and blocks the rest, which is the exact coupling you were trying to break, now with extra steps. Scope one per dependency (or per tenant). A shared compartment is just a smaller hull.

A bulkhead sized wrong fails in one of two ways, and both are quiet. Too small and you throttle yourself, leaving capacity idle while requests queue for no reason. Too large and it never engages, so a flood exhausts the shared resource underneath before the limit is ever reached. Size it against the real thing it protects: a bulkhead of 50 in front of a pg Pool of 10 protects nothing, because the pool runs out at 10. These limits are invisible until someone hits them, which is the entire argument for wiring saturation, rejections and timeouts into your observability from the first day. A bulkhead you cannot see is a bulkhead you cannot size.

Summary

  • Any call that can wait can wait forever. A network request, a query, a lock, a queue read: without a deadline each one is a latent outage. fetch, pg’s connect timeout, and blocking queue reads all default to “wait forever”, and that default is wrong for production.
  • A hang is the failure your monitoring cannot see, because it looks exactly like slow healthy work. A timeout converts an invisible infinite wait into a countable, handleable error, which is what everything else (breaker, retry, fallback) needs in order to act.
  • Timeout everything. AbortSignal.timeout(ms) is the one-liner for fetch; a Promise.race wrapper (with clearTimeout in a finally) covers the rest. Prefer passing the signal into the operation so the deadline cancels the work rather than abandoning it.
  • The number comes from data, not vibes. Base it on the dependency’s real p99 plus headroom, give each dependency its own value, and remember retries multiply the worst case.
  • Deadlines must travel. A request carries a budget, and each hop gets the smaller of what it wants and what is left. Fresh full timeouts at every hop sum past the deadline and burn resources on answers nobody will read. Carry the deadline in an AsyncLocalStorage; merge it with the caller’s abort using AbortSignal.any.
  • Bulkheads isolate. Give each dependency (or tenant) its own bounded pool or concurrency limit, so a flood saturates one compartment and the rest keep serving. In JavaScript the lightweight version is a semaphore, a closure with a counter and a queue, one instance per dependency.
  • They compose, in order. Timeout closest to the call, then bulkhead, then breaker, then retry outermost. Each layer catches what the inner ones let through.
  • Use a timeout on everything that waits, always. Use a bulkhead when one dependency or tenant can consume a resource that others share. Avoid a timeout with no fallback plan, a client timeout that never cancels the server, retrying non-idempotent writes on timeout, and a single global bulkhead that recouples everything it was meant to separate.