Skip to content

Backoff policy

A backoff policy is a function from “how many times has this failed?” to “how long should I wait before trying again?” Decoupled from any specific retry or supervisor, so the calculation is trivially testable and reusable.

interface BackoffPolicy {
delayFor(restartCount: number): number;
}

restartCount is 0-based — the first retry passes 0, the second 1, and so on. Returns the delay in milliseconds.

Two built-ins, both producing values you can plug into a BackoffSupervisor or use standalone.

import { exponentialBackoff } from 'actor-ts';
const policy = exponentialBackoff({
minMs: 200,
maxMs: 10_000,
randomFactor: 0.2,
});
policy.delayFor(0); // ~200ms (±20% jitter)
policy.delayFor(1); // ~400ms
policy.delayFor(2); // ~800ms
policy.delayFor(3); // ~1600ms
policy.delayFor(4); // ~3200ms
policy.delayFor(5); // ~6400ms
policy.delayFor(6); // ~10_000 (clamped)
policy.delayFor(30); // ~10_000 (still clamped)

The formula: min × 2^n clamped to max, multiplied by 1 + random(-randomFactor, +randomFactor).

Three knobs:

FieldMeaning
minMsFloor for the delay. The first retry waits at least this long.
maxMsCeiling for the delay. Once min × 2^n exceeds this, the policy returns maxMs (jittered).
randomFactor (default 0.2)Jitter fraction in [0, 1]. The actual delay is base * (1 + sign * randomFactor) where sign is uniform in [-1, 1].

Why exponential? A failing upstream usually needs time to recover. Doubling the wait gives it more time without infinite delays. And clamping at max prevents pathologically long waits after many failures.

Why jitter? When N clients all retry at the same delay, they synchronize their next attempt — the next retry is a thundering herd, increasing the chance the upstream stays broken. Adding ±20 % noise scatters retries in time so the herd disperses.

import { linearBackoff } from 'actor-ts';
const policy = linearBackoff({
minMs: 500,
maxMs: 5_000,
stepMs: 500,
});
policy.delayFor(0); // ~500ms
policy.delayFor(1); // ~1000ms
policy.delayFor(2); // ~1500ms
policy.delayFor(3); // ~2000ms
policy.delayFor(8); // ~4500ms
policy.delayFor(9); // ~5000ms (clamped)
policy.delayFor(20); // ~5000ms (still clamped)

The formula: min + step × n clamped to max, with the same jitter contract.

When linear? Niche — when you specifically want bounded growth rather than exponential. Examples:

  • A polling cadence that should level off quickly (poll every 500 ms, 1 s, 1.5 s, … capped at 5 s).
  • A producer rate-limiting itself when receiving backpressure — the rate of growth in the wait time matches the steady-state-acceptable load on the consumer.

Exponential is usually the better choice for recovery (give the upstream time to heal). Linear is better for steady-state adaptation.

Both policies accept an optional random: () => number override:

let seed = 0;
const deterministicRandom = () => (seed = (seed * 9301 + 49297) % 233280) / 233280;
const policy = exponentialBackoff({
minMs: 100,
maxMs: 10_000,
randomFactor: 0.2,
random: deterministicRandom,
});
// Now `policy.delayFor(n)` is reproducible.

A linear congruential generator is good enough for tests; for production randomness, the default Math.random is fine.

The policy doesn’t have to feed into a supervisor. Anywhere you’d write await new Promise(r => setTimeout(r, computeDelay(n))), plug a policy in:

import { exponentialBackoff } from 'actor-ts';
const policy = exponentialBackoff({ minMs: 200, maxMs: 5_000 });
let attempt = 0;
while (true) {
try {
const result = await someRiskyCall();
return result;
} catch (err) {
const delay = policy.delayFor(attempt++);
await new Promise(r => setTimeout(r, delay));
if (attempt >= 5) throw err;
}
}

For the more common case of “retry N times with backoff,” see Retry, which has its own delay options (without going through BackoffPolicy). The standalone policy is most useful when you’re integrating with an existing retry/scheduler abstraction and just need the delay-computation piece.

Implement the BackoffPolicy interface:

import type { BackoffPolicy } from 'actor-ts';
// Fibonacci backoff — for fun.
function fibBackoff(opts: { maxMs: number }): BackoffPolicy {
return {
delayFor(n: number): number {
let a = 100, b = 100;
for (let i = 0; i < n; i++) { [a, b] = [b, a + b]; }
return Math.min(a, opts.maxMs);
},
};
}

Pass it into BackoffSupervisor via the policy option and you’ve overridden the default exponential growth.

  • Backoff supervisor — the actor-restart wrapper that consumes a BackoffPolicy.
  • Retry — per-call retry with its own delay options; doesn’t go through BackoffPolicy but the underlying math is similar.
  • Circuit breaker — for when “wait longer between attempts” should escalate to “stop attempting entirely for a while.”

The exponentialBackoff and linearBackoff API references cover the full options.