p-retry: exponential backoff with a decision hook per failure
Retry a promise-returning or async function
At a glance
- What is it?
- p-retry is a small module that retries a promise-returning or async function with exponential backoff, and its real design is in the three hooks it exposes, which let you decide separately whether a failure should consume a retry, what to do before the next attempt, and whether to try at all. The other detail that matters is the rule that a TypeError is not retried unless it is a network error.
- Who is it for?
- Adopt p-retry if you call a flaky network API from Node and want a retry policy that does not retry programming errors by accident, because the TypeError rule alone saves you from a retry loop around a bug. Do not adopt it if you need a queue, a circuit breaker or a concurrency limit, since it retries one call and nothing else.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 35 days ago.
- What is it written in?
- Mainly JavaScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
One promise, retried, with a backoff
p-retry is for retrying a promise-returning or async function, and it does exponential backoff and supports custom retry strategies for failed operations.
The shape of it is small. pRetry(input, options) returns a Promise that is fulfilled when calling input returns a fulfilled promise. If calling input returns a rejected promise, input is called again until the maximum retries are reached, and then it rejects with the last rejection reason.
The input function receives the number of attempts as its first argument and is expected to return a Promise or any value, which means the same wrapper works for a function that is already asynchronous and one that returns a value.
The usage example is the canonical shape of the problem:
import pRetry, {AbortError} from 'p-retry';
const run = async () => {
const response = await fetch('https://sindresorhus.com/unicorn');
// Abort retrying if the resource doesn't exist
if (response.status === 404) {
throw new AbortError(response.statusText);
}
return response.blob();
};
console.log(await pRetry(run, {retries: 5}));The 404 case is the point of the example. A missing resource will never succeed on a second attempt, so the operation aborts immediately instead of burning the retry budget against a certain failure.
The package is MIT licensed, it is an ES module with no CommonJS build, and it requires Node 22 or later. The repository is small in the way a well-executed single-purpose package is small: index.js, index.d.ts, a test file, and a type test.
Why a TypeError is not retried
There is one rule in the library that deserves more attention than it usually gets, because it is the difference between a retry policy that helps and one that hides bugs.
p-retry does not retry on most TypeErrors, with the exception of network errors. The reason given is that this is done on a best case basis, because different browsers have different messages to indicate that a fetch was not successful, with a link to the whatwg/fetch discussion of the problem.
The consequence is stated separately and more strongly. Non-network TypeErrors always abort retries, even if shouldConsumeRetry or shouldRetry would otherwise allow another attempt.
That is a strong guarantee, and it is the right one. A TypeError in JavaScript is almost always a programming error, a typo in a property name, a call on undefined. Retrying it three times with exponential backoff does not fix it, it just delays the stack trace by thirty seconds and turns a clear failure into a timeout.
The single runtime dependency is the package that makes this possible, is-network-error, which is a small module whose job is to decide whether a TypeError came from the network layer or from your code. It is the entire dependency list of the package, and it exists for exactly this decision.
The same reasoning applies to the other default: an AbortError is never retried, and neither of the two hooks is even called for it. If you have decided that an operation must not be retried, no hook can talk you into it.
Three hooks, in a fixed order
The API exposes three callbacks and the order in which they run is part of the contract, because each one can prevent the next.
shouldConsumeRetry runs first, before onFailedAttempt and before shouldRetry. It decides if this failure should consume a retry from the retries budget. When false is returned, the failure does not consume a retry and does not increment the backoff values, but is still subject to maxRetryTime. The documented use is a rate limit error that should not eat your budget, since the example sets retries to 2 and returns false for a RateLimitError.
onFailedAttempt runs next. It is called after shouldConsumeRetry and before shouldRetry, for all errors except AbortError. It receives a context object with the error, the attempt number starting at 1, the retries left, the retries consumed, and the retry delay in milliseconds before the next attempt, which is based on minTimeout, factor, maxTimeout and randomize. The callback may return a promise, so you can add a delay of your own, for example awaiting the delay package for a second before the next attempt. If the callback throws, all retries are aborted and the original promise rejects with the thrown error.
shouldRetry runs last. Returning true triggers a retry and false aborts with the error. It is not called on AbortError, on TypeError except network errors, or if retries or maxRetryTime are exhausted. If it throws, all retries are aborted and the original promise rejects with the thrown error.
The order is what makes the design coherent. Deciding whether a failure counts, then observing it, then deciding whether to continue.
The knobs, and the ones that bite
The options are a short list, and each has a default that reveals an opinion.
retries is the maximum amount of times to retry the operation, must be a non-negative integer or Infinity, and defaults to 10. factor is the exponential factor and defaults to 2. minTimeout is the number of milliseconds before starting the first retry and defaults to 1000, and setting it to 0 retries immediately with no delay. maxTimeout is the maximum number of milliseconds between two retries and defaults to Infinity. randomize is a boolean defaulting to false, and when enabled it randomizes the timeouts by multiplying with a factor between 1 and 2.
Two of those deserve more attention than the rest. randomize is off by default, which means that by default every client retrying at the same moment hits the server at the same moment, and a brief outage becomes a synchronised stampede. Turning it on is a one-line change that most retrying systems need.
maxRetryTime is the maximum time in milliseconds that the retried operation is allowed to run, and it defaults to Infinity. It is measured with a monotonic clock, performance.now(), so system clock adjustments do not affect the limit, which is a small correctness detail that matters on a machine whose clock is corrected by NTP.
Then there is unref, a boolean defaulting to false, which prevents retry timeouts from keeping the process alive. It only affects platforms with an unref method on timeouts, such as Node.js, and it is what stops a script from hanging around because a retry timer is still pending after the work finished.
Aborting a retry in flight
Cancelling is a first-class feature, and it goes through the standard AbortSignal mechanism rather than something bespoke.
You can abort retrying using AbortController, and the example wires it to a cancel button:
import pRetry from 'p-retry';
const run = async () => { … };
const controller = new AbortController();
cancelButton.addEventListener('click', () => {
controller.abort(new Error('User clicked cancel button'));
});
try {
await pRetry(run, {signal: controller.signal});
} catch (error) {
console.log(error.message);
//=> 'User clicked cancel button'
}The reason you would want this is a long backoff. A retry that waits sixty seconds before its next attempt is a retry the user cannot stop, because the promise is not going to settle for a minute. Passing the signal means the wait ends when the user says so, and the error surfaces with the reason you gave.
The other abort route is the AbortError class, which is exported alongside the default. AbortError takes a message or an error, and throwing one from your operation tells p-retry to stop immediately rather than to treat the failure as retryable. The 404 example at the top of the README uses exactly that, and the reason to prefer it over a generic error is that the semantics are explicit: a thrown AbortError is never retried and never reaches the hooks, so you do not have to remember to check for it in shouldRetry.
makeRetriable, and the shape of the package
There is a second entry point for the case where you want a function rather than a call.
makeRetriable(function, options) wraps a function so that each call is automatically retried on failure:
import {makeRetriable} from 'p-retry';
const fetchWithRetry = makeRetriable(fetch, {retries: 5});
const response = await fetchWithRetry('https://sindresorhus.com/unicorn');That is the shape you want when the retry is a property of the operation rather than of the call site, for example a fetch wrapper passed around your application, so that every caller inherits the policy.
The package itself is deliberately minimal. The files list contains index.js and index.d.ts and nothing else, and the exports map points the types and the default at those two files. The sideEffects flag is false, which tells a bundler that importing the module has no side effects and can be dropped if unused, which matters for a package that ends up in front-end bundles by way of a dependency.
The development dependencies are the usual Sindre Sorhus set: xo for linting, ava for the tests, tsd for the type tests, and delay, which the documentation uses as the example of an async onFailedAttempt callback, and execa for running the test suite as a subprocess. The test script runs xo and then ava and then tsd, so the lint, the behaviour and the types are all checked in one command.
The one lint rule that is switched off is no-await-in-loop, which is the rule that would complain about awaiting inside the retry loop. It has to be off, because that loop is the entire point of the library.
Editorial conclusion
Adopt p-retry if you call a flaky network API from Node and want a retry policy that does not retry programming errors by accident, because the TypeError rule alone saves you from a retry loop around a bug. Do not adopt it if you need a queue, a circuit breaker or a concurrency limit, since it retries one call and nothing else. Before you start, decide whether a rate limit response should consume a retry, which is what shouldConsumeRetry is for, and set maxRetryTime rather than only a retry count, so that a slow but eventually successful call is not killed by a long backoff.
Frequently asked questions
What does p-retry do?
It retries a promise-returning or async function with exponential backoff and supports custom retry strategies. pRetry returns a promise that is fulfilled when the input resolves, calls the input again on rejection until the retry budget is exhausted, and then rejects with the last rejection reason.
Does p-retry retry TypeErrors?
Only network errors. p-retry does not retry on most TypeErrors, and non-network TypeErrors always abort retries even if shouldConsumeRetry or shouldRetry would otherwise allow another attempt, because a TypeError is normally a programming error rather than a transient failure.
What is the difference between shouldConsumeRetry, onFailedAttempt and shouldRetry?
shouldConsumeRetry runs first and decides whether the failure counts against the retry budget, so returning false keeps the backoff unchanged and the failure does not consume a retry. onFailedAttempt runs next and observes each failure, and may be async. shouldRetry runs last and decides whether to continue at all, and none of them is called for an AbortError or a non-network TypeError.
How do I limit the total time p-retry spends retrying?
The maxRetryTime option is the maximum time in milliseconds the retried operation is allowed to run, and it defaults to Infinity. It is measured with a monotonic clock through performance.now(), so system clock adjustments do not affect the limit, and the separate unref option prevents pending retry timers from keeping a Node.js process alive.
How do I stop a p-retry loop early?
Throw an AbortError from your operation, which is never retried and never reaches the hooks, or pass an AbortSignal through the signal option and abort it with an AbortController, which ends the pending backoff immediately and rejects with the reason you gave.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/sindresorhus-p-retry)Was this useful?