Transient failures are a fact of life in distributed systems and web applications. Network requests fail, databases lock, and APIs rate-limit. These five packages provide strategies to handle such failures by automatically retrying operations. They differ primarily in their age, programming model (callbacks vs promises), and configuration flexibility. Choosing the right one depends on whether you are working in a legacy callback environment or a modern async/await codebase, and how much control you need over the backoff strategy.
When building robust applications, handling transient failures is non-negotiable. Whether you are fetching data from an API or writing to a database, things will fail temporarily. The packages async-retry, backoff, exponential-backoff, promise-retry, and retry all solve this problem, but they approach it from different eras of JavaScript development. Let's compare how they handle execution, configuration, and errors.
The biggest divide in this group is between older callback-based tools and modern promise-based ones. This dictates how they fit into your codebase.
retry is the original. It uses callbacks for both the operation and the completion.
const retry = require('retry');
const operation = retry.operation({ retries: 3 });
operation.attempt(function(currentAttempt) {
fetchData(function(err, result) {
if (operation.retry(err)) {
return;
}
console.log(operation.mainError());
});
});
backoff also relies on callbacks, focusing on the backoff strategy itself.
const backoff = require('backoff');
const fibBackoff = backoff.fibonacci();
fibBackoff.failAfter(3);
fibBackoff.on('backoff', function(number, delay) {
// Try operation here after delay
});
fibBackoff.backoff();
promise-retry wraps the logic to return a promise, bridging the gap.
const promiseRetry = require('promise-retry');
promiseRetry(function(retry, number) {
return fetchData()
.catch(function(err) {
if (retry.isError(err)) {
throw err; // Triggers retry
}
throw err; // Fails immediately
});
}, { retries: 3 })
.then(function(result) {
console.log(result);
});
async-retry is built for async/await, offering the cleanest syntax for modern apps.
const retry = require('async-retry');
try {
const result = await retry(async (bail, number) => {
const res = await fetchData();
if (res.status === 500) throw new Error('Server Error');
return res;
}, { retries: 3 });
console.log(result);
} catch (err) {
console.error(err);
}
exponential-backoff typically provides the delay logic, often used within a custom async loop.
import { backoff } from 'exponential-backoff';
await backoff(async () => {
const res = await fetchData();
if (!res.ok) throw new Error('Failed');
return res;
}, {
maxNumberOfRetries: 3,
});
Controlling how long to wait between attempts is critical to avoid overwhelming services. Each package exposes these settings differently.
retry uses a timeouts array or factors to calculate delays.
const operation = retry.operation({
retries: 3,
factor: 2,
minTimeout: 1000,
maxTimeout: 5000
});
backoff lets you choose the strategy type (fibonacci, exponential) explicitly.
const exponentialBackoff = backoff.exponential({
initialDelay: 1000,
maxDelay: 5000
});
promise-retry passes options directly to the underlying retry logic.
promiseRetry(function(retry) {
return fetchData();
}, {
retries: 3,
factor: 2,
minTimeout: 1000
});
async-retry simplifies options to the most common needs.
await retry(async () => {
return fetchData();
}, {
retries: 3,
factor: 2,
minTimeout: 1000,
randomize: true // Adds jitter
});
exponential-backoff focuses heavily on the delay math, often including jitter by default.
await backoff(async () => {
return fetchData();
}, {
maxNumberOfRetries: 3,
startingDelay: 1000,
timeMultiple: 2
});
Knowing when to stop retrying is as important as retrying itself. Some packages let you bail out early.
retry requires you to check operation.retry(err) manually to decide continuation.
if (err.code === 'ENOTFOUND') {
operation.fail(err); // Stop immediately
return;
}
if (operation.retry(err)) return;
backoff emits events you listen to, allowing logic inside the event handler.
fibBackoff.on('fail', function(err) {
console.log('Stopped due to error', err);
});
promise-retry allows throwing specific errors to signal a hard failure.
.catch(function(err) {
if (err.code === 'CRITICAL') {
retry.cancel(); // Stop retries
}
throw err;
});
async-retry provides a bail function to stop retries immediately on non-retryable errors.
await retry(async (bail) => {
const res = await fetchData();
if (res.status === 404) {
bail(new Error('Not Found')); // Stops retries
return;
}
throw new Error('Retry me');
});
exponential-backoff usually relies on the promise rejection to stop, but some versions support abort signals.
const controller = new AbortController();
await backoff(async () => {
if (someCondition) controller.abort();
return fetchData();
}, {
signal: controller.signal
});
| Feature | retry | backoff | promise-retry | async-retry | exponential-backoff |
|---|---|---|---|---|---|
| Style | Callback | Callback | Promise | Async/Await | Async/Await |
| Age | Legacy | Legacy | Mature | Modern | Modern |
| Bail Out | Manual Check | Event Listener | Cancel Method | bail() Function | Abort Signal |
| Jitter | randomize | Custom | Inherited | randomize | Built-in |
| Best For | Legacy Node | Custom Strategies | npm Tooling | Frontend/Next.js | Custom Loops |
retry and backoff are the foundations. They are battle-tested but show their age with callback patterns. Use them only if you are maintaining older systems or need specific low-level control over the backoff algorithm itself.
promise-retry is the bridge. It is excellent for tooling and CLI apps where promises are needed but the retry logic is preferred. It is less common in frontend UI code.
async-retry is the frontend favorite. Its API is designed for modern JavaScript. The bail function is particularly useful for handling 4xx errors that should not be retried. It is the safest choice for React, Vue, or Next.js projects.
exponential-backoff is a specialist. Use it if you are writing your own retry logic and just need a robust delay calculator, or if you need specific jitter configurations not found in the others.
Final Thought: For most new frontend development, async-retry offers the best balance of simplicity and power. It removes the boilerplate of older libraries while providing the control needed for production-grade resilience.
Choose async-retry for modern frontend or Node.js projects using async/await. It is lightweight, promise-native, and maintained by Vercel, making it a safe bet for React and Next.js ecosystems where concise syntax is valued.
Choose backoff if you need fine-grained control over the backoff strategy itself, separate from the retry logic. It is best suited for older Node.js services that rely on callback patterns and require custom exponential or fibonacci strategies.
Choose exponential-backoff when you need a dedicated implementation of exponential backoff with jitter support without the overhead of a full retry wrapper. It is ideal for scenarios where you are building your own retry loop but need a reliable delay calculator.
Choose promise-retry if you are working in an environment that already uses the retry package but needs promise support. It is commonly found in npm internals and tooling, making it a solid choice for build scripts or CLI tools.
Choose retry only for maintaining legacy systems that rely on callback-based flows. It is the foundational package for many others but lacks native promise support, making it less suitable for new frontend development.
Retrying made simple, easy, and async.
// Packages
const retry = require('async-retry');
const fetch = require('node-fetch');
await retry(
async (bail) => {
// if anything throws, we retry
const res = await fetch('https://google.com');
if (403 === res.status) {
// don't retry upon 403
bail(new Error('Unauthorized'));
return;
}
const data = await res.text();
return data.substr(0, 500);
},
{
retries: 5,
}
);
retry(retrier : Function, opts : Object) => Promise
async or not. In other words, it can be a function that returns a Promise or a value.Function you can invoke to abort the retrying (bail)Number identifying the attempt. The absolute first attempt (before any retries) is 1.opts are passed to node-retry. Read its docs
retries: The maximum amount of times to retry the operation. Default is 10.factor: The exponential factor to use. Default is 2.minTimeout: The number of milliseconds before starting the first retry. Default is 1000.maxTimeout: The maximum number of milliseconds between two retries. Default is Infinity.randomize: Randomizes the timeouts by multiplying with a factor between 1 to 2. Default is true.onRetry: an optional Function that is invoked after a new retry is performed. It's passed the Error that triggered it as a parameter.