<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/p-retry-vs-async-retry-vs-exponential-backoff-retry-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/p-retry-vs-async-retry-vs-exponential-backoff-retry-2026/raw.md -->
<!-- Source path: content/guides/p-retry-vs-async-retry-vs-exponential-backoff-retry-2026.mdx -->

---
og_image: "/images/guides/p-retry-vs-async-retry-vs-exponential-backoff-retry-2026.webp"
title: "p-retry vs async-retry vs exponential-backoff 2026"
description: "Compare p-retry, async-retry, and exponential-backoff for retrying failed async operations in Node.js. Exponential backoff, jitter, abort signals, and more."
date: "2026-03-09"
authors: ["team"]
tier: 2
tags: ["nodejs", "typescript", "api", "automation"]
---

## 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) from `5xx` (server bug) and network errors
- `AbortSignal` lets 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](https://github.com/sindresorhus/p-retry) — promise retry with backoff:

### Basic usage

```typescript
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

```typescript
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)

```typescript
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)

```typescript
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](https://github.com/vercel/async-retry) — callback/promise retry:

### Usage

```typescript
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

```typescript
// 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](https://github.com/coveo/exponential-backoff) — just the algorithm:

### Usage

```typescript
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)

```typescript
// 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 `numOfAttempts` semantics (vs `retries` = 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 →](https://www.pkgpulse.com)*

*See also: [cac vs meow vs arg 2026](/guides/cac-vs-meow-vs-arg-lightweight-cli-argument-parsers-2026) and [cheerio vs jsdom vs Playwright](/guides/cheerio-vs-jsdom-vs-playwright-html-2026), [archiver vs adm-zip vs JSZip (2026)](/guides/archiver-vs-adm-zip-vs-jszip-zip-archive-creation-2026).*
