The problem. Every strategy keeps its state in the process that runs it. Scale a service to ten instances behind a
load balancer and you get:
Ten circuit breakers. A dependency goes down. Each instance needs its own run of failures before its circuit opens,
so the dead dependency takes ten times the failed calls, and users hit ten times the errors.
Ten rate limits. A partner API allows 100 calls a minute. Each instance enforces 100 a minute, so the fleet sends up
to 1 000 and gets 429s.
Ten caches, ten locks. A “run once” job runs once per instance. A cache miss is fetched ten times.
resilience-pipeline/redis moves that state to Redis. This guide shows which state to share, how to wire it, and what
stays local on purpose. The class-by-class reference is on the Redis integration page.
Create one Redis client per process and build pipelines once, at module scope. A pipeline built per request starts from an
empty state each time, and with sharedCircuitState it would also start a new sync timer per request.
redis.ts
import Redis from'ioredis';
// Short timeouts: a rate limit check that waits 10 s for Redis is worse than no rate limit.
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().
Shares the breaker's state with other instances (servers, processes) through a store, e.g. RedisCircuitStateStore:
one instance seeing the dependency fail opens the circuit for all of them.
addCircuitBreaker({ extensions: [sharedCircuitState({ store, key: 'payments' })] })
sharedCircuitState } from'resilience-pipeline';
import {
classRedisCircuitStateStore
CircuitStateStore over Redis: a write only lands if its updatedAt is newer than the stored one. Keys don't expire.
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().
Background behaviour plugged into the breaker, e.g. circuitHealthCheck({ check, interval }) or
sharedCircuitState({ store, key }) from this package. Kept out of the core breaker so you only bundle what you use.
Shares the breaker's state with other instances (servers, processes) through a store, e.g. RedisCircuitStateStore:
one instance seeing the dependency fail opens the circuit for all of them.
addCircuitBreaker({ extensions: [sharedCircuitState({ store, key: 'payments' })] })
Instance A’s own window crosses 50% failures. Its circuit opens and it writes { state: 'open', openUntil, … } to rp:cb:payments.
Every other instance reads the key within syncInterval (2 s here) and opens too, without having seen a single failure.
After breakDuration, each instance lets its own probe through. The first one whose probe succeeds closes its circuit
and writes closed; the others adopt it on their next sync.
The key is one logical breaker. Use the same key on every instance that calls the same dependency, and different keys for
different dependencies (payments, payments-eu). The rolling window is never written to Redis.
The shared state also gives you a fleet-wide kill switch: isolate() through a CircuitBreakerManualControl on one
instance writes isolated, and every instance rejects calls until one of them calls close().
A quota is per API key. Enforce it in Redis so the fleet as a whole stays under it. When Redis is unreachable, insurance
answers from an in-memory limiter instead of failing every call, sized to this instance’s share of the quota:
partner.ts
import {
classRateLimiterStrategy<T>
Runs any RateLimiterAlgorithm as a strategy: weights, FIFO waiting up to queueTimeout, block, allow/deny, insurance, telemetry.
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,
classWindowCounter
Segmented sliding window: the window is split into segments buckets and slides one bucket at a time.
With 1 segment it is a fixed window. Windows are aligned to origin; with 'first-request' alignment a new
window starts at the first request after the window empties instead.
WindowCounter } from'resilience-pipeline';
import {
classRedisRateLimiter
Sliding-window RateLimiterAlgorithm shared through Redis. Run it with RateLimiterStrategy:
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().
Longest an execution may wait for permits before it is rejected. Waiters are served first come, first served. Default 0: reject right away.
queueTimeout: '2s', // wait a little for a permit instead of failing at once
// Redis down or slower than 50 ms: this instance's share of the quota, counted locally.
PermitRateLimiterOptions.insurance?: {
algorithm: RateLimiterAlgorithm;
timeout?: Duration;
} |undefined
In-memory fallback for a remote algorithm: when acquire throws or exceeds timeout, algorithm answers instead
and OnRateLimiterInsuranceUsed is reported. Size it per process (e.g. the shared limit / instances).
Segmented sliding window: the window is split into segments buckets and slides one bucket at a time.
With 1 segment it is a fixed window. Windows are aligned to origin; with 'first-request' alignment a new
window starts at the first request after the window empties instead.
WindowCounter(
var Math:Math
An intrinsic object that provides basic mathematics functionality and constants.
Math.
Math.floor(x: number): number
Returns the greatest integer less than or equal to its numeric argument.
WindowCounter(permitLimit, windowMs, segments, origin) is the same sliding window the in-memory limiters use. Every call
answered by it reports OnRateLimiterInsuranceUsed, so you can see when the fleet runs on local limits.
For a limit per tenant or user across instances, use redisPartitionLimiter with addPartitionedRateLimiter; the
rate-limited APIs guide has the full pattern.
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().
Fencing token of the innermost lock the calling code runs under. Pass it to the protected resource so it can reject stale writers.
getLockToken } from'resilience-pipeline';
import {
classRedisLockStore
LockStore on a single Redis primary (no Redlock): lease = SET NX PX, fencing token = INCR of a per-key counter.
The counter never expires (it must only grow), so one small key per lock key is kept forever.
Caveats: Redis replication is async, so after a failover a lease (or counter increments) can be lost: two holders,
or a repeated fencing token. For tokens to survive restarts, run with AOF (appendfsync everysec or always).
The protected resource should still reject writes whose token is below the highest it has seen.
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().
LockStore on a single Redis primary (no Redlock): lease = SET NX PX, fencing token = INCR of a per-key counter.
The counter never expires (it must only grow), so one small key per lock key is kept forever.
Caveats: Redis replication is async, so after a failover a lease (or counter increments) can be lost: two holders,
or a repeated fencing token. For tokens to survive restarts, run with AOF (appendfsync everysec or always).
The protected resource should still reject writes whose token is below the highest it has seen.
RedisLockStore({
RedisStoreOptions.client: RedisClient
client:
constredis:RedisClient
redis }),
LockOptions.ttl?: Duration |undefined
Lease length. Renewed every ttl/3 while the callback runs. Default 30 s.
Fencing token of the innermost lock the calling code runs under. Pass it to the protected resource so it can reject stale writers.
getLockToken(),
signal: AbortSignal
Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.
signal));
} catch (
var e:unknown
e) {
if (!(
var e:unknown
einstanceof
classLockNotAcquiredError
The lock was held by someone else for longer than wait.
LockNotAcquiredError)) throw
var e:unknown
e;
}
The lease is renewed every ttl / 3 while the job runs. If a renewal fails (the instance was paused by GC, or lost Redis),
the job’s signal is aborted with LockLostError, because another instance may take the lease from then on. Stop writing
when the signal aborts, and have the protected resource reject writes with a fencing token lower than the highest it has seen:
that check, not the lease, is what keeps two holders from both writing.
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().
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().
Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.
signal), {
ExecuteOptions.operationKey?: string |undefined
operationKey: 'rates:EUR' });
staleTtl makes the shared cache a fleet-wide fallback: while the rates API is down, every instance serves the last value
any of them fetched. Values go through JSON.stringify, so store plain data (a Date comes back as a string).
Concurrent misses are coalesced per process only: right after the entry expires, each instance may fetch once.
Limits that stay local are per instance. Size them from the dependency’s capacity divided by the instance count, and
remember that autoscaling changes the divisor:
Concurrency limiter toward a database with 100 connections and 5 instances:permitLimit: 20 each, or
less if other services share the database.
Inbound load shedding: per instance by nature, since it protects that process; see load shedding.
Insurance limits: the shared quota divided by the instance count, as above. With autoscaling, divide by the maximum
instance count so a scaled-out fleet on insurance still stays under the quota.