Skip to content
Buy me a coffee

Cache and coalesce

Three related tools built on one strategy:

Tool Stores results Merges concurrent calls Serves old data on failure
addCache({ ttl }) yes, for ttl yes (default) with staleTtl
addCoalesce() no yes no
addStaleFallback({ maxAge }) only as a backup no yes, up to maxAge

Use cache when the same data is read far more often than it changes. Use coalesce (single-flight) when many callers ask for the same thing at the same moment, a cache stampede after a deploy or a cold key, but every answer must be fresh. Use stale fallback when every call must go through, yet an old answer beats an error. Don’t cache writes, or per-user data under a shared key.

const
const pipeline: ResiliencePipeline<Rates, TimeoutRejectedError>
pipeline
= new
new ResiliencePipelineBuilder<Rates, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<Rates, 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 Rates
Rates
>()
.
ResiliencePipelineBuilder<Rates, never, never>.addCache(options: CacheOptions<Rates>): ResiliencePipelineBuilder<Rates, never, never>

Cache successful results by key, coalesce concurrent identical calls, optionally serve stale values on failure.

addCache
({
CacheOptions<Rates>.key?: ((context: ResilienceContext) => string | undefined) | undefined

Cache key for the execution; undefined bypasses the cache. Default: context.operationKey.

key
: () => 'fx-rates',
CacheOptions<Rates>.ttl: Duration

How long a value is served without calling. 0 with coalesce gives request deduplication without caching.

ttl
: '1m',
CacheOptions<Rates>.staleTtl?: Duration | undefined

Stale-if-error window after ttl: when the call fails, a value younger than ttl + staleTtl is returned instead. Default 0.

staleTtl
: '1h' })
.
ResiliencePipelineBuilder<Rates, never, never>.addTimeout(options: Duration | TimeoutOptions): Next<Rates, never, never, TimeoutRejectedError>

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

addTimeout
('2s')
.
ResiliencePipelineBuilder<Rates, TimeoutRejectedError, never>.build(): ResiliencePipeline<Rates, TimeoutRejectedError>
build
();
const
const rates: Rates
rates
= await
const pipeline: ResiliencePipeline<Rates, TimeoutRejectedError>
pipeline
.
ResiliencePipeline<Rates, TimeoutRejectedError>.execute<Rates>(callback: (context: ResilienceContext) => MaybePromise<Rates>, options?: ExecuteOptions): Promise<Rates>

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 fetchRates(signal: AbortSignal): Promise<Rates>
fetchRates
(
signal: AbortSignal

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

signal
));

Fresh for a minute; for the next hour, served only if a refresh fails.

dependency down0 ms5 s10 scache entryagefetchRates()callback runs
fresh / hitstale (served on error)gonemiss: runs the loadjoined the load in flighterror
  • cache entry:
  • fetchRates():
fresh hits0
loads (misses)0
coalesced0
stale served0
errors0
cacheStrategy({ key: () => 'fx-rates', ttl: 3000, staleTtl: 4000 })

For each execution:

  1. Key. key(context), by default context.operationKey. An undefined key skips the cache entirely.
  2. Fresh hit (age < ttl): return the stored value. The callback doesn’t run. OnCacheHit.
  3. Too old (age ≥ ttl + staleTtl): delete the entry.
  4. Miss: OnCacheMiss, then call through. Concurrent misses for the same key share one call (OnCoalesced).
  5. Success: store it if shouldCache agrees (and ttl + staleTtl > 0). Failures are never stored.
  6. Failure with an entry still in its stale window, and shouldServeStale agrees: return the stale value instead. OnStaleServed.

The store is read on every call and may be remote. A store that throws never fails the call: a failed get counts as a miss, a failed set or delete is skipped, and each is reported as OnCacheStoreFailed.

const
const users: ResiliencePipeline<User, never>
users
= new
new ResiliencePipelineBuilder<User, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<User, 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 User
User
>().
ResiliencePipelineBuilder<User, never, never>.addCoalesce(options?: Omit<CacheOptions<User>, "ttl" | "staleTtl" | "store" | "shouldCache">): ResiliencePipelineBuilder<User, never, never>

Single-flight only: concurrent executions with the same key share one call; nothing is cached.

addCoalesce
().
ResiliencePipelineBuilder<User, never, never>.build(): ResiliencePipeline<User, never>
build
();
// 100 concurrent requests for user 42: one database query, 100 answers.
const
const getUser: (id: string) => Promise<User>
getUser
= (
id: string
id
: string) =>
const users: ResiliencePipeline<User, never>
users
.
ResiliencePipeline<User, never>.execute<User>(callback: (context: ResilienceContext) => MaybePromise<User>, options?: ExecuteOptions): Promise<User>

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 loadUser(id: string, signal: AbortSignal): Promise<User>
loadUser
(
id: string
id
,
signal: AbortSignal

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

signal
), {
ExecuteOptions.operationKey?: string | undefined
operationKey
: `user:${
id: string
id
}` });
await
var Promise: PromiseConstructor

Represents the completion of an asynchronous operation

Promise
.
PromiseConstructor.all<Promise<User>[]>(values: Promise<User>[]): Promise<User[]> (+1 overload)

Creates a Promise that is resolved with an array of results when all of the provided Promises resolve, or rejected when any Promise is rejected.

@param ― values An array of Promises.

@returns ― A new Promise.

all
(
var Array: ArrayConstructor
Array
.
ArrayConstructor.from<unknown, Promise<User>>(iterable: Iterable<unknown> | ArrayLike<unknown>, mapfn: (v: unknown, k: number) => Promise<User>, thisArg?: any): Promise<User>[] (+3 overloads)

Creates an array from an iterable object.

@param ― iterable An iterable object to convert to an array.

@param ― mapfn A mapping function to call on every element of the array.

@param ― thisArg Value of 'this' used to invoke the mapfn.

from
({
ArrayLike<T>.length: number
length
: 100 }, () =>
const getUser: (id: string) => Promise<User>
getUser
('42')));

addCoalesce(options) is addCache with ttl: 0: nothing is kept after the call ends. Every caller gets the same outcome, success or failure.

Abort semantics. The shared call runs on its own AbortSignal, not the first caller’s, so one caller giving up can’t cancel it for the others.

  • A caller whose signal aborts leaves at once and fails with its abort reason. The shared call keeps running for the rest.
  • When the last caller leaves, the shared call is aborted and detached; the next caller starts a fresh one instead of joining a dying call.
  • The shared call otherwise sees the first caller’s context (properties, operationKey).

Put timeouts and retries inside (after) the cache, so they apply to the shared call. A timeout outside it only lets that one caller stop waiting.

Option Type Default Description
ttl Duration required How long a value is served without calling. 0 with coalesce gives deduplication without caching. ≥ 0.
key (context) => string | undefined context.operationKey Cache key; undefined bypasses the cache (no caching, no coalescing).
staleTtl Duration 0 Stale-if-error window after ttl: when the call fails, a value younger than ttl + staleTtl is returned instead.
shouldServeStale (args: { outcome, context }) => boolean | Promise<boolean>, or a PredicateBuilder every failure except aborts and client errors (4xx other than 408 / 429) Which failures a stale value may hide.
shouldCache (args: { outcome, context }) => boolean | Promise<boolean> every success Which successful results to store. Failures are never stored.
store CacheStore a new MemoryCacheStore per strategy Where entries live. Share one store to share the cache between pipelines.
coalesce boolean true Concurrent misses for the same key share one call.
onCacheHit (args: { context, key, value, age }) => void | Promise<void> – Fresh hit. age in ms.
onCacheMiss (args: { context, key }) => void | Promise<void> – Miss (also when a stale value exists).
onStaleServed (args: { context, key, value, age, outcome }) => void | Promise<void> – A stale value replaced the failure in outcome.
name string 'Cache' ('Coalesce' for addCoalesce) Strategy name.

addCoalesce takes the same options minus ttl, staleTtl, store and shouldCache.

addStaleFallback / staleFallbackStrategy remembers the last good value per key and returns it only when a call fails. Fresh calls always go through. It is the cache strategy with ttl: 0, staleTtl: maxAge and coalesce: false; see fallback for how it fits with other fallbacks.

Option Type Default Description
maxAge Duration required How old a last-good value may be and still be served.
maxEntries number 1000 Keys remembered (LRU). Ignored when store is set.
store CacheStore new MemoryCacheStore({ maxEntries }) Where values live.
key, shouldServeStale, onStaleServed, name as above; name defaults to 'StaleFallback'
const
const pipeline: ResiliencePipeline<any, TimeoutRejectedError>
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>.addStaleFallback(options: StaleFallbackOptions<any>): ResiliencePipelineBuilder<any, never, never>

Serve the last good value when a call fails (no caching of fresh calls): tenace's withStaleFallback.

addStaleFallback
({
key?: ((context: ResilienceContext) => string | undefined) | undefined

Cache key for the execution; undefined bypasses the cache. Default: context.operationKey.

key
: () => 'prices',
StaleFallbackOptions<any>.maxAge: Duration

How old a last-good value may be and still be served.

maxAge
: '6h' })
.
ResiliencePipelineBuilder<any, never, never>.addRetry(options?: RetryOptions<any>): ResiliencePipelineBuilder<any, never, never>
addRetry
({
RetryOptions<any>.maxRetryAttempts?: number | undefined

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

maxRetryAttempts
: 2 })
.
ResiliencePipelineBuilder<any, never, never>.addTimeout(options: Duration | TimeoutOptions): Next<any, never, never, TimeoutRejectedError>

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

addTimeout
('3s')
.
ResiliencePipelineBuilder<any, TimeoutRejectedError, never>.build(): ResiliencePipeline<any, TimeoutRejectedError>
build
();

MemoryCacheStore is the default: an in-process Map bounded by maxEntries (default 1000), evicting the least recently used entry. It ignores the TTL hint; the strategy deletes expired entries when it reads them and LRU bounds the rest. It also has size and clear().

import {
class MemoryCacheStore

In-process store, bounded by maxEntries (least recently used goes first).

MemoryCacheStore
,
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 store: MemoryCacheStore
store
= new
new MemoryCacheStore(options?: {
maxEntries?: number;
}): MemoryCacheStore

In-process store, bounded by maxEntries (least recently used goes first).

MemoryCacheStore
({
maxEntries?: number | undefined
maxEntries
: 10_000 });
const
const a: ResiliencePipeline<any, never>
a
= 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>.addCache(options: CacheOptions<any>): ResiliencePipelineBuilder<any, never, never>

Cache successful results by key, coalesce concurrent identical calls, optionally serve stale values on failure.

addCache
({
CacheOptions<any>.ttl: Duration

How long a value is served without calling. 0 with coalesce gives request deduplication without caching.

ttl
: '5m',
CacheOptions<any>.store?: CacheStore | undefined

Default: a new MemoryCacheStore per strategy. Share one to share the cache between pipelines.

store
}).
ResiliencePipelineBuilder<any, never, never>.build(): ResiliencePipeline<any, never>
build
();
const
const b: ResiliencePipeline<any, never>
b
= 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>.addCache(options: CacheOptions<any>): ResiliencePipelineBuilder<any, never, never>

Cache successful results by key, coalesce concurrent identical calls, optionally serve stale values on failure.

addCache
({
CacheOptions<any>.ttl: Duration

How long a value is served without calling. 0 with coalesce gives request deduplication without caching.

ttl
: '5m',
CacheOptions<any>.store?: CacheStore | undefined

Default: a new MemoryCacheStore per strategy. Share one to share the cache between pipelines.

store
}).
ResiliencePipelineBuilder<any, never, never>.build(): ResiliencePipeline<any, never>
build
(); // same entries as `a`
const store: MemoryCacheStore
store
.
MemoryCacheStore.clear(): void
clear
(); // e.g. after a deploy

Implement CacheStore to use anything else. Methods may be sync or async:

interface
interface MyCacheStore
MyCacheStore
extends
(alias) interface CacheStore
import CacheStore

Async so remote stores (Redis, ...) can plug in. The strategy decides freshness from storedAt.

CacheStore
{
MyCacheStore.get(key: string): Promise<CacheEntry | undefined>
get
(
key: string
key
: string):
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<
(alias) interface CacheEntry
import CacheEntry
CacheEntry
| undefined>;
/** ttlMs = ttl + staleTtl: how long the entry can still be useful. Remote stores can expire it natively. */
MyCacheStore.set(key: string, entry: CacheEntry, ttlMs: number): Promise<void>

ttlMs = ttl + staleTtl: how long the entry can still be useful. Remote stores can expire it natively.

set
(
key: string
key
: string,
entry: CacheEntry
entry
:
(alias) interface CacheEntry
import CacheEntry
CacheEntry
,
ttlMs: number
ttlMs
: number):
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<void>;
MyCacheStore.delete(key: string): Promise<void>
delete
(
key: string
key
: string):
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<void>;
}
// CacheEntry = { value: unknown; storedAt: number } (epoch ms from the pipeline's clock)

The strategy decides freshness from storedAt, so a store only has to keep entries. RedisCacheStore (Redis) is ready-made. Values must survive the store’s serialization (JSON for Redis).

Telemetry event Callback Args
OnCacheHit onCacheHit { context, key, value, age }
OnCacheMiss onCacheMiss { context, key }
OnStaleServed onStaleServed { context, key, value, age, outcome }
OnCoalesced – { context, key, waiters }: a caller joined an in-flight call; waiters includes it.
OnCacheStoreFailed – { context, key, operation: 'get' | 'set' | 'delete', error }

The strategy raises no errors of its own. A coalesced caller that aborts fails with its signal’s reason.

  • Shared keys across types. Entries are untyped. Two pipelines sharing a store must not use the same key for different shapes.
  • Mutable values. The memory store returns the same object to every hit. Don’t mutate cached results.
  • Per-user data. Include the user or tenant in the key: key: (ctx) => `profile:${ctx.properties.get('userId')}` .
  • Fallback: addStaleFallback alongside fixed and computed fallbacks.
  • Batcher: merge different keys into one call.
  • Redis: RedisCacheStore.