Skip to content
Buy me a coffee

Load shedding

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:

  1. A deadline per request, propagated to every call made while handling it, so work for a request that has run out of time stops.
  2. A concurrency cap with a short queue: past it, reject at once.
  3. Priorities, so when something has to be rejected, it’s the least important traffic.

Each framework integration builds the same inbound pipeline: rate limit (optional) → concurrency limiter → timeout.

import express from 'express';
import { resilienceMiddleware } from '@ylcnfrht/resilience/http';
const app = express();
app.get('/healthz', (_req, res) => res.send('ok')); // before the middleware: never shed
app.use(
resilienceMiddleware({
timeout: '5s',
concurrency: { permitLimit: 200, queueLimit: 100, queueTimeout: '500ms' },
}),
);

What a request goes through:

Situation Response
Fewer than 200 requests in flight Runs at once.
200 in flight, fewer than 100 waiting Waits for a slot, at most 500 ms.
Queue full 503 at once (BULKHEAD_FULL, reason full).
Waited 500 ms without a slot 503 (BULKHEAD_FULL, reason queue-timeout).
Handler runs past 5 s 504 (TIMEOUT). Every pipeline called by the handler is aborted too.
Client disconnects The request’s signal aborts; nested calls are cancelled. No response is sent.

A full bulkhead is 503, not 429: it’s your capacity that ran out, not the client’s quota. The body is JSON ({ statusCode, error, message }).

  • 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:

const
const inventory: ResiliencePipeline<number, TimeoutRejectedError>
inventory
= new
new ResiliencePipelineBuilder<number, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<number, 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
<number>({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'inventory' })
.
ResiliencePipelineBuilder<number, never, never>.addRetry(options?: RetryOptions<number>): ResiliencePipelineBuilder<number, never, never>
addRetry
({
RetryOptions<number>.maxRetryAttempts?: number | undefined

Retries in addition to the original call. Default 3. Infinity retries until aborted.

maxRetryAttempts
: 3,
RetryOptions<number>.delay?: Duration | undefined

Base delay. Default 2000 ms.

delay
: '200ms' })
.
ResiliencePipelineBuilder<number, never, never>.addTimeout(options: Duration | TimeoutOptions): Next<number, never, never, TimeoutRejectedError>

addTimeout(5000), addTimeout('5s') or addTimeout({ timeout: '5s', onTimeout }).

addTimeout
('2s')
.
ResiliencePipelineBuilder<number, TimeoutRejectedError, never>.build(): ResiliencePipeline<number, TimeoutRejectedError>
build
();
// Called from a route handler: no signal passed in, yet a request that hits its 5 s
// deadline also stops this call and its retries.
export const
const stockOf: (sku: string) => Promise<number>
stockOf
= (
sku: string
sku
: string) =>
const inventory: ResiliencePipeline<number, TimeoutRejectedError>
inventory
.
ResiliencePipeline<number, TimeoutRejectedError>.execute<number>(callback: (context: ResilienceContext) => MaybePromise<number>, options?: ExecuteOptions): Promise<number>

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 queryInventory(sku: string, signal: AbortSignal): Promise<number>
queryInventory
(
sku: string
sku
,
signal: AbortSignal

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

signal
));
// In plain handler code, read the request's signal directly (undefined outside a request):
export async function
function audit(entry: string): Promise<void>
audit
(
entry: string
entry
: string) {
await
function fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> (+1 overload)
fetch
('https://audit.internal/log', {
RequestInit.method?: string | undefined

A string to set request's method.

method
: 'POST',
RequestInit.body?: BodyInit | null | undefined

A BodyInit object or null to set request's body.

body
:
entry: string
entry
,
RequestInit.signal?: AbortSignal | null | undefined

An AbortSignal to set request's signal.

signal
:
function getResilienceSignal(): AbortSignal | undefined

Shortcut for getResilienceContext()?.signal: pass it to fetch / your DB driver from deep inside a call.

getResilienceSignal
() });
}

See context and signals for how the propagation works.

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:

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
, type
(alias) interface ResilienceContext
import ResilienceContext
ResilienceContext
} from '@ylcnfrht/resilience';
import {
const HTTP_REQUEST: "@ylcnfrht/resilience.httpRequest"

context.properties key of the inbound request (Express req / Hono c), for your own partitioned limiters.

HTTP_REQUEST
} from '@ylcnfrht/resilience/http';
type
type Req = {
method: string;
path: string;
headers: Record<string, string | undefined>;
}
Req
= {
method: string
method
: string;
path: string
path
: string;
headers: Record<string, string | undefined>
headers
:
type Record<K extends keyof any, T> = { [P in K]: T; }

Construct a type with a set of properties K of type T

Record
<string, string | undefined> };
const
const req: (ctx: ResilienceContext) => Req
req
= (
ctx: ResilienceContext
ctx
:
(alias) interface ResilienceContext
import ResilienceContext
ResilienceContext
) =>
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
(
const HTTP_REQUEST: "@ylcnfrht/resilience.httpRequest"

context.properties key of the inbound request (Express req / Hono c), for your own partitioned limiters.

HTTP_REQUEST
) as
type Req = {
method: string;
path: string;
headers: Record<string, string | undefined>;
}
Req
;
/** Lower runs sooner. */
function
function priority(ctx: ResilienceContext): number

Lower runs sooner.

priority
(
ctx: ResilienceContext
ctx
:
(alias) interface ResilienceContext
import ResilienceContext
ResilienceContext
): number {
const
const r: Req
r
=
const req: (ctx: ResilienceContext) => Req
req
(
ctx: ResilienceContext
ctx
);
if (
const r: Req
r
.
path: string
path
.
String.startsWith(searchString: string, position?: number): boolean

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.

startsWith
('/checkout')) return 0; // revenue
if (
const r: Req
r
.
headers: Record<string, string | undefined>
headers
['x-plan'] === 'paid') return 1;
if (
const r: Req
r
.
method: string
method
=== 'GET') return 5;
return 10; // background syncs, batch exports
}
export const
const inbound: ResiliencePipeline<any, WithErrors<BulkheadRejectedError, never, TimeoutRejectedError>>
inbound
= 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
({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'inbound' })
.
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
: 200,
ConcurrencyLimiterOptions.queueLimit?: number | undefined

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

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

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

queueTimeout
: '500ms',
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
,
// Queue full: a checkout request pushes out the newest queued request of the lowest priority.
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>.addTimeout(options: Duration | TimeoutOptions): Next<any, BulkheadRejectedError, never, TimeoutRejectedError>

addTimeout(5000), addTimeout('5s') or addTimeout({ timeout: '5s', onTimeout }).

addTimeout
('5s')
.
ResiliencePipelineBuilder<any, WithErrors<BulkheadRejectedError, never, TimeoutRejectedError>, never>.build(): ResiliencePipeline<any, WithErrors<BulkheadRejectedError, never, TimeoutRejectedError>>
build
();
// app.use(resilienceMiddleware({ pipeline: inbound }))

How it behaves:

  • 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.

export const
const limit: AdaptiveLimitProvider
limit
= new
new AdaptiveLimitProvider(): AdaptiveLimitProvider

Reads the live limit of one adaptive limiter (health checks, dashboards).

AdaptiveLimitProvider
();
export const
const inbound: ResiliencePipeline<any, WithErrors<BulkheadRejectedError, never, TimeoutRejectedError>>
inbound
= 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
({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'inbound' })
.
ResiliencePipelineBuilder<any, never, never>.addAdaptiveConcurrencyLimiter(options?: AdaptiveConcurrencyLimiterOptions<any>): Next<any, never, never, BulkheadRejectedError>

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.

addAdaptiveConcurrencyLimiter
({
AdaptiveConcurrencyLimiterOptions<any>.algorithm?: "vegas" | "gradient2" | "aimd" | LimitAlgorithm | undefined

Default 'gradient2'. Pass an object for custom tuning (vegas({ alpha: 2 })) or your own algorithm.

algorithm
: 'gradient2',
AdaptiveConcurrencyLimiterOptions<any>.initialLimit?: number | undefined

Default 20.

initialLimit
: 50,
AdaptiveConcurrencyLimiterOptions<any>.minLimit?: number | undefined

Default 1.

minLimit
: 10, // never shed down to nothing
AdaptiveConcurrencyLimiterOptions<any>.maxLimit?: number | undefined

Default 1000.

maxLimit
: 500,
AdaptiveConcurrencyLimiterOptions<any>.queueLimit?: number | undefined

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

queueLimit
: 50,
AdaptiveConcurrencyLimiterOptions<any>.queueTimeout?: Duration | undefined

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

queueTimeout
: '300ms',
AdaptiveConcurrencyLimiterOptions<any>.limitProvider?: AdaptiveLimitProvider | undefined

Read limit / inflight / queued from outside.

limitProvider
:
const limit: AdaptiveLimitProvider
limit
,
})
.
ResiliencePipelineBuilder<any, BulkheadRejectedError, never>.addTimeout(options: Duration | TimeoutOptions): Next<any, BulkheadRejectedError, never, TimeoutRejectedError>

addTimeout(5000), addTimeout('5s') or addTimeout({ timeout: '5s', onTimeout }).

addTimeout
('5s')
.
ResiliencePipelineBuilder<any, WithErrors<BulkheadRejectedError, never, TimeoutRejectedError>, never>.build(): ResiliencePipeline<any, WithErrors<BulkheadRejectedError, never, TimeoutRejectedError>>
build
();
// app.use(resilienceMiddleware({ pipeline: inbound }))
// 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:

const
const recommendations: ResiliencePipeline<string[], never>
recommendations
= new
new ResiliencePipelineBuilder<string[], never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<string[], 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
<string[]>({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'recommendations' })
.
ResiliencePipelineBuilder<string[], never, never>.addFallback(options: FallbackOptions<string[]> & {
shouldHandle?: undefined;
}): ResiliencePipelineBuilder<string[], never, string> (+1 overload)

Without shouldHandle it catches every failure of the strategies added after it, so their errors leave the error type.

addFallback
({
FallbackOptions<string[]>.fallbackAction?: ((args: OutcomeArgs<string[]>) => MaybePromise<string[]>) | undefined

Produces the substitute value; throwing here makes the execution fail with that error. Give either this or fallbackValue.

fallbackAction
: () => [] }) // shed = empty list, the page still renders
.
ResiliencePipelineBuilder<string[], never, string>.addConcurrencyLimiter(options: number | ConcurrencyLimiterOptions, queueLimit?: number): Next<string[], never, string, 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
: 0 })
.
ResiliencePipelineBuilder<string[], never, string>.addTimeout(options: Duration | TimeoutOptions): Next<string[], never, string, TimeoutRejectedError>

addTimeout(5000), addTimeout('5s') or addTimeout({ timeout: '5s', onTimeout }).

addTimeout
('300ms')
.
ResiliencePipelineBuilder<string[], never, string>.build(): ResiliencePipeline<string[], never>
build
();

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.

app.use(
resilienceMiddleware({
timeout: '5s',
concurrency: { permitLimit: 200, queueLimit: 100, queueTimeout: '500ms' },
rateLimit: { key: (req) => req.get('x-api-key') ?? req.ip, permitLimit: 600, window: '1m' },
}),
);
  • Inbound timeout below your callers’ timeout; signals passed down so shed requests stop working.
  • Concurrency cap from a load test, short queue, queueTimeout well under the budget.
  • Health checks and readiness probes registered before the middleware.
  • Priorities (priority, overflow) when some traffic matters more; adaptive limits when one number can’t fit.
  • A bulkhead plus fallback for each optional dependency.
  • Alert on OnRateLimiterRejected (bulkhead rejections share this event) and on the 503 rate.