Skip to content
Buy me a coffee

Hedging

Hedging cuts tail latency. If the first attempt hasn’t answered after a delay, it starts a second one without cancelling the first, and keeps whichever good result arrives first. The losers are aborted.

Use it for idempotent reads whose latency has a long tail: a replica that’s sometimes slow, a search query, a cache cluster. Don’t use it for writes, or when the extra load would hurt a struggling dependency; every hedge is another request. Unlike retry, it doesn’t wait for a failure before trying again.

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 '@nodelibraries/resilience';
const
const pipeline: ResiliencePipeline<string[], TimeoutRejectedError>
pipeline
= 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[]>()
.
ResiliencePipelineBuilder<string[], never, never>.addHedging(options?: HedgingOptions<string[]>): ResiliencePipelineBuilder<string[], never, never>
addHedging
({
HedgingOptions<string[]>.delay?: Duration | undefined

ms to wait before starting another attempt. Default 2000. > 0 latency mode, 0 parallel mode (all at once), < 0 fallback mode (next attempt only after a failure).

delay
: '150ms', // ≈ your p95: start a second attempt if the first is slower
HedgingOptions<string[]>.maxHedgedAttempts?: number | undefined

Hedged attempts in addition to the primary. Default 1.

maxHedgedAttempts
: 1, // 2 calls at most
})
.
ResiliencePipelineBuilder<string[], never, never>.addTimeout(options: Duration | TimeoutOptions): Next<string[], never, never, TimeoutRejectedError>

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

addTimeout
('1s') // per attempt
.
ResiliencePipelineBuilder<string[], TimeoutRejectedError, never>.build(): ResiliencePipeline<string[], TimeoutRejectedError>
build
();
const
const hits: string[]
hits
= await
const pipeline: ResiliencePipeline<string[], TimeoutRejectedError>
pipeline
.
ResiliencePipeline<string[], TimeoutRejectedError>.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 search(query: string, signal: AbortSignal): Promise<string[]>
search
('red shoes',
signal: AbortSignal

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

signal
));

Pass signal to your I/O. That’s how the losing attempts are cancelled.

The sign of delay picks the mode.

delay Mode Next attempt starts Use it for
> 0 (default 2000) Latency After delay without an answer, or at once after a handled failure Cutting tail latency.
0 Parallel Immediately: all attempts race Lowest latency at the highest cost.
< 0 Fallback Only after the previous attempt failed Trying another replica or endpoint in turn. Like retry without delays.
0 ms200 ms400 ms600 msno hedgingprimary aloneprimary alone: answers after 600 ms600 ms
winnerhandled failureaborted loserwithout hedging
  • no hedging: primary alone: answers after 600 ms 0 ms to 600 ms; lav at 600 ms

All attempts failed after 0 ms (without hedging: 600 ms), 0 requests sent.

hedgingStrategy({ delay: 150, maxHedgedAttempts: 1 })
  1. The primary attempt starts (context.attemptNumber 0).
  2. While fewer than 1 + maxHedgedAttempts attempts have started, the strategy waits up to the delay for any attempt to finish. If none does, it starts the next one and reports OnHedging.
  3. When an attempt finishes:
    • If shouldHandle doesn’t match it (a success, or an error that isn’t transient, such as a 404), it wins. The other attempts are aborted and the outcome is returned right away; hedging doesn’t wait for the losers to settle.
    • If it’s a handled failure, the next attempt starts at once (if any are left), and the others keep running.
  4. When every attempt has failed, the primary’s failure is returned.

The default shouldHandle is the same as retry’s: errors except 4xx (408/429 count), aborts and rejections. Results never trigger a hedge unless your predicate says so.

Each attempt gets its own:

  • signal, linked to the caller’s. Aborting the execution aborts all attempts; a winner aborts only the others.
  • attemptNumber: 0 for the primary, then 1, 2, … in the order attempts start. One counter covers the whole execution, so with a retry around hedging or inside the hedged attempts every attempt still gets a distinct number, safe for per-attempt idempotency keys. (onHedging, delayGenerator and actionGenerator receive hedging’s own count: 1 for the first hedge.)
  • copy of properties. Parallel attempts can’t overwrite each other’s values. When an attempt wins, its properties are merged back into the caller’s map. Losers’ writes are dropped.
const
const pipeline: ResiliencePipeline<string, never>
pipeline
= 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>().
ResiliencePipelineBuilder<string, never, never>.addHedging(options?: HedgingOptions<string>): ResiliencePipelineBuilder<string, never, never>
addHedging
({
HedgingOptions<string>.delay?: Duration | undefined

ms to wait before starting another attempt. Default 2000. > 0 latency mode, 0 parallel mode (all at once), < 0 fallback mode (next attempt only after a failure).

delay
: 0,
HedgingOptions<string>.maxHedgedAttempts?: number | undefined

Hedged attempts in addition to the primary. Default 1.

maxHedgedAttempts
: 2 }).
ResiliencePipelineBuilder<string, never, never>.build(): ResiliencePipeline<string, never>
build
();
const
const properties: Map<string, unknown>
properties
= new
var Map: MapConstructor
new <string, unknown>(iterable?: Iterable<readonly [string, unknown]> | null | undefined) => Map<string, unknown> (+3 overloads)
Map
<string, unknown>();
await
const pipeline: ResiliencePipeline<string, never>
pipeline
.
ResiliencePipeline<string, never>.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
,
attemptNumber: number

0 for the first attempt, then 1, 2, … in the order attempts start. Use it for logging or per-attempt idempotency keys. One counter per execution numbers every retry and hedged attempt, however retry and hedging are nested (retry around hedging, retry inside hedged branches), so a number is never repeated within an execution. Strategies' own counters (RetryPredicateArgs.attemptNumber, OnHedgingArgs.attemptNumber) stay local.

attemptNumber
,
properties: Map<string, unknown>

Shared bag for passing data between strategies and the callback.

properties
}) => {
properties: Map<string, unknown>

Shared bag for passing data between strategies and the callback.

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

Adds a new element with a specified key and value to the Map. If an element with the same key already exists, the element will be updated.

set
('replica',
attemptNumber: number

0 for the first attempt, then 1, 2, … in the order attempts start. Use it for logging or per-attempt idempotency keys. One counter per execution numbers every retry and hedged attempt, however retry and hedging are nested (retry around hedging, retry inside hedged branches), so a number is never repeated within an execution. Strategies' own counters (RetryPredicateArgs.attemptNumber, OnHedgingArgs.attemptNumber) stay local.

attemptNumber
);
return
function query(replica: number, signal: AbortSignal): Promise<string>
query
(
attemptNumber: number

0 for the first attempt, then 1, 2, … in the order attempts start. Use it for logging or per-attempt idempotency keys. One counter per execution numbers every retry and hedged attempt, however retry and hedging are nested (retry around hedging, retry inside hedged branches), so a number is never repeated within an execution. Strategies' own counters (RetryPredicateArgs.attemptNumber, OnHedgingArgs.attemptNumber) stay local.

attemptNumber
,
signal: AbortSignal

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

signal
);
},
{
ExecuteOptions.properties?: Map<string, unknown> | undefined
properties
},
);
const properties: Map<string, unknown>
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
('replica'); // the winner's replica

actionGenerator returns the action for a hedged attempt, for example another region or endpoint. Return undefined to repeat the original callback. It isn’t called for the primary.

const
const regions: string[]
regions
= ['https://eu.api.example.com', 'https://us.api.example.com', 'https://ap.api.example.com'];
const
const pipeline: ResiliencePipeline<Response, never>
pipeline
= new
new ResiliencePipelineBuilder<Response, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<Response, 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
<
interface Response

The Response interface of the Fetch API represents the response to a request.

MDN Reference

Response
>()
.
ResiliencePipelineBuilder<Response, never, never>.addHedging(options?: HedgingOptions<Response>): ResiliencePipelineBuilder<Response, never, never>
addHedging
({
HedgingOptions<Response>.delay?: Duration | undefined

ms to wait before starting another attempt. Default 2000. > 0 latency mode, 0 parallel mode (all at once), < 0 fallback mode (next attempt only after a failure).

delay
: -1, // fallback mode: next region only after a failure
HedgingOptions<Response>.maxHedgedAttempts?: number | undefined

Hedged attempts in addition to the primary. Default 1.

maxHedgedAttempts
: 2,
HedgingOptions<Response>.actionGenerator?: ((args: HedgingActionArgs<Response>) => ((context: ResilienceContext) => MaybePromise<Response>) | undefined) | undefined

Return a different action for hedged attempts (e.g. another endpoint), or undefined to repeat the original.

actionGenerator
: ({
attemptNumber: number
attemptNumber
}) => ({
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
(`${
const regions: string[]
regions
[
attemptNumber: number
attemptNumber
]}/stock`, {
RequestInit.signal?: AbortSignal | null | undefined

An AbortSignal to set request's signal.

signal
}),
})
.
ResiliencePipelineBuilder<Response, never, never>.build(): ResiliencePipeline<Response, never>
build
();
const
const res: Response
res
= await
const pipeline: ResiliencePipeline<Response, never>
pipeline
.
ResiliencePipeline<Response, never>.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
(`${
const regions: string[]
regions
[0]}/stock`, {
RequestInit.signal?: AbortSignal | null | undefined

An AbortSignal to set request's signal.

signal
}));

The generated action replaces the strategies added after hedging as well as the callback. To keep them, call the callback the generator receives: it runs the rest of the pipeline with the original callback, and throws on failure.

delayGenerator returns the delay before each next attempt. It gets { attemptNumber, context }, where attemptNumber is the attempt about to start (1 for the first hedge). The sign rules above apply per step: return -1 to wait for a failure.

// First hedge after 100 ms, the next one only if something fails.
const
const pipeline: ResiliencePipeline<any, never>
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>.addHedging(options?: HedgingOptions<any>): ResiliencePipelineBuilder<any, never, never>
addHedging
({
HedgingOptions<any>.maxHedgedAttempts?: number | undefined

Hedged attempts in addition to the primary. Default 1.

maxHedgedAttempts
: 2,
HedgingOptions<any>.delayGenerator?: ((args: HedgingDelayArgs) => MaybePromise<Duration | undefined>) | undefined

Delay before the attempt about to start; undefined uses delay.

delayGenerator
: ({
attemptNumber: number
attemptNumber
}) => (
attemptNumber: number
attemptNumber
=== 1 ? 100 : -1) })
.
ResiliencePipelineBuilder<any, never, never>.build(): ResiliencePipeline<any, never>
build
();
Option Type Default Description
name string 'Hedging' Strategy name in telemetry and getStrategy().
shouldHandle (args) => boolean (or a promise) or PredicateBuilder transient errors Which outcomes count as failed. Anything else wins.
maxHedgedAttempts number ≥ 1 1 Attempts in addition to the primary.
delay Duration 2000 Wait before the next attempt. > 0 latency, 0 parallel, < 0 fallback mode.
delayGenerator ({ attemptNumber, context }) => Duration | undefined (or a promise) none Per-attempt delay; overrides delay (undefined keeps it).
actionGenerator ({ attemptNumber, context, callback }) => action | undefined none Action for a hedged attempt. undefined repeats the original.
onHedging (args: OnHedgingArgs) => void none Before each hedged attempt. Gets attemptNumber, duration (ms since the primary started) and context. Awaited.

Duration is milliseconds or a string such as '150ms'. maxHedgedAttempts < 1 throws RangeError on build().

With execute(fn, { idempotent: false }), hedging never starts an attempt while another one may be in flight. After a failure it only starts another if the failed attempt never started, i.e. it was a rejection, and only if your shouldHandle handles rejections. With the default predicate, a non-idempotent call runs exactly once.

Telemetry event Callback When
OnHedging onHedging Before each hedged attempt (not the primary).
OnExecutionAttempt none Every call of the original callback, emitted by the pipeline. Actions from actionGenerator that don’t call callback aren’t counted.

Hedging raises no error of its own. When every attempt fails, the primary attempt’s error is returned. Losers are aborted with a plain abort(), so their signal’s reason is an AbortError. They are also aborted when hedging itself fails (an actionGenerator, onHedging or delayGenerator that throws). With executeStream, a loser that already produced its first chunk has its stream closed, and so does a stream your shouldHandle rejected (unless it ends up being the returned result).

  • Not passing signal. The losers keep running and their responses are thrown away.
  • Timeout outside hedging when you meant per attempt. addTimeout().addHedging() limits the whole race; addHedging().addTimeout() limits each attempt. Both are useful; pick on purpose.
  • Hedging writes. Use idempotent: false for them, or don’t hedge them.