Skip to main content

p-retry vs async-retry vs exponential-backoff: Retry Strategies in Node.js (2026)

·PkgPulse Team

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 — 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)
  }
}
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

Featurep-retryasync-retryexponential-backoff
ESM native❌ (CJS)
TypeScript
AbortSignal
Cancel retryAbortErrorbail()retry() → false
Jitter✅ (randomize)✅ (randomize)✅ (3 strategies)
onFailedAttemptonRetryretry 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

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.