The problem. A service that can handle 500 requests a second gets 800. Nothing rejects the extra 300, so they queue:
in the event loop, in the connection pool, in the dependency. Latency climbs past the clients’ timeouts, clients give up and
retry, and the server spends its time on requests nobody is waiting for any more. Throughput of useful responses drops
to near zero while the CPU is at 100%. That is congestion collapse, and it outlasts the spike that caused it.
Load shedding turns the extra 300 into fast 503s, so the other 500 still get answered in time. Clients see a few
quick errors they can retry elsewhere, instead of everyone timing out.
Three tools, all on the inbound side:
A deadline per request, propagated to every call made while handling it, so work for a request that has run out of time stops.
A concurrency cap with a short queue: past it, reject at once.
Priorities, so when something has to be rejected, it’s the least important traffic.
permitLimit: the concurrency at which latency starts to climb. Measure it with a load test: raise the
load until p99 latency bends upward, and take the in-flight count at that point. Little’s law gives a first guess:
target throughput × typical latency (500 req/s × 0.2 s = 100).
queueLimit: a short buffer for bursts, not a place to wait out an overload. One to two times permitLimit at most.
queueTimeout: well under the request budget. A request that waited 4 s of a 5 s budget has 1 s left to do 5 s of
work; reject it while the client can still retry somewhere else.
timeout: below the timeout of whoever calls you (load balancer, upstream service), so your 504 arrives
before they give up and retry.
The inbound timeout isn’t only a stopwatch. Every pipeline executed while handling the request runs inside its context
and inherits its signal, so when the 5 s are up, the database query, the HTTP call and the retry delay underneath are all
aborted, without passing a signal through every function:
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().
The built-in concurrency option treats every request the same. For priorities, build the inbound pipeline yourself and
pass it as pipeline. The request is in context.properties under HTTP_REQUEST:
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().
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.
Returns true if the sequence of elements of searchString converted to a String is the
same as the corresponding elements of this object (converted to a String) starting at
position. Otherwise returns false.
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().
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'.
The queue is ordered by priority, then arrival. When a slot frees, the highest-priority waiter gets it.
With overflow: 'drop-lowest-priority', a newcomer that finds the queue full evicts the newest waiter of the lowest
priority, if that waiter ranks below it. The evicted request gets 503 with reason dropped. If nothing ranks
below the newcomer, the newcomer is rejected.
overflow: 'drop-oldest' is the other option: under overload the oldest waiter is the one least likely to make its
deadline, so drop it and serve the fresh one.
Requests already running are never interrupted to make room; priorities only decide who waits and who is rejected.
HTTP_REQUEST holds Express’s req, Hono’s c, Fastify’s request or Koa’s ctx, so read the fields that framework has.
A fixed permitLimit is right for one deployment, one instance size and one traffic mix. The
adaptive concurrency limiter finds the limit at runtime: it raises it while latency
stays flat and lowers it as soon as latency climbs or requests time out.
exportconst
constlimit:AdaptiveLimitProvider
limit=new
newAdaptiveLimitProvider(): AdaptiveLimitProvider
Reads the live limit of one adaptive limiter (health checks, dashboards).
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().
Concurrency limit that tunes itself from observed latency and failures (gradient2 / vegas / aimd),
so you don't have to guess the right number: it grows while the dependency keeps up and backs off when it queues.
// Expose limit.limit, limit.inflight and limit.queued on a metrics or debug endpoint.
Things to know before you put it in front of a whole service:
It learns from latency. If routes differ wildly (a 5 ms cache hit and a 3 s report), the latency signal is noisy.
Give slow routes their own pipeline (app.use('/reports', resilienceMiddleware({ pipeline: reports }))) or exclude them.
Timeouts count as overload, client errors (4xx) and client disconnects don’t. That’s what you want inbound: a
handler that hit its deadline is a sign of saturation.
No priorities. The adaptive limiter has a single FIFO queue. If you need both, put a fixed concurrency limiter with
priorities in front of it, sized at the adaptive limiter’s maxLimit.
minLimit keeps the service from shedding everything after a burst of slow requests caused by a dependency, not by load.
The same tools protect you from a slow dependency eating every slot. Give each dependency its own bulkhead, so a
database that slows down can hold at most its share of the requests in flight, and the routes that don’t need it keep working:
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().
For an optional feature, a shed call answered by a fallback is the best outcome: the user gets the page without that part,
and the dependency gets breathing room.
The inbound rateLimit option is for fairness between callers, not for overload: a per-IP or per-API-key limit
with 429 and RateLimit-* headers. Use it together with the concurrency cap, not instead of it. A rate limit can’t
see that each request suddenly takes ten times longer; the concurrency cap can.