System designDeep dive10 min
Bulkheads
A ship's hull is divided into watertight compartments, so a hole floods one section instead of sinking the ship. Bulkheads do the same for software: they split shared resources into separate pools, so one slow dependency, one heavy customer or one bad deploy can only use up its own share.
1 · The problem
A shared pool lets the worst caller use up everyone's share
An appointment-booking service is used by 4,000 clinics and salons. Each server calls three outside services through one HTTP client with a pool of 50 connections: a payment provider for deposits, an SMS provider for reminders, and a calendar-sync service. Normally they need few connections at once (in flight = arrival rate × time per call, from Throughput, utilization & queueing):
Calls to | Normal load | Connections in use |
|---|---|---|
Payment provider | 40 a second × 0.25 s | 10 |
SMS provider | 30 a second × 0.2 s | 6 |
Calendar sync | 20 a second × 0.5 s | 10 |
Total | 26 of 50 |
Then the SMS provider slows to 10 seconds a message. Reminder calls now want 30 × 10 = 300 connections. They can't have 300, but within a couple of seconds they hold all 50, and every payment call queues behind them. Patients can't book, because of a reminder service nobody was watching.
The same thing happens one level up. A chain of 300 salons starts a bulk import of its old appointments. The import runs through the same servers and the same database connections as live bookings, and every other customer's booking page slows to a crawl. One heavy customer degrading everyone else is a noisy neighbor.
Both are the same failure: a shared resource with no limit per user. A bulkhead is that limit. It splits the resource into compartments, so one caller's trouble can fill its own compartment and no more. Unlike a circuit breaker, which stops calling a failing dependency (The circuit breaker pattern), a bulkhead caps how much of you any one thing can use, failing or not.
2 · In one process
Give each dependency its own small pool
Inside one server, the fix is a separate pool, or a separate concurrency limit, per dependency. Size each from its normal load with room for bursts:
The slow SMS provider fills its own 10 slots and nothing else. Payments and calendar sync keep their capacity, so bookings carry on.
When a compartment is full, the next call must not wait forever. The usual choices:
- Reject at once. Resilience4j's semaphore bulkhead, for example, allows 25 concurrent calls by default and waits 0 ms for a free slot before refusing.
- Wait briefly in a small bounded queue, then refuse.
- Defer the work. A refused reminder goes back on a queue to be sent later; nobody needs it within the second.
Two ways to build the walls:
- A semaphore counts calls in flight and refuses past the limit. It is cheap and works with any threading model.
- A thread pool per dependency runs the calls on its own fixed set of threads with a bounded queue, so the caller's threads are never the ones stuck waiting, at the cost of extra threads and a hand-off.
Bulkheads pair well with breakers. The bulkhead keeps a slow dependency from taking more than its share while the breaker decides it is failing; then the breaker stops the calls altogether.
3 · Between workloads
Separate the work that must not wait from the work that can
The bulk import is a different problem: not a slow dependency, but a big consumer competing for the same servers, queues and database. Here the walls go around kinds of work, so that the work that must stay fast gets capacity nobody else can take:
Live bookings and bulk jobs run on different servers and draw on separate shares of the database's connections. A huge import can only make the import queue longer.
Each layer has its own wall, and each wall has an ordinary tool:
Shared resource | The bulkhead | Example |
|---|---|---|
Outbound connections | A pool or concurrency limit per dependency | 10 SMS connections, 20 for payments |
CPU and memory | Separate processes or containers with resource limits | Import workers capped at 2 CPUs each |
Servers | Separate groups of instances per kind of consumer | Booking servers never run imports |
Queues | A queue and workers per kind of message | Reminders can back up without delaying payments |
Shared capacity per customer | Per-tenant quotas and rate limits | One customer's API traffic capped (Rate limiting algorithms) |
Prefer the limits your platform already enforces, such as container resource limits, database connection limits per user and gateway rate limits, over rebuilding them in application code.
4 · Cells
Cells split the whole system so a failure reaches only some customers
Pools protect against one dependency or one kind of work. But some failures hit everything a server does: a bad deploy, a corrupt configuration, a request that crashes any server that handles it (a poison pill). Against those, the largest bulkhead is a cell: a complete, independent copy of the system that serves only some of the customers.
Eight cells, each with its own servers, queues and database. A failure in one cell reaches 1 ÷ 8 = 12.5% of customers instead of all of them.
The design rules that make cells work:
- Partition by a key that most requests already carry, here the customer ID, so a request never needs two cells.
- Keep the router thin. It only decides which cell, because it is the one piece every request still shares.
- Share no state between cells, or the shared part becomes the new single point of failure.
- Deploy cell by cell. A bad release breaks the first cell it reaches and is rolled back before it reaches the rest.
Cells don't have to mean more hardware: 30 servers can become 6 cells of 5. What they cost is the work of routing, provisioning cells and moving customers between them. The scope of impact shrinks with the number of cells, so 20 cells means a 5% blast radius. Availability zones and regions are the same idea applied by the cloud provider (Redundancy & failure domains).
5 · Shuffle sharding
Random overlapping shards isolate customers far better than fixed ones
Cells give each customer one compartment shared with many others. Shuffle sharding does better with the same machines. Take 8 workers. Ordinary sharding splits them into 4 fixed shards of 2, and a customer whose requests break their workers takes down a quarter of everyone. Shuffle sharding instead gives every customer its own random pair of the 8, and there are 28 different pairs (8 × 7 ÷ 2).
Customers who lose every worker they have when one customer's workers fail: 1 ÷ 4 = 25%, 1 ÷ 28 ≈ 3.6% and 1 ÷ 70 ≈ 1.4%. Everyone else shares at most part of the bad shard.
Suppose one clinic's malformed requests crash both of its workers. Only customers with exactly the same pair, about 1 in 28, lose both. Everyone else shares at most one worker with it and still has a healthy one, as long as their clients retry on the other worker of their shard. Shuffle sharding only works with clients that retry like this.
The numbers grow fast. Amazon Route 53 spreads its DNS service over 2,048 virtual name servers and gives each customer domain 4 of them: about 730 billion possible shards, enough that no two customer domains share more than two name servers. A flood of traffic aimed at one domain overwhelms its four name servers, and every other domain keeps at least two that work.
6 · The catch
Every wall leaves capacity idle
A shared pool of 50 connections can lend any idle connection to whoever needs it. Pools of 20, 10 and 15 can't: on a normal day the payment pool may be full while the SMS pool sits idle. Bulkheads trade efficiency for isolation, and that trade has to be sized on purpose:
- Too small a compartment rejects ordinary bursts, so you have built an outage of your own.
- Too large, and it no longer protects anything.
- Too many walls make the system hard to size, monitor and reason about.
Mistake | What happens | Better |
|---|---|---|
Sizing pools by guesswork | Rejections at the first busy hour | Size from rate × duration at peak, plus headroom, and alert on rejections |
Walls around the servers, one shared database behind them | The database is still the hull with one compartment | Separate connection limits per workload, or per-cell databases |
Cells that share a configuration store or deploy at once | One bad push reaches every cell | Deploy and configure cell by cell |
Waiting forever for a free slot | The wait itself ties up the caller | Short waits and a clear rejection or fallback |
Watch every compartment separately. A bulkhead that is full or rejecting is either doing its job during an incident or too small in normal times; per-pool usage and rejection counts tell you which (Observability: metrics, logs & traces).
Check yourself
4 questions
Takeaways
Remember this
- A bulkhead splits a shared resource into compartments so one slow dependency, heavy customer or bad deploy can only exhaust its own share.
- Inside a process, give each dependency its own pool or concurrency limit, sized from rate × duration, and reject or defer when it is full.
- Separate work that must stay fast from bulk work: different servers, queues and shares of the database.
- Cells are whole-system bulkheads: a failure reaches only the customers of one cell, and deploys go cell by cell.
- Shuffle sharding gives each customer a random subset of workers, shrinking the share that loses everything from 1 in 4 to 1 in 28 with the same 8 workers.