The Problem: Flaky Network Calls

Every production codebase eventually talks to an unreliable endpoint — a third‑party API, a micro‑service that occasionally times out, or a CDN that returns 5xx for a few seconds. Writing a try/catch around every fetch call clutters the business logic and makes retry policies inconsistent.

I’ve spent too many evenings debugging why a user’s dashboard stayed blank because a single transient failure wasn’t retried. The fix isn’t magic; it’s a small, composable utility that centralizes retry logic, respects AbortSignal, and backs off exponentially.

Design Goals

  • Declarative: call fetchWithRetry(url, options) and get a promise that either resolves with a successful response or rejects after the configured attempts.
  • Configurable: max attempts, base delay, jitter, and a predicate to decide which status codes are retryable.
  • Cancellation‑aware: honor an external AbortSignal so callers can abort a whole batch of requests.
  • Zero dependencies: plain ES2020, works in browsers and Node 18+.

The Core Implementation

/**
 * Fetch with automatic retry and exponential backoff.
 * @param {string|Request} input - URL or Request object.
 * @param {RequestInit & { retries?: number; baseDelay?: number; maxDelay?: number; jitter?: boolean; retryableStatuses?: number[]; signal?: AbortSignal }} init
 * @returns {Promise}
 */
export async function fetchWithRetry(input, init = {}) {
  const {
    retries = 3,
    baseDelay = 300,          // ms
    maxDelay = 5000,
    jitter = true,
    retryableStatuses = [408, 429, 500, 502, 503, 504],
    signal,
    ...fetchInit
  } = init;

  // If the caller supplied an AbortSignal, we attach a listener that
  // rejects the promise immediately when aborted.
  const abortPromise = signal
    ? new Promise((_, reject) => {
        const handler = () => reject(new DOMException('Aborted', 'AbortError'));
        signal.addEventListener('abort', handler, { once: true });
        // Clean‑up if the fetch finishes first.
        return () => signal.removeEventListener('abort', handler);
      })
    : Promise.never();

  let attempt = 0;
  while (true) {
    try {
      // Race the real fetch against the abort promise.
      const response = await Promise.race([
        fetch(input, { ...fetchInit, signal }),
        abortPromise
      ]);

      // If the response is ok or not in the retryable list, return it.
      if (response.ok || !retryableStatuses.includes(response.status)) {
        return response;
      }
      // Otherwise treat it as a failure that may be retried.
      throw new Error(`HTTP ${response.status}`);
    } catch (err) {
      attempt++;
      if (attempt > retries) {
        // No more attempts — surface the original error.
        throw err;
      }
      // Exponential backoff with optional jitter.
      const delay = Math.min(baseDelay * 2 ** (attempt - 1), maxDelay);
      const finalDelay = jitter ? delay * (0.5 + Math.random() * 0.5) : delay;
      await new Promise(r => setTimeout(r, finalDelay));
      // Loop continues for next attempt.
    }
  }
}

Why This Shape?

Separation of concerns — The wrapper only knows *how* to retry; it doesn’t embed business rules. Callers decide which status codes are retryable via retryableStatuses. This keeps the function pure and testable.

AbortSignal integration — Modern fetch already accepts a signal, but we also need to abort the *retry loop* itself. Wrapping the abort in a promise that races with fetch guarantees that a cancellation stops both the current request and any pending back‑off timers.

Exponential backoff + jitter — Pure exponential growth can cause thundering‑herd problems when many clients retry simultaneously. Adding a small random factor (0.5–1.0) spreads the load without sacrificing the quick‑retry benefit for transient glitches.

Configurable capsmaxDelay prevents a single request from holding a UI thread for minutes. The default 5 seconds works well for most user‑facing flows.

Real‑World Usage

Imagine a dashboard that loads three independent widgets. Each widget calls a different micro‑service. We want all three to retry independently, but we also want a “Cancel all” button when the user navigates away.

const controller = new AbortController();
const { signal } = controller;

async function loadWidget(url, label) {
  try {
    const res = await fetchWithRetry(url, {
      signal,
      retries: 4,
      baseDelay: 200,
      retryableStatuses: [429, 500, 502, 503, 504]
    });
    const data = await res.json();
    renderWidget(label, data);
  } catch (e) {
    if (e.name === 'AbortError') return; // silent cancellation
    showWidgetError(label, e.message);
  }
}

// Fire off all widgets concurrently.
Promise.all([
  loadWidget('/api/sales', 'Sales'),
  loadWidget('/api/inventory', 'Inventory'),
  loadWidget('/api/alerts', 'Alerts')
]);

// User clicks “Cancel” or navigates away.
function onCancel() {
  controller.abort();
}

Notice how the same AbortController cancels every in‑flight request *and* stops any pending retries. No extra bookkeeping required.

Testing the Wrapper

Because the function is pure aside from fetch and timers, unit tests are straightforward with a mock fetch and jest.useFakeTimers():

import { fetchWithRetry } from './fetchWithRetry';

beforeEach(() => {
  jest.useFakeTimers();
  global.fetch = jest.fn();
});

afterEach(() => {
  jest.useRealTimers();
  jest.restoreAllMocks();
});

test('retries on 503 then succeeds', async () => {
  global.fetch
    .mockResolvedValueOnce({ ok: false, status: 503 })
    .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) });

  const promise = fetchWithRetry('/test', { retries: 3, baseDelay: 100 });

  // Fast‑forward through the back‑off.
  await jest.runAllTimersAsync();
  const res = await promise;
  expect(res.ok).toBe(true);
  expect(global.fetch).toHaveBeenCalledTimes(2);
});

When Not to Use It

Don’t blanket‑retry idempotent‑unsafe operations (POST, PUT, DELETE) unless you’re certain the endpoint implements idempotency keys. The wrapper defaults to retrying only safe status codes, but you can still shoot yourself in the foot by adding 400 to retryableStatuses.

Also, for high‑throughput internal services where latency budgets are tight, a circuit‑breaker pattern may be more appropriate than simple retries.

Wrap‑Up

A tiny, well‑scoped utility like fetchWithRetry eliminates a whole class of flaky‑UI bugs and gives you a single place to tune retry policy across the codebase. It respects modern cancellation primitives, adds jitter to protect downstream services, and stays dependency‑free. Drop it into a shared utils package, import wherever you call fetch, and you’ll spend far less time chasing “it works on my machine” network issues.