TL;DR
p-retry is the most popular retry library for Node.js — wraps any async function, exponential backoff by default, integrates with AbortController, and has a clean onFailedAttempt hook. async-retry is the older callback-based alternative from npm — similar feature set with a different API. exponential-backoff is a minimal, framework-agnostic library focused on the backoff algorithm itself. In 2026: use p-retry for most Node.js projects — it's ESM-native, TypeScript-first, and well-maintained.
Key Takeaways
- p-retry: ~5M weekly downloads — ESM-native, wraps
p-*ecosystem, AbortController support - async-retry: ~5M weekly downloads — callback/promise style, used by many older packages
- exponential-backoff: ~8M weekly downloads — minimal, just the backoff algorithm, no retry orchestration
- Always add jitter to backoff — without it, many clients retry simultaneously ("thundering herd")
- Don't retry non-retriable errors — distinguish
4xx(your bug) from5xx(server bug) and network errors AbortSignallets callers cancel retry loops — critical for request timeouts
Why Retry?
Transient failures that ARE worth retrying:
- Network timeout (connection dropped, DNS hiccup)
- HTTP 429 (rate limited — back off and retry)
- HTTP 503 (server temporarily unavailable)
- HTTP 502/504 (gateway timeout — load balancer or upstream issue)
- Database connection pool exhausted (momentary)
- Disk I/O errors on cold storage
Failures that are NOT worth retrying:
- HTTP 400 (your request is malformed — retrying won't help)
- HTTP 401 / 403 (auth problem — fix credentials, don't retry)
- HTTP 404 (resource doesn't exist)
- Validation errors (same input = same error)
- Programming bugs (null reference, wrong types)
p-retry
p-retry — promise retry with backoff:
Basic usage
import pRetry, { AbortError } from "p-retry"
// Retry any async function:
const data = await pRetry(
async (attemptNumber) => {
console.log(`Attempt ${attemptNumber}`)
const response = await fetch("https://api.npmjs.org/downloads/point/last-week/react")
// Don't retry client errors (4xx):
if (response.status === 404) {
throw new AbortError(`Package not found: ${response.status}`)
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`) // Will be retried
}
return response.json()
},
{
retries: 3, // Total attempts = retries + 1 = 4
minTimeout: 1_000, // Initial delay: 1 second
maxTimeout: 10_000, // Max delay: 10 seconds
factor: 2, // Exponential factor (1s → 2s → 4s → 8s)
}
)
onFailedAttempt hook
import pRetry, { AbortError, FailedAttemptError } from "p-retry"
async function fetchNpmData(packageName: string) {
return pRetry(
async () => {
const res = await fetch(`https://api.npmjs.org/downloads/point/last-week/${packageName}`)
if (res.status === 429) {
const retryAfter = res.headers.get("Retry-After")
throw new Error(`Rate limited. Retry after: ${retryAfter}s`)
}
if (res.status >= 400 && res.status < 500) {
throw new AbortError(`Client error: ${res.status}`) // No retry
}
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
},
{
retries: 5,
onFailedAttempt(error: FailedAttemptError) {
console.warn(
`Attempt ${error.attemptNumber} failed. ` +
`${error.retriesLeft} retries left. ` +
`Error: ${error.message}`
)
},
}
)
}
With AbortController (timeout)
import pRetry from "p-retry"
async function fetchWithTimeout(url: string, timeoutMs = 30_000) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
return await pRetry(
async () => {
const res = await fetch(url, { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
},
{
retries: 3,
signal: controller.signal, // Abort retries when signal fires
}
)
} finally {
clearTimeout(timeout)
}
}
Retry with jitter (recommended for distributed systems)
import pRetry from "p-retry"
// p-retry has randomize option for jitter:
const result = await pRetry(
async () => callExternalApi(),
{
retries: 5,
minTimeout: 500,
maxTimeout: 30_000,
factor: 2,
randomize: true, // ✅ Adds jitter — spreads retries across time
// Without jitter: all failed clients retry at the same moment
// With jitter: retries spread out, reducing thundering herd
}
)
async-retry
async-retry — callback/promise retry:
Usage
import retry from "async-retry"
const data = await retry(
async (bail, attemptNumber) => {
// bail() cancels retries immediately:
const response = await fetch("https://api.npmjs.org/downloads/point/last-week/react")
if (response.status === 404) {
bail(new Error("Package not found")) // Equivalent to AbortError
return
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`) // Triggers retry
}
return response.json()
},
{
retries: 3,
minTimeout: 1_000,
maxTimeout: 10_000,
factor: 2,
randomize: true,
onRetry(error, attemptNumber) {
console.warn(`Attempt ${attemptNumber} failed: ${error.message}`)
},
}
)
Key difference from p-retry
// p-retry: throw AbortError to stop retrying
import { AbortError } from "p-retry"
throw new AbortError("Don't retry this")
// async-retry: call bail() to stop retrying
async (bail) => {
bail(new Error("Don't retry this"))
}
// Both achieve the same thing, just different patterns
exponential-backoff
exponential-backoff — just the algorithm:
Usage
import { backOff } from "exponential-backoff"
// Wraps any async function with exponential backoff:
const data = await backOff(
() => fetch("https://api.npmjs.org/downloads/point/last-week/react").then(r => r.json()),
{
numOfAttempts: 5, // Max 5 attempts
startingDelay: 500, // Start at 500ms
maxDelay: 15_000, // Cap at 15s
jitter: "full", // "full" | "none" | "truncated-exponential"
retry(error, attemptNumber) {
// Return false to stop retrying:
if (error.status >= 400 && error.status < 500) return false
console.log(`Attempt ${attemptNumber} failed: ${error.message}`)
return true
},
}
)
Built-in jitter strategies
jitter: "none"
delay = startingDelay * factor ^ attempt
→ Predictable but causes thundering herd
jitter: "full"
delay = random(0, startingDelay * factor ^ attempt)
→ Maximum spread, least predictable
jitter: "truncated-exponential" (recommended)
delay = random(startingDelay, min(maxDelay, startingDelay * factor ^ attempt))
→ Spreads retries while preserving minimum delay
Build Your Own (for simple cases)
// Sometimes the simplest solution is just a few lines:
async function withRetry<T>(
fn: () => Promise<T>,
{
attempts = 3,
delay = 1000,
factor = 2,
} = {}
): Promise<T> {
let lastError: Error
for (let i = 0; i < attempts; i++) {
try {
return await fn()
} catch (error) {
lastError = error as Error
if (i < attempts - 1) {
const jitter = Math.random() * delay * 0.3
await new Promise(r => setTimeout(r, delay * factor ** i + jitter))
}
}
}
throw lastError!
}
// Usage:
const data = await withRetry(() => fetch(url).then(r => r.json()), {
attempts: 3,
delay: 500,
})
Feature Comparison
| Feature | p-retry | async-retry | exponential-backoff |
|---|---|---|---|
| ESM native | ✅ | ❌ (CJS) | ✅ |
| TypeScript | ✅ | ✅ | ✅ |
| AbortSignal | ✅ | ❌ | ❌ |
| Cancel retry | AbortError | bail() | retry() → false |
| Jitter | ✅ (randomize) | ✅ (randomize) | ✅ (3 strategies) |
| onFailedAttempt | ✅ | onRetry | retry callback |
| Custom delays | ✅ | ✅ | ✅ |
| Weekly downloads | ~5M | ~5M | ~8M |
When to Use Each
Choose p-retry if:
- ESM project — p-retry is fully ESM-native
- Need AbortController/AbortSignal integration for request cancellation
- Using other
p-*utilities (p-limit, p-queue, p-map) for consistency - TypeScript-first development
Choose async-retry if:
- Existing codebase already uses it (many Vercel/Next.js internals)
- CommonJS project that hasn't migrated to ESM
- Familiar with callback-style
bail()pattern
Choose exponential-backoff if:
- Need fine-grained jitter strategy control
- Framework-agnostic code (works in browser, Node, Deno, Workers)
- Want explicit
numOfAttemptssemantics (vsretries= additional attempts)
Build your own if:
- Need only simple fixed-delay retries (3 lines of code)
- Can't add dependencies
- Very specific backoff logic not covered by libraries
Jitter Strategies and the Thundering Herd Problem
The most frequently overlooked aspect of retry logic is jitter, and it's also the most important for distributed systems. Without jitter, every client that experiences the same failure (a momentary outage at 14:32:05) will back off for the same duration and retry simultaneously at 14:32:06, 14:32:08, 14:32:12, and so on. This "thundering herd" creates a second wave of traffic spikes precisely when the service is trying to recover. AWS wrote a canonical blog post on this problem in 2015, and it remains the most common cause of retries making an outage worse rather than better.
p-retry's randomize: true option implements "full jitter": the actual delay is a random value between 0 and the calculated exponential backoff. This maximizes spread at the cost of minimum delay guarantees — a client might retry almost immediately if the random value is close to 0. exponential-backoff offers three named strategies: "none" (pure exponential, deterministic), "full" (same as p-retry's randomize), and "truncated-exponential" (random between startingDelay and the current exponential cap), which preserves a minimum delay floor. The truncated-exponential strategy is AWS's recommendation for most scenarios because it balances spread with a guaranteed minimum cooling-off period.
async-retry's randomize: true works identically to p-retry's — both delegate to the retry npm package which implements the same algorithm. The practical difference between the three libraries' jitter implementations is negligible for most use cases. Where the choice matters is in the granularity of control: exponential-backoff lets you name the strategy explicitly (making intent clear in code review), while p-retry and async-retry use a boolean that conflates "I want jitter" with "I want full jitter specifically."
Integrating with Rate Limiting and Retry-After Headers
HTTP APIs that return 429 Too Many Requests or 503 Service Unavailable often include a Retry-After header telling clients exactly how long to wait before retrying. Correctly handling this header requires custom delay logic that none of the three libraries implement by default, but all three support it through their configuration hooks.
With p-retry, the onFailedAttempt callback can read the response from the thrown error and modify the next delay by throwing a new error with a custom attemptNumber or by dynamically adjusting state outside the closure. A cleaner pattern is to use the retries option with a custom minTimeout that accounts for the Retry-After value: parse the header from the error, then set minTimeout to Math.max(defaultMin, retryAfterMs) before calling pRetry. With exponential-backoff, the retry callback receives the error object and can return a modified delay by throwing with a custom delay property.
For OpenAI, Anthropic, and other AI API clients that frequently return 429 errors due to token rate limits, a pattern worth standardizing on is: parse Retry-After (present in seconds), convert to milliseconds, apply a 10-20% jitter buffer on top (to avoid all clients waking up at exactly the same second), and use that as the minTimeout for the next attempt. The onFailedAttempt hook in p-retry is the cleanest place to implement this because it fires synchronously before the delay begins, giving you a chance to read the error and influence the next attempt's timing.
p-retry in the p-* Ecosystem
p-retry is part of Sindre Sorhus's p-* utility collection, which includes p-limit (concurrency limiting), p-queue (priority queue for async operations), p-map (parallel map with concurrency), and p-timeout (add timeout to any promise). These utilities compose naturally: wrapping p-map calls with p-retry, or using p-limit to constrain the concurrency of retried operations, follows consistent patterns.
The ecosystem-level reason to standardize on p-retry over async-retry is ESM. p-retry v10 is pure ESM — no CJS bundle — which aligns with Node.js's long-term direction and avoids the dual-package hazard. async-retry remains CJS-only. If your project is already ESM (using "type": "module" in package.json) or is migrating to ESM, p-retry integrates without any interop overhead. exponential-backoff ships both ESM and CJS bundles, making it the most flexible choice for libraries that need to support both module systems. For applications (not libraries), p-retry's ESM-only stance is a non-issue since you control the module system.
The AbortSignal integration in p-retry is worth highlighting as a differentiator. When a user cancels a request or a server-side timeout fires, propagating that cancellation through the retry loop prevents "ghost retries" that continue consuming resources after the caller has given up. p-retry's signal option wires directly into this: when the signal fires, any in-progress attempt is aborted and no further attempts are made. async-retry and exponential-backoff require you to implement this cancellation logic manually.
Testing Retry Logic Without Waiting
Writing reliable unit tests for retry behavior requires controlling time and failure injection. With p-retry, the recommended testing pattern is to pass a vi.fn() or jest.fn() as the operation, configure it to throw on the first N calls and succeed on the N+1th, and use Vitest's fake timers (vi.useFakeTimers()) to advance past the backoff delays without actually waiting. The minTimeout and maxTimeout options accept very low values (like 1ms) in tests so that retry loops complete synchronously relative to the test runner's event loop tick. A common pitfall is forgetting to restore real timers after the test, which causes subsequent tests to run with fake time and produce non-deterministic failures. With exponential-backoff, the same fake timer approach works, but you need to spy on setTimeout and advance timers by the expected computed delay per attempt.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on p-retry v10.x, async-retry v1.x, and exponential-backoff v3.x.
Compare utility and async control flow packages on PkgPulse →
See also: cac vs meow vs arg 2026 and cheerio vs jsdom vs Playwright, archiver vs adm-zip vs JSZip (2026).