Skip to content
Commitpedia
Log in Join & edit

fetch with timeout, retries, and exponential backoff

A drop-in fetch wrapper that times out slow requests, retries network errors and 429/5xx responses with jittered exponential backoff, and honors Retry-After.

Edit history of fetch with timeout, retries, and exponential backoff: 1 recorded edit
Edit history · 1 edit
Type
Script
Edits
1
Editors
1
First issued
15 Oct 2026
Last edit
15 Oct 2026
Language
javascript
Licence
CC0-1.0

Plain fetch() waits as long as the network lets it and gives up after a single failure. Real clients need a timeout and a polite retry strategy. This module adds both without any dependencies.

The script

Save it as fetch-retry.mjs. It needs Node.js 20.3 or newer (for AbortSignal.any) or a current browser. Run it directly with a URL, or import fetchWithRetry into your own code.

// fetch-retry.mjs - fetch with a timeout, retries, and exponential backoff.
// Needs Node.js 20.3+ or a current browser. Usage: node fetch-retry.mjs URL

const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
const IDEMPOTENT = new Set(["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]);

function sleep(ms, signal) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(resolve, ms);
    signal?.addEventListener("abort", () => {
      clearTimeout(timer);
      reject(signal.reason);
    }, { once: true });
  });
}

function retryAfterMs(response) {
  const value = response.headers.get("Retry-After");
  if (!value) return null;
  const seconds = Number(value);
  if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
  const date = Date.parse(value);
  return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}

export async function fetchWithRetry(url, options = {}) {
  const {
    retries = 3,
    timeoutMs = 5000,
    baseDelayMs = 300,
    maxDelayMs = 5000,
    retryUnsafe = false,
    signal,
    ...init
  } = options;
  const method = (init.method || "GET").toUpperCase();
  const canRetry = IDEMPOTENT.has(method) || retryUnsafe;

  for (let attempt = 0; ; attempt++) {
    const attemptSignal = signal
      ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
      : AbortSignal.timeout(timeoutMs);

    let response;
    let error;
    try {
      response = await fetch(url, { ...init, method, signal: attemptSignal });
    } catch (err) {
      if (signal?.aborted) throw err;
      error = err;
    }

    const retryable = error || RETRYABLE_STATUS.has(response.status);
    if (!retryable || !canRetry || attempt >= retries) {
      if (error) throw error;
      return response;
    }

    const backoff = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
    const jittered = Math.random() * backoff;
    const wait = (response && retryAfterMs(response)) ?? jittered;
    const reason = error ? error.name : `HTTP ${response.status}`;
    console.error(`attempt ${attempt + 1} failed (${reason}); retrying in ${Math.round(wait)} ms`);
    await response?.body?.cancel();
    await sleep(Math.min(wait, maxDelayMs), signal);
  }
}

const runDirectly = typeof process !== "undefined" && process.argv?.[1] &&
  import.meta.url === new URL(`file://${process.argv[1]}`).href;

if (runDirectly) {
  const url = process.argv[2] ?? "http://127.0.0.1:8765/";
  const response = await fetchWithRetry(url, { retries: 4, timeoutMs: 2000 });
  console.log(response.status, response.statusText);
  console.log(await response.text());
}
node fetch-retry.mjs http://127.0.0.1:8765/status/503

Design decisions

  • Per-attempt timeout. AbortSignal.timeout() aborts a request that hangs. Each attempt gets a fresh timeout, so a slow first try does not eat the whole budget.
  • Caller cancellation wins. If you pass your own signal and abort it, the function stops immediately instead of retrying. AbortSignal.any() combines the two signals.
  • Only retry what is safe. Network errors and the statuses 408, 429, 500, 502, 503, and 504 are retried. Other 4xx codes mean the request itself is wrong, so retrying would only repeat the failure.
  • Only retry idempotent methods. Repeating a POST could create a duplicate order. Pass retryUnsafe: true only when your API supports idempotency keys or similar protection.
  • Backoff with full jitter. The delay ceiling doubles each attempt (300 ms, 600 ms, 1200 ms, and so on, capped at maxDelayMs), and the actual delay is random below that ceiling. Randomness keeps many clients from retrying in lockstep after an outage.
  • Retry-After is respected. Servers send it with 429 and 503 to say when to come back, as seconds or an HTTP date. The wait is still capped so a huge value does not stall your app.
  • Release the body. Cancelling the unused response body before retrying lets the connection be reused or freed.

Using it in your code

import { fetchWithRetry } from "./fetch-retry.mjs";

const controller = new AbortController();
const response = await fetchWithRetry("https://api.example.com/items", {
  headers: { Accept: "application/json" },
  retries: 3,
  timeoutMs: 4000,
  signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const items = await response.json();

Note that the function returns the final response even when it is an error status, just like fetch(). Check response.ok yourself. A timeout surfaces as an error named TimeoutError; a manual abort surfaces as AbortError or whatever reason you passed to abort().

Log lines go to console.error. In a library you would usually replace that with an onRetry callback so the caller decides what to log.

See also

  • 0018 Promises and async/await explained A promise is a placeholder for a value that arrives later. async/await lets you write code that waits for promises as if… Intermediate · Concept · Any system Article Async code 15 Oct 2026
  • 0019 fetch with timeout, retries and proper error handling A reusable fetchJson helper for Node 20+ and browsers that adds a timeout, retries with backoff for temporary failures, and clear errors… Intermediate · How-to · Any system Script Async code · Making requests 15 Oct 2026
  • 0023 Run async tasks with a concurrency limit A small mapLimit helper that runs an async function over many items with at most N in flight, keeps result order, stops… Advanced · How-to · Any system Script Async code 15 Oct 2026
  • 0081 How an HTTP request works: methods, headers, and body An HTTP request is a short text message a client sends to a server: a method and path, some headers, and sometimes… Beginner · Concept · Any system Article Making requests 15 Oct 2026
  • 0083 Test a JSON API with curl A runnable bash script showing the curl commands you need most when testing an API: GET, POST JSON, custom headers, Bearer tokens,… Beginner · How-to · Linux, macOS, Windows Script Authentication · Making requests 15 Oct 2026
  • 0084 Tiny JSON echo server for testing HTTP clients A standard-library Python server that echoes back the method, path, query, headers, and body of every request, and can return any status… Intermediate · How-to · Any system Script Files & automation · Making requests 15 Oct 2026

Repos in the wild

  • Requests: HTTP for humans psf/requests Requests is the classic Python library for calling web APIs and downloading pages, with a simple API for GET and POST requests, headers, JSON and sessions. GitHub
  • Axios axios/axios A promise-based HTTP client for browsers and Node.js with interceptors, automatic JSON handling, request cancellation and a consistent API in both environments. GitHub
  • curl and libcurl (curl/curl) curl/curl The source of curl, the command-line tool for transferring data with URLs, and libcurl, the C library behind it that many other programs use for HTTP. GitHub

History

Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.

  1. commitpedia-admin +5,279 chars

Report a problem

Something wrong, unsafe or copied? Tell the editors.
What is wrong?