Fetch timeout in JavaScript: AbortSignal.timeout and retry

fetch() has no timeout option. Pass signal: AbortSignal.timeout(ms), catch the TimeoutError, and retry network errors, 429 and 5xx with backoff.

5 min readSume
All posts

To set a fetch timeout, pass an abort signal in the options: fetch(url, { signal: AbortSignal.timeout(5000) }) gives up after 5 seconds and rejects with a TimeoutError DOMException. fetch() has no timeout option and no retry, so retries are a loop you write: resend on network errors, timeouts, 408, 429 and 5xx with backoff, wait for Retry-After, and resend a POST only when it carries an Idempotency-Key.

Fetch facts come from MDN's AbortSignal.timeout(), fetch() and RequestInit pages, Node.js's global objects page and the undici docs listed under Sources. Sume facts come from Jobs and results, Create a run, Errors and spend and Authentication, plus the current code of Sume's TypeScript SDK. All were read on 2026-09-28.

Does fetch have a default timeout?

Not as an option: MDN's list of fetch() options, RequestInit, offers signal to cancel a request and no timeout field. In Node.js, the built-in fetch is powered by a bundled undici, whose default dispatcher inherits undici's client defaults: headersTimeout and bodyTimeout of 300e3 milliseconds, so 300 seconds waiting for headers and 300 seconds between body chunks. process.versions.undici shows which undici your Node.js bundles.

AbortSignal.timeout() has worked across current browsers since April 2024 and exists in Node.js since v17.3.0 and v16.14.0. It counts active time, so the clock pauses while a page sits in the back-forward cache or a worker is suspended.

How do I retry a failed fetch?

Wrap it. fetch() resolves on HTTP error statuses such as 404 or 504 and rejects only when the request fails, so the loop has to check res.status as well as catch. This version gives each attempt its own timeout, backs off exponentially with ±20% jitter, waits Retry-After seconds when the server sends them, and never resends a POST that has no Idempotency-Key. It also cancels the body of a response it throws away: undici's README says to always consume or cancel a response body in Node.js, or connections can run out.

From MDN's fetch() and AbortSignal.timeout() pages and Sume's Errors and spend, read 2026-09-28.
What happenedWhat fetch doesRetry it?
Network failureRejects with a TypeErrorYes, if the request is safe to resend
Your timeout firedRejects with a TimeoutErrorYes, if the request is safe to resend
408, 429 or 5xxResolves; check res.statusYes, with backoff; wait Retry-After when sent
Any other 4xxResolves; check res.statusNo. At a Sume create, nothing ran and nothing was charged
You called abort()Rejects with an AbortErrorNo
async function fetchWithRetry(url, init = {}, { retries = 2, timeoutMs = 30_000 } = {}) {
  const method = (init.method ?? "GET").toUpperCase();
  const resendable =
    method === "GET" || method === "HEAD" || new Headers(init.headers).has("Idempotency-Key");
  for (let attempt = 0; ; attempt++) {
    let res;
    try {
      res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
      const transient = res.status === 408 || res.status === 429 || res.status >= 500;
      if (!transient || attempt >= retries || !resendable) return res;
      await res.body?.cancel(); // release the connection before retrying
    } catch (err) {
      // TimeoutError from the signal, or TypeError for a network failure
      if (attempt >= retries || !resendable) throw err;
    }
    const retryAfter = Number(res?.headers.get("retry-after"));
    const delayMs =
      retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt * (0.8 + Math.random() * 0.4);
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
}

Is it safe to retry a POST after a timeout?

Only with an idempotency key. A timeout ends your wait, not the work: Sume's docs say a client-side timeout does not cancel a generation job, which keeps running and billing, and that abandoning a poll loop does not stop a Format run or its spend. Resend the same body with the same key and Sume returns the original instead of billing a second one; for a Format run that is 200 with idempotency_hit: true. Build the body once as a string so every attempt sends the same bytes.

const res = await fetchWithRetry("https://api.sume.com/v1/formats/acme/weekly-promo/runs", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SUME_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "weekly-promo-2026-W40",
  },
  body: JSON.stringify({ input: { week: "2026-W40" } }),
});
const { data, error } = await res.json(); // 202: new run. 200: replay.

How long should the timeout be?

Long enough for the request, not for the work behind it. A Format run create answers immediately with a receipt while the run takes minutes, and a job submit that waits blocks for at most 30 seconds. Size the per-request timeout for that, then take the result by polling status_url or by webhook. If your own deadline passes, keep polling rather than submitting a new paid job; retry the submit itself only with the same key. Video generation API timeouts lists Sume's wait limits.

Where should this fetch code run?

On your server. Sume's docs say browser and mobile clients should call your backend, which attaches the API key, and that keys don't belong in frontend JavaScript. In TypeScript, @sume-com/sdk already wraps fetch this way in current code: a 10-minute AbortSignal.timeout per request by default, two retries of 408, 429, 5xx and transport failures with ±20% jittered backoff, retry-after honored up to 60 seconds, and a POST retried only when it carries an Idempotency-Key. See the Sume TypeScript SDK quickstart.

Sources

Related posts

More in Integrations

All Integrations posts

Written by Sume