A tiny, correct retry-with-backoff helper
Retries are easy to write and easy to get wrong. Here's a 25-line version with jitter, a cap, and cancellation.
Almost every service I’ve worked on has a hand-rolled retry loop. Most of them share the same three bugs: no jitter, no upper bound, and no way to stop.
The helper
type RetryOptions = {
retries?: number; // max attempts after the first
baseMs?: number; // first backoff
maxMs?: number; // cap for any single wait
signal?: AbortSignal; // cancel from outside
};
export async function retry<T>(
fn: (attempt: number) => Promise<T>,
{ retries = 5, baseMs = 200, maxMs = 5_000, signal }: RetryOptions = {},
): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await fn(attempt);
} catch (err) {
if (attempt >= retries || signal?.aborted) throw err;
// "Full jitter": random wait between 0 and the exponential ceiling.
const ceiling = Math.min(maxMs, baseMs * 2 ** attempt);
await new Promise((r) => setTimeout(r, Math.random() * ceiling));
}
}
}
Using it
const res = await retry(() => fetch('https://api.example.com/data'), { retries: 3 });
Why full jitter
Without jitter, every client that failed at the same moment retries at the same moment, and you get a thundering herd. Randomising the whole wait spreads them out.
What to add next
- Only retry errors that are actually transient (timeouts, 429, 5xx).
- Respect a
Retry-Afterheader when the server sends one. - Emit a metric per retry so you can see when things degrade.