Skip to content

Bulkhead ​

The bulkhead pattern (inspired by Polly's BulkheadPolicy and Resilience4j's Bulkhead) limits the number of concurrent calls to a service, preventing one slow dependency from monopolising all resources.


The problem ​

Without isolation, a single slow or failing dependency can consume all concurrency and block unrelated services:

Without bulkhead:
  slow-api: 50 concurrent calls (all waiting 30s)
  fast-api: 0 slots left ← blocked by slow-api

With bulkhead (maxConcurrent: 5):
  slow-api: max 5 calls at a time
  fast-api: completely unaffected ✓

Basic usage ​

typescript
client.bulkhead({ maxConcurrent: 20 })

Requests beyond maxConcurrent queue — maxQueue defaults to 50 — and wait up to queueTimeoutMs (default 10 s) for a slot. Set maxQueue: 0 to reject immediately instead.


With queue ​

typescript
client.bulkhead({
  maxConcurrent: 20,    // max in-flight at any moment
  maxQueue: 100,        // queue up to 100 excess requests
  queueTimeoutMs: 3_000 // reject queued requests after 3 s
})

When a slot frees up, the oldest queued request is promoted.


Configuration ​

OptionTypeDefaultDescription
maxConcurrentnumber—Max in-flight requests. Must be ≥ 1
maxQueuenumber50Max requests in the waiting queue
queueTimeoutMsnumber10000Reject queued request after this many ms. Pass Infinity to wait indefinitely

Changed in 2.0

queueTimeoutMs used to default to undefined, which meant waiting forever. So bulkhead({ maxConcurrent: 20 }) gave you 50 queue slots that could block a caller indefinitely — a blocked thread by another name, and the most common way a healthy service is taken down by a sick dependency. An unbounded wait is now opt-in via Infinity.

maxConcurrent must be at least 1

maxConcurrent: 0 used to be accepted and then deadlock every request with no error at all. It now throws at the call that sets it.


Error types ​

ErrorWhen
Error('Bulkhead queue full')active >= maxConcurrent AND queue.length >= maxQueue
Error('Bulkhead queue timeout')Queued request waited longer than queueTimeoutMs, or ran out of deadline budget

Neither is retried: re-queueing a shed request is exactly the load the bulkhead exists to refuse.


Deadlines and cancellation ​

A queued request also gives up when the call's deadline runs out or its AbortSignal fires, and is removed from the queue when it does:

typescript
const controller = new AbortController()
client.bulkhead({ maxConcurrent: 5, queueTimeoutMs: 30_000 })

const pending = client.request({ url: '/report', policy: { signal: controller.signal } })
controller.abort()  // leaves the queue immediately, does not hold a slot

client.state().bulkhead reports live active and queued counts.


Observability ​

typescript
client
  .on({ onBulkheadReject: ({ active, queued }) =>
    metrics.increment('bulkhead.rejected', { active, queued })
  })
  .bulkhead({ maxConcurrent: 20, maxQueue: 100 })

Sizing guide ​

Service typemaxConcurrentmaxQueue
Critical, fast API50–100200
Non-critical, slow API5–1020
Background jobs2–510
High-throughput microservice100–200500

TIP

Start conservative and tune up. It's better to queue than to let one service take down others.

Released under the MIT License.