p-retry vs async-retry vs exponential-backoff: Retry Strategies in Node.js (2026)
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
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 →