Split resources into isolated pools per dependency, so one overwhelmed dependency can't starve every other request.
If every outgoing call — to the payments API, the recommendations service, the email provider — draws from one shared connection pool, a slow payments API can occupy every connection in that pool waiting on timeouts, leaving nothing available for the recommendations calls that were working fine.
Named after a ship's watertight compartments: partition the shared resource (connections, threads, queue capacity) into separate, fixed-size pools per dependency or per tenant. One pool flooding doesn't sink the others.
Each dependency gets its own pool with a hard cap. Payments filling its four slots has no effect on the separate Email pool.
class Bulkhead {
#maxConcurrent;
#active = 0;
#queue = [];
constructor(maxConcurrent) { this.#maxConcurrent = maxConcurrent; }
async run(fn) {
if (this.#active >= this.#maxConcurrent) {
throw new Error("bulkhead full — rejecting immediately"); // fail fast, don't queue forever
}
this.#active++;
try {
return await fn();
} finally {
this.#active--;
}
}
}
const paymentsPool = new Bulkhead(2);
const emailPool = new Bulkhead(2);
const slowPaymentsCall = () => new Promise((r) => setTimeout(() => r("paid"), 5000));
paymentsPool.run(slowPaymentsCall);
paymentsPool.run(slowPaymentsCall);
paymentsPool.run(slowPaymentsCall).catch((e) => console.log(e.message)); // rejected — pool full
// Email pool is untouched by payments being saturated
emailPool.run(async () => "sent").then(console.log); // "sent"