Hotshard
Fault Tolerance

Bulkheads & Isolation

One slow dependency shouldn't sink the whole ship.

Your service usually calls several other services to answer one request. The trouble is that they normally share one pool of worker threads. When one of those downstream services goes slow, requests waiting on it pile up and hold those shared threads β€” and if enough of them park there, the pool empties and requests to the healthy services can't get a thread either. One slow dependency has just taken your whole service down. A bulkhead fixes that by giving each dependency its own bounded slice of the resource, so a dependency that goes slow can only exhaust its own slice. This page shows the failure, the fix, and how bulkheads combine with timeouts and circuit breakers into the standard resilience stack.

~14 min read

One slow dependency drains the shared pool#

TL;DRthe 30-second version
  • A server answers each request on a worker thread from a fixed pool. If several dependencies share one pool, a slow dependency holds its threads until they time out β€” and enough slow calls at once drain the whole pool, so even requests to healthy dependencies can't get a thread. One partial failure becomes a total outage.
  • The fix is to partition the resource: give each dependency its own bounded slice β€” its own small thread pool, or a limit on how many calls can run at once. A dependency that goes slow can only fill its own slice; calls to the others still get through.
  • It's named after a ship's hull. A hull is split into sealed compartments by walls called bulkheads, so a hole floods one compartment instead of sinking the ship. Same idea: wall off the resource so one failure floods one compartment.
  • Two ways to build a compartment: a semaphore (a counter that caps how many calls run at once β€” cheap, but the call still runs on your thread) or a dedicated thread pool (true isolation, and it can time the call out and walk away β€” but each thread costs memory).
  • It rarely works alone. A bulkhead caps how many calls run at once, a timeout bounds how long each one waits, and a circuit breaker stops calling a dependency that's clearly down. Together they're the standard resilience stack.

Picture a product page. To render it, your server calls three other services: a reviews service, a recommendations service, and a pricing service. Each incoming request runs on one worker thread, pulled from a fixed pool β€” say 200 threads. That number is fixed because each thread costs memory and CPU to schedule, so you can't just make it huge.

On a normal day all three services answer in about 20 milliseconds. A thread makes its calls, gets its answers, renders the page, and is back in the pool almost immediately. 200 threads is plenty of headroom for the traffic you get.

Now the recommendations service gets slow. Not down β€” just slow, taking 2 seconds to answer instead of 20 milliseconds. Every request that needs recommendations now holds its worker thread for the full 2 seconds, waiting. Slowness is worse than a clean error here: an error comes back fast and frees the thread, but a hang keeps the thread parked doing nothing.

Do the arithmetic β€” this is the whole problemSay 50 requests a second need recommendations. At 20 ms each, only about 1 thread is ever busy on recommendations at any instant. At 2 seconds each, that jumps to 50 requests/s Γ— 2 s = 100 threads busy at once. Traffic didn't change β€” only latency did β€” and recommendations now holds 100 of your 200 threads. Push the latency a little higher, or the rate, and it holds all 200. The pool is empty.

Here's the sting. Once the pool is empty, a request that only needs reviews and pricing β€” two services that are perfectly healthy β€” can't get a worker thread either, because they're all parked on recommendations. So the whole product page goes down. A slow patch in one non-critical feature took out the entire page. This is how a partial failure cascades into a total outage, and it's the exact failure a bulkhead exists to stop.

Wall off each dependency in its own compartment#

The root cause is the shared pool: one bucket of threads that any dependency can drain to zero. So don't share it. Give each dependency its own bounded slice of the resource, and cap how much it can ever take. Then a dependency that goes slow can only exhaust its own slice β€” and everything else keeps flowing.

Back to the product page. Instead of 200 threads open to all three services, hand recommendations its own budget of 20, reviews its own 20, and pricing its own 20. Now when recommendations slows to 2 seconds, it fills its 20-thread budget and stops there. The 21st recommendations call is rejected instantly rather than grabbing a thread and waiting. Reviews and pricing still have their own budgets, untouched, so the page still renders β€” just without recommendations. You've traded one feature for the survival of the page.

Shared: one pool of 200any dependency can drain it to zero
one slow dependency β†’
Recommendations goes slowholds threads on 2s calls
no threads left for anyone β†’
Pool emptiesreviews + pricing starve too β†’ page down
Shared pool vs. partitioned compartments
Why it's called a bulkheadA ship's hull is split into sealed compartments by reinforced walls called bulkheads. Punch a hole below the waterline and one compartment floods β€” but the walls stop the water spreading, so the ship stays up. Michael Nygard borrowed the name in his 2007 book Release It! for exactly this move: partition the resource so a failure floods one compartment instead of sinking the whole service. The metaphor is load-bearing β€” the point is the walls, not the water.

A bulkhead almost never ships alone. It's one of three moves that answer different questions about a call to a dependency, and a robust client uses all three at once:

PatternThe question it answersWhat it does
BulkheadHow many calls at once?Caps concurrency per dependency, so one can't drain the shared pool.
TimeoutHow long do I wait?Bounds each single call, so a hang can't hold a thread forever.
Circuit breakerShould I even try right now?Stops calling a dependency that's clearly down, then probes for recovery.

They stack cleanly. The timeout puts a ceiling on how long any one call parks a thread. The bulkhead puts a ceiling on how many can park at once. The circuit breaker notices when a dependency is failing so hard that you should stop calling it entirely and fail fast instead. The full circuit-breaker mechanism β€” the states, the probing, the recovery β€” is its own topic; you can watch it run in the circuit breaker simulator (/circuitbreaker/sim). For this page, the one thing to hold onto is that the bulkhead is the concurrency cap in that stack.

Two ways to build a compartment

"Give each dependency a bounded slice" can mean two different things, and the difference matters. The compartment can be a semaphore or a dedicated thread pool.

A semaphore is just a counter with a cap. Before a call to recommendations, you try to take a permit; if the count is already at its limit (say 20), you're rejected on the spot. When the call returns, you give the permit back. The call still runs on your own worker thread β€” the semaphore only counts how many are in flight and slams the door once the limit is hit. It's cheap: a single counter, no extra threads.

A dedicated thread pool is a real, separate set of threads that belong only to recommendations. Your worker thread hands the call off to that pool and waits for the result. Because the call now runs on a different thread, your worker can give up on it β€” if it takes too long, your thread walks away and returns an error while the pooled thread is left to finish or time out on its own. That hand-off is what lets a thread pool enforce a timeout and truly wall the slowness off from your own threads. The cost is that every one of those threads uses memory and adds scheduling overhead, so you can't have unlimited pools.

Semaphore isolationThread-pool isolation
Where the call runsOn your own worker threadOn a separate, dedicated thread
CostAlmost nothing β€” one counterMemory + scheduling per thread
Can it time out and walk away?No β€” your thread still waits for the callYes β€” your thread abandons a slow call
Best forFast, in-process or very high-volume callsNetwork calls that can hang
The rule of thumbUse a thread pool for network calls, because those are the ones that hang, and the walk-away is exactly what you want. Reach for a semaphore when the call is so high-volume that a thread hand-off per call is too expensive, or when the work is in-process and can't really hang. Netflix's Hystrix made thread-pool isolation its default for this reason and reserved semaphores for high-throughput non-network calls; resilience4j ships both as two implementations in one bulkhead module and leaves the choice to you.
How big should each compartment be?

A compartment that's too small rejects healthy traffic; too big and it can't protect the pool, because a slow dependency is allowed to hold too many threads before it's capped. The right size comes straight out of Little's Law, which says the number of calls in flight equals the arrival rate times how long each call takes.

The sizing number, computedRecommendations gets 50 requests a second, and on a healthy day each takes 20 ms (0.02 s). Little's Law: concurrency = 50 Γ— 0.02 = 1 call in flight at a time. Even with generous headroom for normal jitter and short spikes, a budget of 5–10 threads covers it comfortably. So set the compartment to, say, 10 β€” not 100. Now when the service slows to 2 seconds, it fills those 10 and the 11th call is rejected instantly. Contrast the shared-pool case: at 2 s it wanted 50 Γ— 2 = 100 threads, which is exactly what emptied the pool. The small compartment turns that 100-thread grab into a capped 10.

That's the core insight: size the compartment for the dependency's healthy load plus a little headroom, not for its worst case. The whole point is that the worst case gets rejected at the wall instead of served. A rejected call is cheap and instant; a call that grabs a thread and hangs is the expensive thing you're trying to prevent.

PredictA dependency normally gets 200 requests/second and answers in 50 ms. Roughly what concurrency does it need on a healthy day, and why would you set the bulkhead only a bit above that rather than at, say, 200?

Hint: Little's Law first (rate Γ— latency). Then ask what a big budget lets a slow dependency do.

Little's Law: 200 requests/s Γ— 0.05 s = 10 calls in flight at once on a healthy day. So the real need is about 10; a budget around 15–20 gives headroom for jitter and short bursts. Setting it to 200 defeats the bulkhead β€” if the dependency slows to 1 second, 200 requests/s Γ— 1 s = 200 concurrent calls, and a budget of 200 would happily let all of them grab threads, which is the pool-draining you were trying to prevent. A tight budget sized to healthy load means the slow case hits the wall and gets rejected fast, instead of being allowed to consume the whole pool. Size for health; reject the worst case at the wall.

Per-tenant compartments: the noisy-neighbor versionThe same move works one level up. On a shared, multi-tenant system, one heavy customer can hog the shared pool and slow everyone β€” the noisy-neighbor problem. Give each tenant (or each tier of tenants) its own bounded compartment and one tenant's spike can only fill its own budget, not starve the rest. This is why big platforms partition capacity by tenant, region, or cell: the blast radius of any one problem is capped to a single compartment instead of the whole fleet.
What isolation costs you

Bulkheads aren't free, and the costs are real enough that you isolate deliberately, not everywhere.

You gainYou pay
Contained blast radius: a slow dependency can only fill its own compartment, never the shared pool.Wasted capacity: threads reserved for one dependency sit idle when it's quiet, even if another is starving. Partitioning fragments the pool.
Fail fast: past the cap, calls are rejected instantly instead of piling up on a doomed dependency.More rejections: a compartment sized too tight rejects healthy traffic during a legitimate burst it could have absorbed.
Isolation of latency: one dependency's slowness stays contained instead of spreading to unrelated requests.Tuning burden: every compartment is another size to pick and re-check against real traffic and latency.
A clean place to degrade: a full compartment is a natural hook for a fallback (a cached value, a default, a trimmed page).Overhead: thread-pool isolation adds a thread hand-off and memory per pool; many small pools add up.

The honest trade is contained failure versus fragmented capacity. Ten separate compartments of 20 threads can't lend each other a thread the way one pool of 200 can β€” so on an ordinary day a bulkheaded system uses its threads a little less efficiently. You accept that small everyday inefficiency to buy the guarantee that no single dependency can ever take the whole thing down. That's usually a good deal for anything user-facing, which is why you isolate the calls that can hurt you and don't bother wrapping trivial in-process work.

Where bulkheads run in the wild
  • Netflix Hystrix β€” the library that popularized the pattern at scale. Every dependency call ran as a command on its own bulkheaded thread pool with its own circuit breaker, so a slow dependency could exhaust only its pool. Netflix put Hystrix into maintenance mode in 2018 in favor of lighter, adaptive concurrency limiting, but it's where most people first met the pattern.
  • resilience4j β€” the de facto JVM successor. Its bulkhead module ships two implementations: a SemaphoreBulkhead (a counting semaphore with a maxConcurrentCalls cap) and a FixedThreadPoolBulkhead (a bounded thread pool with a queue), alongside separate Retry, RateLimiter, CircuitBreaker, and TimeLimiter modules you compose together.
  • Connection pools are bulkheads you already use. Giving each database or downstream service its own bounded connection pool means one slow database can exhaust only its own connections, not every connection the app has β€” the same partition, applied to sockets instead of threads.
  • Service meshes (Envoy, Istio) enforce per-upstream concurrency limits at the sidecar proxy, so the cap lives outside the app code and works across languages. The proxy rejects calls to an over-subscribed upstream before they ever reach a worker.
  • Cell-based and per-tenant architectures (AWS and others) take the idea to the top level: partition the whole fleet into independent cells or per-tenant shards, so any single failure β€” a bad deploy, a hot tenant, a poisoned cache β€” is contained to one cell instead of the entire service.
Common misconceptions & gotchas
Isn't a bulkhead just rate limiting?

No β€” they bound different things. A rate limiter caps how many calls per second are allowed (a rate over time); a bulkhead caps how many calls are in flight at once (concurrency, at any instant). A dependency can be within its rate limit and still tie up every thread if each call is slow, because it's the concurrency, not the rate, that drains the pool. They compose: the rate limiter throttles inbound volume, the bulkhead caps in-flight concurrency. You can see the rate-limiting side in the rate limiter simulator (/ratelimit/sim).

Does a bulkhead need a circuit breaker, or the other way around?

They solve different halves of the same problem and are best together. The bulkhead caps how much of your resource a slow dependency can consume, so it protects you even while you're still trying. The circuit breaker decides to stop trying once the dependency is clearly down, so calls fail fast instead of waiting for a timeout at all. Bulkhead alone still lets its (capped) share of calls hang; breaker alone still lets calls through during the window before it trips. Use both, plus a timeout on each call.

Why reject the extra calls instead of queuing them?

A short queue is fine and thread-pool bulkheads usually have one. But an unbounded queue just moves the problem: calls that would have been rejected instantly now sit in the queue growing stale, and by the time one is picked up the caller may have already given up. Rejecting fast once the compartment and its small queue are full is the point β€” a fast rejection lets the caller fall back or fail cleanly, which is far better than a slow success nobody's waiting for anymore. This is load shedding, and you can watch a bounded queue shed its overflow in the backpressure simulator (/backpressure/sim).

Should I put every dependency in its own bulkhead?

No β€” isolate the calls that can actually hurt you. Network calls to other services, databases, and third-party APIs are the ones that hang and cascade, so those get compartments. Wrapping fast, in-process work in a thread-pool bulkhead just adds hand-off overhead for no protection. Isolate at the granularity of things that fail independently: usually one compartment per downstream dependency.

QuizYour service shares one thread pool across three downstream calls. One of them, a recommendations API, occasionally slows to several seconds and takes the whole service down with it, even though the other two are healthy. What's the smallest change that stops the outage?

  1. Add more threads to the shared pool
  2. Give the recommendations call its own bounded compartment (thread pool or semaphore) sized to its healthy load, so it can't drain the shared pool
  3. Add a rate limiter capping recommendations to N requests per second
  4. Retry the recommendations call when it's slow
Show answer

Give the recommendations call its own bounded compartment (thread pool or semaphore) sized to its healthy load, so it can't drain the shared pool β€” This is the exact failure the bulkhead prevents: a shared pool one slow dependency can drain to zero. Giving recommendations its own compartment, sized to its healthy concurrency (Little's Law: rate Γ— latency, plus headroom), caps how many threads it can ever hold β€” the slow case fills its own budget and gets rejected fast, while the other two calls keep their threads and the service stays up. Adding threads just delays the same drain. A rate limiter caps calls-per-second but not in-flight concurrency, so slow calls can still tie up every thread within the limit. Retrying makes it worse β€” more load on an already-struggling dependency.

In an interview

Lead with the failure, not the definition. The thing a bulkhead prevents is a shared resource pool being drained to zero by one slow dependency, which turns a partial failure into a total outage. If you can describe that cascade β€” slow dependency holds threads, pool empties, healthy calls starve β€” you've shown you understand why the pattern exists.

Then name the fix in one line: partition the resource so each dependency gets its own bounded compartment, and size it to healthy load with Little's Law (rate Γ— latency) so the worst case is rejected at the wall. Being able to produce that number on the spot is the trap most people miss β€” they say "add a thread pool" but can't say how big or why.

Score the rest by placing it in the resilience stack: a bulkhead caps concurrency, a timeout bounds each wait, a circuit breaker stops trying β€” three patterns answering three different questions. Mention the two implementations (semaphore vs thread pool, and that only the thread pool can time out and walk away) and name a real system (Hystrix's per-dependency thread pools, resilience4j's two bulkhead implementations, or a per-database connection pool). If it comes up, extend it upward: per-tenant compartments contain a noisy neighbor, and cell-based architecture is the same move at fleet scale.

References & further reading
References

Feedback on this topic β†’