All articles
Design PatternsSystem DesignReliability11 min read

Bulkhead Pattern: Containing the Blast Radius

Shared thread pools, connection pools and queues mean one noisy feature can starve every other one. The bulkhead pattern partitions resources so a failure floods a single compartment instead of the whole ship.

Shared resources are shared failure

Most services start with one HTTP thread pool, one database connection pool and one queue consumer group. That is fine until a single downstream call gets slow. Then every in-flight request for that call holds a thread, and the pool becomes a global lock on your service.

The users who notice are not the ones using the broken feature. They are the ones trying to pay, because payment requests are now queued behind three hundred stuck recommendation calls.

The idea, borrowed from ships

A ship's hull is divided into watertight compartments. A breach floods one section; the vessel stays afloat. In software, you partition the finite resources — threads, connections, memory, concurrency permits — so that exhaustion is local.

WITHOUT BULKHEADSone pool — 200 threadsrecommendations holds all of thempayments ✕ search ✕ checkout ✕ — all blockedWITH BULKHEADSPayments40% of its own pool in useSearch60% of its own pool in useRecommendations100% of its own pool in userecommendations saturates — payments and search are untouched
The same load, two resource topologies, completely different outcomes.

Where to place bulkheads

LevelPartition byMechanism
Thread / concurrencyDownstream dependencySeparate executors or semaphores
Connection poolWorkload classDistinct DB pools for OLTP vs reporting
QueueMessage type or tenantSeparate topics and consumer groups
ProcessCriticalityDeploy payments as its own service
ClusterTenant tierDedicated node pools for enterprise customers

The cheapest useful version is a semaphore per dependency inside one service. The strongest is physical separation. Most teams need the first and reach for the last only for the money path.

Sizing the compartments

  1. Start from Little's Law: concurrency = throughput × latency. Twenty requests per second at 200ms needs about four permits, not two hundred.
  2. Add headroom for the p99, not the mean — that is where saturation happens.
  3. Deliberately under-provision optional features. Shedding recommendations is fine.
  4. Never let the sum of pools exceed what the process can actually run.
Rejection is a feature
When a bulkhead is full, the correct behaviour is to reject immediately, not to queue unboundedly. An unbounded queue converts a capacity problem into a latency problem and then into an out-of-memory crash.

Implementation sketch

class Bulkhead {
  private inFlight = 0;
  constructor(private name: string, private limit: number) {}

  async run<T>(fn: () => Promise<T>): Promise<T> {
    if (this.inFlight >= this.limit) {
      metrics.increment("bulkhead.rejected", { pool: this.name });
      throw new BulkheadFullError(this.name);
    }
    this.inFlight++;
    metrics.gauge("bulkhead.inflight", this.inFlight, { pool: this.name });
    try {
      return await fn();
    } finally {
      this.inFlight--;
    }
  }
}

const pools = {
  payments: new Bulkhead("payments", 60),
  search: new Bulkhead("search", 30),
  recommendations: new Bulkhead("recommendations", 8),
};

// recommendations can never consume more than 8 concurrent slots
await pools.recommendations.run(() => recoClient.fetch(userId));
A semaphore-based bulkhead: bounded concurrency plus fast rejection.

Combine this with a circuit breaker: the bulkhead caps concurrency, the breaker stops calling altogether once failure is sustained. They solve adjacent problems and are usually deployed as a pair.

Bulkheads for multi-tenant systems

The noisy neighbour is the same failure wearing a different hat. One customer submits a hundred-thousand-row import and every other tenant's dashboards stall. Partition by tenant:

  • Per-tenant concurrency caps on expensive operations.
  • Separate queues (or at least fair scheduling) so one tenant cannot monopolise consumers.
  • Dedicated infrastructure for the largest accounts, priced accordingly.

The cost: utilisation

Partitioned resources cannot be borrowed. Three pools of fifty are strictly less efficient than one pool of a hundred and fifty, because idle capacity in one compartment cannot rescue another. You are buying predictability with hardware.

That trade is almost always correct for anything touching revenue, and often wrong for internal batch systems where throughput matters more than isolation.

Interview framing

“Checkout, search and recommendations get separate connection pools and concurrency limits, sized from Little's Law with p99 latency. Recommendations gets eight permits and fails open to a cached list. When a pool is full we reject in microseconds rather than queueing, and we alert on rejection rate — that is the signal that a compartment is under-sized rather than broken.”

Keep reading

Suggested next articles based on this one.

Design it, don't just read it.

Practise LLD and system design problems with structured rubrics and AI feedback.

Start practising free