This article explains the bulkhead pattern as a crucial system design concept for isolating failures in distributed systems. It demonstrates how a slow dependency, like an LLM provider, can starve an unrelated critical path (e.g., login) by consuming shared resources. The author provides a simple, dependency-free TypeScript implementation of a bulkhead to prevent such cascading failures.
Read original on Dev.to #systemdesignIn distributed systems, a single slow external dependency can inadvertently bring down unrelated, critical services. The article illustrates this with a scenario where a slow LLM provider consumes all available concurrency slots or connections, preventing even a simple login request from being processed. This isn't due to the Node.js event loop blocking, but rather a depletion of finite resources shared across different service paths, such as per-instance concurrency limits (e.g., Cloud Run), reverse proxy connection caps, or shared database connection pools.
Understanding Resource Starvation
Little's Law (L = λW) highlights this: if the time a request spends in the system (W) increases due to a slow dependency, the number of requests in flight (L) will also increase proportionally, quickly exhausting finite slots if the arrival rate (λ) remains constant.
The bulkhead pattern is an architectural design principle derived from shipbuilding, where compartments prevent a breach from flooding the entire vessel. In software, it involves isolating elements of an application into separate pools of resources. This ensures that if one component or external dependency fails or degrades, it only impacts its designated pool, leaving other services operational.
The article provides a concise TypeScript implementation of a semaphore-based bulkhead. This implementation manages concurrent calls and waiting requests for a given operation. It uses three key parameters for each dependent service:
export class Bulkhead {
private active = 0;
private queue: Waiter[] = [];
private opts: Options;
constructor(opts: Options) {
this.opts = opts;
}
async run<T>(fn: () => Promise<T>): Promise<T> {
await this.acquire();
try {
return await fn();
} finally {
this.release();
}
}
private acquire(): Promise<void> {
const { name, limit, maxQueue, maxWaitMs } = this.opts;
if (this.active < limit) {
this.active++;
return Promise.resolve();
}
if (this.queue.length >= maxQueue) {
return Promise.reject(new BulkheadFullError(name));
}
return new Promise((resolve, reject) => {
const w: Waiter = { resolve };
if (Number.isFinite(maxWaitMs)) {
w.timer = setTimeout(() => {
// ... rejection logic
}, maxWaitMs);
}
this.queue.push(w);
});
}
private release(): void {
this.active--;
if (this.queue.length > 0) {
const next = this.queue.shift();
if (next?.timer) clearTimeout(next.timer);
next?.resolve();
}
}
}