Async retry with exponential backoff
typescriptA production-ready async retry utility that implements exponential backoff with jitter. Supports configurable max attempts, base delay, max delay cap, and an optional error filter predicate to selectively retry specific errors.
Code
interface RetryOptions {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
jitterFactor?: number;
shouldRetry?: (error: unknown, attempt: number) => boolean;
onRetry?: (error: unknown, attempt: number, delayMs: number) => void;
}
interface RetryResult<T> {
data: T;
attempts: number;
}
class RetryError extends Error {
constructor(
message: string,
public readonly attempts: number,
public readonly lastError: unknown
) {
super(message);
this.name = 'RetryError';
}
}
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
const calculateDelay = (
attempt: number,
baseDelayMs: number,
maxDelayMs: number,
jitterFactor: number
): number => {
const exponentialDelay = baseDelayMs * Math.pow(2, attempt - 1);
const cappedDelay = Math.min(exponentialDelay, maxDelayMs);
const jitter = cappedDelay * jitterFactor * Math.random();
return Math.floor(cappedDelay + jitter);
};
async function retryWithBackoff<T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<RetryResult<T>> {
const {
maxAttempts = 3,
baseDelayMs = 1000,
maxDelayMs = 30000,
jitterFactor = 0.1,
shouldRetry = () => true,
onRetry = () => {},
} = options;
if (maxAttempts < 1) {
throw new Error('maxAttempts must be at least 1');
}
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const data = await fn();
return { data, attempts: attempt };
} catch (error) {
lastError = error;
const isLastAttempt = attempt === maxAttempts;
const shouldAttemptRetry = !isLastAttempt && shouldRetry(error, attempt);
if (!shouldAttemptRetry) {
break;
}
const delayMs = calculateDelay(attempt, baseDelayMs, maxDelayMs, jitterFactor);
onRetry(error, attempt, delayMs);
await sleep(delayMs);
}
}
throw new RetryError(
`Operation failed after ${maxAttempts} attempts`,
maxAttempts,
lastError
);
}
// Example usage with a flaky API call
const fetchWithRetry = async (url: string): Promise<Response> => {
const { data } = await retryWithBackoff(
() => fetch(url).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res;
}),
{
maxAttempts: 5,
baseDelayMs: 500,
maxDelayMs: 10000,
jitterFactor: 0.2,
shouldRetry: (error, attempt) => {
if (error instanceof Error) {
const message = error.message;
const isRetryable = message.includes('HTTP 5') ||
message.includes('HTTP 429') ||
message.includes('fetch failed');
return isRetryable;
}
return false;
},
onRetry: (error, attempt, delayMs) => {
console.log(`Attempt ${attempt} failed, retrying in ${delayMs}ms...`);
},
}
);
return data;
};
export { retryWithBackoff, RetryOptions, RetryResult, RetryError };How It Works
This retry utility implements a robust pattern for handling transient failures in asynchronous operations. The exponential backoff strategy increases wait times between retries (1s, 2s, 4s, 8s...), which helps prevent overwhelming struggling services. The jitter factor adds randomness to delays, preventing the 'thundering herd' problem where multiple clients retry simultaneously after a service recovers.
The shouldRetry predicate is crucial for production use. Not all errors should trigger retries—you typically want to retry network timeouts, rate limits (HTTP 429), and server errors (5xx), but not client errors like 400 Bad Request or 401 Unauthorized. The predicate receives both the error and current attempt number, allowing sophisticated retry logic based on error type or degrading retry willingness over time.
The onRetry callback enables observability without coupling the retry logic to any specific logging framework. You can use it to emit metrics, log warnings, or trigger alerts. The function returns a RetryResult object containing both the successful data and the number of attempts made, which is useful for monitoring retry rates.
The RetryError class wraps the final failure with context about how many attempts were made and preserves the original error. This makes debugging easier and allows calling code to distinguish between immediate failures and exhausted retries. The max delay cap prevents exponential backoff from creating unreasonably long waits in extreme cases.
Use this pattern for external API calls, database connections, and any I/O operation that may experience transient failures. Avoid using it for operations that are deterministically failing (validation errors, missing resources) or where idempotency isn't guaranteed—retrying a non-idempotent operation could cause duplicate side effects. Always ensure the operation you're retrying is safe to repeat.