javascript 93 lines · 2 tabs

Retry Failed Async Operations With Exponential Backoff and Jitter in Node.js

Shared by codesnips Aug 2026
2 tabs
'use strict';

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

function backoffDelay(attempt, baseMs, maxDelayMs) {
  const exponential = baseMs * 2 ** attempt;
  const capped = Math.min(exponential, maxDelayMs);
  // Full jitter in the lower half keeps a floor while spreading load.
  const jitter = 0.5 + Math.random() * 0.5;
  return Math.round(capped * jitter);
}

async function retry(operation, options = {}) {
  const {
    retries = 3,
    baseMs = 200,
    maxDelayMs = 5000,
    shouldRetry = () => true,
    onRetry = () => {},
  } = options;

  let lastError;

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      return await operation(attempt);
    } catch (err) {
      lastError = err;

      const isLastAttempt = attempt === retries;
      if (isLastAttempt || !shouldRetry(err, attempt)) {
        throw err;
      }

      const delay = backoffDelay(attempt, baseMs, maxDelayMs);
      onRetry({ error: err, attempt, nextDelayMs: delay });
      await sleep(delay);
    }
  }

  throw lastError;
}

module.exports = { retry, backoffDelay, sleep };
2 files · javascript Explain with highlit

This snippet shows a small, reusable retry helper for async operations in Node.js and a realistic caller that uses it to fetch from a flaky upstream API. The core idea is that transient failures — a dropped connection, a 503, a brief rate-limit — are best handled by waiting and trying again rather than failing the whole request. Naive retries hammer the failing service and can amplify an outage, so the delay grows exponentially with each attempt and a random jitter is added to prevent many clients from retrying in lockstep (the thundering-herd problem).

In retry.js, retry wraps any function returning a promise. It loops up to retries + 1 times, awaiting the operation inside a try/catch. On success it returns immediately. On failure it consults a shouldRetry predicate so callers can distinguish retryable errors (network blips, 5xx) from permanent ones (a 400 or a validation error) — retrying a permanent failure just wastes time. When the attempt cap is reached, the last error is re-thrown so the failure still surfaces.

The delay is computed by backoffDelay: baseMs * 2 ** attempt, clamped to maxDelayMs, then multiplied by a random factor between 0.5 and 1.0 for jitter. The sleep helper is a promisified setTimeout. An optional onRetry callback exposes each attempt for logging or metrics without coupling the helper to any logger.

In apiClient.js, fetchOrder passes a fetch closure to retry. It treats AbortError timeouts and 5xx responses as retryable via isRetryable, while a 4xx throws an ApiError that stops the loop. Each request uses AbortController with a per-attempt timeout so a hung socket does not block the whole budget.

The main trade-off is latency versus resilience: more retries improve success rates but extend worst-case response time, so retries, baseMs, and maxDelayMs should be tuned against a real timeout budget. This pattern fits idempotent reads and safe writes; non-idempotent operations need an idempotency key before retrying is safe. Keeping the mechanism (retry) separate from the policy (isRetryable) makes it easy to reuse across clients.


Related snips

Share this code

Here's the card — post it anywhere.

Retry Failed Async Operations With Exponential Backoff and Jitter in Node.js — share card
Link copied