Skip to main content

Retry an async operation with exponential backoff

A flaky upstream deserves a second attempt; a 404 does not. This recipe builds a retry helper from sleep, clamp, randomInt and tryCatch — about twenty lines, no dependency.

The code

import { clamp } from '@rtorcato/js-common/numbers'
import { sleep } from '@rtorcato/js-common/sleep'
import { randomInt } from '@rtorcato/js-common/random'
import { type Result, tryCatch } from '@rtorcato/js-common/try'

export type RetryOptions = {
/** Total attempts, including the first. Default 4. */
attempts?: number
/** Delay before the first retry, in ms. Doubles each round. Default 200. */
baseMs?: number
/** Upper bound on a single delay, in ms. Default 10_000. */
maxMs?: number
/** Return false to stop early — e.g. on a 4xx. Default: retry everything. */
shouldRetry?: (error: unknown) => boolean
}

/**
* Runs `fn`, retrying failures with exponential backoff and jitter.
* Resolves to a `Result` — the last error if every attempt failed.
*/
export async function retry<T>(
fn: () => Promise<T>,
{ attempts = 4, baseMs = 200, maxMs = 10_000, shouldRetry = () => true }: RetryOptions = {}
): Promise<Result<T>> {
let last: Result<T> = { data: null, error: new Error('retry: attempts must be at least 1') }

for (let attempt = 0; attempt < attempts; attempt++) {
last = await tryCatch(fn)
if (!last.error) return last
if (attempt === attempts - 1 || !shouldRetry(last.error)) break

const backoff = clamp(baseMs * 2 ** attempt, baseMs, maxMs)
await sleep(randomInt(Math.round(backoff / 2), backoff)) // jitter
}

return last
}

Call it and branch on the Result — no try/catch at the call site:

import { isSuccess } from '@rtorcato/js-common/try'

const result = await retry(() => getJson<Report>('/api/report'))

if (isSuccess(result)) {
render(result.data)
} else {
logger.error({ err: result.error }, 'report fetch failed after 4 attempts')
}

Why jitter

Without it, every client that failed at the same moment retries at the same moment — the thundering herd that keeps a recovering service down. Sleeping a random amount between half the backoff and the full backoff spreads the retries out. With the defaults, the waits are roughly 100–200 ms, 200–400 ms, then 400–800 ms.

clamp is what stops the doubling from running away: attempt 10 would otherwise wait about 200 seconds.

Retry the right errors

Retrying a 400 just fails four times more slowly. Pass shouldRetry to retry only what a retry can fix — network failures, 429, and 5xx:

class HttpError extends Error {
constructor(readonly status: number) {
super(`HTTP ${status}`)
}
}

const result = await retry(() => fetchReport(), {
shouldRetry: (error) =>
!(error instanceof HttpError) || error.status === 429 || error.status >= 500,
})
Only retry idempotent work

A GET or a PUT is safe to repeat. A POST that charges a card is not — a timeout does not tell you whether the server processed the request. Retry those only behind an idempotency key.

Bound each attempt

Backoff does not help if a single attempt hangs forever. withTimeout caps one attempt; the retry loop caps the whole operation:

import { withTimeout } from '@rtorcato/js-common/promises'

const result = await retry(() => withTimeout(fetchReport(), 5_000), { attempts: 3 })

Worst case here is 3 × 5 s of work plus the backoff waits — a number you can put in a timeout budget.

Let the caller cancel

Pass an AbortSignal through to the work and check it between attempts, so a cancelled request stops retrying instead of finishing its schedule:

import { createAbortController } from '@rtorcato/js-common/abortController'

const { controller, signal } = createAbortController()

const result = await retry(() => fetch('/api/report', { signal }), {
shouldRetry: () => !signal.aborted,
})

See also