Skip to content
Buy me a coffee

Concurrency limiter (bulkhead)

A concurrency limiter (a bulkhead) caps how many executions run at the same time. A few more can wait in a queue; everything beyond that is rejected at once. Use it to stop one slow dependency from taking all your sockets, DB connections or memory, and to shed load before it piles up. It does not limit calls per second: a fast dependency can still get thousands of calls a second. For that, use a rate limiter. If you don’t know the right number, use the adaptive concurrency limiter.

import {
class ResiliencePipelineBuilder<T = any, E = never, C extends string = never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
} from '@ylcnfrht/resilience';
const
const pipeline: ResiliencePipeline<any, BulkheadRejectedError>
pipeline
= new
new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
()
.
ResiliencePipelineBuilder<any, never, never>.addConcurrencyLimiter(options: number | ConcurrencyLimiterOptions, queueLimit?: number): Next<any, never, never, BulkheadRejectedError>

addConcurrencyLimiter(10), addConcurrencyLimiter(10, 50) or full options.

addConcurrencyLimiter
({
ConcurrencyLimiterOptions.permitLimit: number

Max executions running at once.

permitLimit
: 10,
ConcurrencyLimiterOptions.queueLimit?: number | undefined

Max executions waiting (FIFO) for a permit. Default 0.

queueLimit
: 50,
ConcurrencyLimiterOptions.queueTimeout?: Duration | undefined

Longest a queued execution waits for a permit before it is rejected. Default: no limit.

queueTimeout
: '2s' })
.
ResiliencePipelineBuilder<any, BulkheadRejectedError, never>.build(): ResiliencePipeline<any, BulkheadRejectedError>
build
();
const
const res: Response
res
= await
const pipeline: ResiliencePipeline<any, BulkheadRejectedError>
pipeline
.
ResiliencePipeline<any, BulkheadRejectedError>.execute<Response>(callback: (context: ResilienceContext) => MaybePromise<Response>, options?: ExecuteOptions): Promise<Response>

Runs the callback through the pipeline; throws the final error.

execute
(({
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
}) =>
function fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> (+1 overload)
fetch
('https://api.example.com/report', {
RequestInit.signal?: AbortSignal | null | undefined

An AbortSignal to set request's signal.

signal
}));

Shorthands: addConcurrencyLimiter(10) (10 running, no queue) and addConcurrencyLimiter(10, 50) (10 running, 50 queued). The same works for concurrencyLimiterStrategy(10, 50).

Running 0/3

  1. free
  2. free
  3. free

Queue 0/3

  1. empty
  2. empty
  3. empty
  • completed
  • rejected · full
  • rejected · queue-timeout
  • rejected · dropped

0 completed, 0 rejected: 0 full, 0 queue timeout, 0 dropped.

completed0
rejected · full0
rejected · queue-timeout0
rejected · dropped0
addConcurrencyLimiter({ permitLimit: 3, queueLimit: 3, queueTimeout: '1s' })
  1. A free permit (active < permitLimit): the execution runs right away.
  2. No free permit, room in the queue (queued < queueLimit): it waits. The queue is ordered by priority (lower runs first), FIFO within one priority.
  3. Queue full: the overflow policy decides who is rejected: the newcomer, or someone already waiting.
  4. When an execution finishes, its permit goes straight to the head of the queue. A waiter is never overtaken by a newcomer.

A waiter leaves the queue when its queueTimeout expires (rejected, reason 'queue-timeout') or when its signal aborts (fails with the abort reason, not a rejection).

The default queueLimit is 0. Waiting rarely helps an overloaded service: callers usually prefer a fast 503 to a slow one. Add a queue to absorb short bursts, and give it a queueTimeout so nobody waits longer than the caller would.

overflow When the queue is full
'reject-new' (default) The newcomer is rejected (reason: 'full').
'drop-oldest' The longest-waiting execution is rejected (reason: 'dropped') and the newcomer takes its place. Good when stale requests are worthless (the client has likely given up).
'drop-lowest-priority' The newest waiter of the lowest priority is dropped, only if the newcomer outranks it (a strictly lower priority number). Otherwise the newcomer is rejected.
import {
class ResiliencePipelineBuilder<T = any, E = never, C extends string = never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
} from '@ylcnfrht/resilience';
// Paying customers jump the queue; under pressure, free-tier waiters are dropped first.
const
const pipeline: ResiliencePipeline<any, BulkheadRejectedError>
pipeline
= new
new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
()
.
ResiliencePipelineBuilder<any, never, never>.addConcurrencyLimiter(options: number | ConcurrencyLimiterOptions, queueLimit?: number): Next<any, never, never, BulkheadRejectedError>

addConcurrencyLimiter(10), addConcurrencyLimiter(10, 50) or full options.

addConcurrencyLimiter
({
ConcurrencyLimiterOptions.permitLimit: number

Max executions running at once.

permitLimit
: 20,
ConcurrencyLimiterOptions.queueLimit?: number | undefined

Max executions waiting (FIFO) for a permit. Default 0.

queueLimit
: 100,
ConcurrencyLimiterOptions.queueTimeout?: Duration | undefined

Longest a queued execution waits for a permit before it is rejected. Default: no limit.

queueTimeout
: '1s',
ConcurrencyLimiterOptions.priority?: ((context: ResilienceContext) => number) | undefined

Queue order: lower runs sooner (like a Unix nice value); FIFO within a priority. Default 0 for everyone.

priority
: (
ctx: ResilienceContext
ctx
) => (
ctx: ResilienceContext
ctx
.
ResilienceContext.properties: Map<string, unknown>

Shared bag for passing data between strategies and the callback.

properties
.
Map<string, unknown>.get(key: string): unknown

Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.

@returns ― Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.

get
('tier') === 'paid' ? 0 : 10),
ConcurrencyLimiterOptions.overflow?: "reject-new" | "drop-oldest" | "drop-lowest-priority" | undefined

When the queue is full: 'reject-new' (default) rejects the newcomer; 'drop-oldest' rejects the longest-waiting execution instead; 'drop-lowest-priority' rejects the newest of the lowest priority, if the newcomer outranks it. Dropped executions reject with BulkheadRejectedError reason 'dropped'.

overflow
: 'drop-lowest-priority',
})
.
ResiliencePipelineBuilder<any, BulkheadRejectedError, never>.build(): ResiliencePipeline<any, BulkheadRejectedError>
build
();
await
const pipeline: ResiliencePipeline<any, BulkheadRejectedError>
pipeline
.
ResiliencePipeline<any, BulkheadRejectedError>.execute<string>(callback: (context: ResilienceContext) => MaybePromise<string>, options?: ExecuteOptions): Promise<string>

Runs the callback through the pipeline; throws the final error.

execute
(async () => 'ok', {
ExecuteOptions.properties?: Map<string, unknown> | undefined
properties
: new
var Map: MapConstructor
new <string, string>(iterable?: Iterable<readonly [string, string]> | null | undefined) => Map<string, string> (+3 overloads)
Map
([['tier', 'paid']]) });
Option Type Default Description
permitLimit number required Max executions running at once. Integer ≥ 1.
queueLimit number 0 Max executions waiting for a permit.
queueTimeout Duration no limit Longest a queued execution waits before it is rejected with reason: 'queue-timeout'. Must be > 0.
priority (context) => number 0 for all Queue order: lower runs sooner (like a Unix nice value), FIFO within a priority. Only called when the execution has to queue. If it throws, the execution fails with that error.
overflow 'reject-new' | 'drop-oldest' | 'drop-lowest-priority' 'reject-new' Who is rejected when the queue is full (see above).
onRejected (args: { context, retryAfter }) => void | Promise<void> – Called on every rejection. retryAfter is always undefined here.
name string 'ConcurrencyLimiter' Strategy name, for telemetry and getStrategy().

Duration is milliseconds or a string such as '500ms', '2s', '1m'.

Every rejection is a BulkheadRejectedError:

Field Value
code 'BULKHEAD_FULL'
reason 'full' (no permit, no queue room), 'queue-timeout' (waited queueTimeout), 'dropped' (pushed out by overflow)
active, queued Running and waiting executions at the moment of rejection
retryAfter undefined

It extends RateLimiterRejectedError, so isRateLimitError() and isRejection() match it. The default retry predicate does not retry rejections; the default fallback predicate does handle them. The /http helpers map it to 503, not 429: a full bulkhead is a capacity problem on your side, not the client sending too fast. See error codes.

try {
await
const pipeline: ResiliencePipeline<any, any>
pipeline
.
ResiliencePipeline<any, any>.execute<string>(callback: (context: ResilienceContext) => MaybePromise<string>, options?: ExecuteOptions): Promise<string>

Runs the callback through the pipeline; throws the final error.

execute
(({
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
}) =>
function generateReport(signal: AbortSignal): Promise<string>
generateReport
(
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
));
} catch (
var error: unknown
error
) {
if (
var error: unknown
error
instanceof
class BulkheadRejectedError

A concurrency limiter (bulkhead) is full: a capacity problem on our side, so HTTP maps it to 503, not 429.

BulkheadRejectedError
) {
var console: Console

The console module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers.

The module exports two specific components:

  • A Console class with methods such as console.log(), console.error() and console.warn() that can be used to write to any Node.js stream.
  • A global console instance configured to write to process.stdout and process.stderr. The global console can be used without importing the node:console module.

Warning: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the note on process I/O for more information.

Example using the global console:

console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err

@see ― source

console
.
Console.warn(message?: any, ...optionalParams: any[]): void (+1 overload)

The console.warn() function is an alias for

error

.

@since ― v0.1.100

warn
(`shed (${
var error: BulkheadRejectedError
error
.
BulkheadRejectedError.reason: "full" | "queue-timeout" | "dropped"

'full': no slot and no queue room; 'queue-timeout': waited queueTimeout without getting a slot; 'dropped': removed from the queue to make room (overflow: 'drop-oldest' | 'drop-lowest-priority').

reason
}): ${
var error: BulkheadRejectedError
error
.
BulkheadRejectedError.active: number
active
} running, ${
var error: BulkheadRejectedError
error
.
BulkheadRejectedError.queued: number
queued
} queued`);
}
}
Telemetry event Callback When
OnRateLimiterRejected onRejected An execution is rejected (any reason).

active and queued are readable on the strategy instance, e.g. for a health endpoint or a dashboard. ResiliencePipelineRegistry.limiters() lists every limiter of every registered pipeline.

import {
class ResiliencePipelineBuilder<T = any, E = never, C extends string = never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
,
class ConcurrencyLimiterStrategy<T>

Bulkhead: limits concurrent executions, queues a few, rejects the rest.

ConcurrencyLimiterStrategy
} from '@ylcnfrht/resilience';
const
const pipeline: ResiliencePipeline<any, BulkheadRejectedError>
pipeline
= new
new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
().
ResiliencePipelineBuilder<any, never, never>.addConcurrencyLimiter(options: number | ConcurrencyLimiterOptions, queueLimit?: number): Next<any, never, never, BulkheadRejectedError>

addConcurrencyLimiter(10), addConcurrencyLimiter(10, 50) or full options.

addConcurrencyLimiter
(10, 50).
ResiliencePipelineBuilder<any, BulkheadRejectedError, never>.build(): ResiliencePipeline<any, BulkheadRejectedError>
build
();
const
const limiter: ConcurrencyLimiterStrategy<unknown> | undefined
limiter
=
const pipeline: ResiliencePipeline<any, BulkheadRejectedError>
pipeline
.
ResiliencePipeline<any, BulkheadRejectedError>.getStrategy<ConcurrencyLimiterStrategy<unknown>>(nameOrKind: string): ConcurrencyLimiterStrategy<unknown> | undefined

The strategy instance named name (or of class kind), to read its live state: getStrategy<CircuitBreakerStrategy>('CircuitBreaker')?.state.

getStrategy
<
class ConcurrencyLimiterStrategy<T>

Bulkhead: limits concurrent executions, queues a few, rejects the rest.

ConcurrencyLimiterStrategy
<unknown>>('ConcurrencyLimiter');
var console: Console

The console module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers.

The module exports two specific components:

  • A Console class with methods such as console.log(), console.error() and console.warn() that can be used to write to any Node.js stream.
  • A global console instance configured to write to process.stdout and process.stderr. The global console can be used without importing the node:console module.

Warning: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the note on process I/O for more information.

Example using the global console:

console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err

@see ― source

console
.
Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)

Prints to stdout with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to printf(3) (the arguments are all passed to util.format()).

const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout

See util.format() for more information.

@since ― v0.1.100

log
(
const limiter: ConcurrencyLimiterStrategy<unknown> | undefined
limiter
?.
ConcurrencyLimiterStrategy<unknown>.active: number | undefined

Executions running now.

active
,
const limiter: ConcurrencyLimiterStrategy<unknown> | undefined
limiter
?.
ConcurrencyLimiterStrategy<unknown>.queued: number | undefined

Executions waiting for a permit.

queued
);
  • One limiter per dependency. A shared pipeline means a shared limit. Build one pipeline per downstream (or use a registry key per downstream) so a slow payments API can’t starve the inventory API.
  • Pair it with a timeout. Permits are released only when the call ends. A call that hangs forever holds its permit forever. Add a timeout inside the limiter.
  • Cancellation is free. A queued caller whose signal aborts leaves the queue at once and never takes a permit.
  • HTTP servers. The /http, Fastify and Koa middlewares have a concurrency option that does this for incoming requests: see load shedding.