Back to Blog
Web APIs2026-04-23

Modern Fetch API Patterns

Timeouts, retries, cancellation, and progress with the built-in fetch.

The native fetch API has caught up to dedicated HTTP libraries. With AbortController, AbortSignal.timeout, and streaming bodies, you rarely need axios anymore — but fetch's defaults are sharp-edged, and production code needs a handful of patterns layered on top.

First, the Error Model

Fetch rejects only on network failure: DNS errors, connection resets, CORS blocks, aborts. A 404 or a 500 is a *successful* fetch that resolves normally. Every wrapper must check res.ok (true for status 200–299) or res.status explicitly. Forgetting this is the single most common fetch bug, and it fails silently — the code happily parses an HTML error page as if it were data.

Timeouts via AbortSignal

- const res = await fetch(url, { signal: AbortSignal.timeout(5000) });

AbortSignal.timeout is built into all modern runtimes — no more setTimeout/clearTimeout/controller.abort() choreography, and no leaked timers when the request finishes early. Distinguish the failure modes when catching: a timeout rejects with a TimeoutError DOMException, a manual abort with AbortError, and a network failure with a TypeError. Retrying makes sense for the first and third; retrying a user-initiated abort is a bug.

Combining Signals

- const userAbort = new AbortController(); - const signal = AbortSignal.any([userAbort.signal, AbortSignal.timeout(10000)]); - fetch(url, { signal });

AbortSignal.any lets either source cancel the request — user navigation or deadline, whichever fires first. Pass the same userAbort.signal into every request belonging to one screen and a single abort() call cleans up on unmount; this composes with React effect cleanup and router navigation naturally.

Retry with Exponential Backoff

- async function fetchRetry(url, opts, retries = 3) { - for (let i = 0; i < retries; i++) { - try { - const res = await fetch(url, opts); - if (res.status < 500) return res; // success or 4xx: do not retry - } catch (e) { if (i === retries - 1) throw e; } - await new Promise(r => setTimeout(r, 2 ** i * 200 + Math.random() * 100)); - } - }

Three refinements separate this from a naive loop. Jitter (the random component) prevents synchronized retry storms when many clients fail together. 429 handling: respect the Retry-After response header instead of your own schedule. Idempotency: retrying GET, PUT, and DELETE is safe by contract; blindly retrying POST can double-charge a customer — either make the endpoint idempotent with an idempotency key header or do not retry it.

Streaming Responses

res.body is a ReadableStream, and response bodies are async-iterable in modern runtimes:

- for await (const chunk of res.body) { /* chunk is a Uint8Array */ }

Pipe through TextDecoderStream for incremental text — the pattern behind LLM token streaming and progressive rendering of large NDJSON exports. Two details: iterating consumes the body (call res.clone() first if something else needs it too), and download progress falls out for free by accumulating chunk.length against the content-length header.

Request Streaming

Chromium and recent Safari accept a ReadableStream as a request body:

- fetch('/upload', { method: 'POST', body: fileStream, duplex: 'half' });

The duplex: 'half' option is mandatory, and the connection must be HTTP/2 or later. Feature-detect and fall back to plain body upload — Firefox still lacks it. For upload *progress* specifically, XMLHttpRequest remains the only universal answer; wrap it once and move on.

A Production JSON Wrapper

- async function api(url, body) { - const res = await fetch(url, { - method: body ? 'POST' : 'GET', - headers: { 'content-type': 'application/json' }, - body: body && JSON.stringify(body), - signal: AbortSignal.timeout(8000) - }); - if (!res.ok) throw new Error(res.status + ' ' + res.statusText); - return res.status === 204 ? null : res.json(); - }

Fifteen lines cover 90% of frontend HTTP needs: JSON defaults, a deadline, real errors for non-2xx, and the 204 edge case that otherwise throws on empty-body parsing. Attach response details to the thrown error object in real code so callers can branch on status.

Smaller Sharp Edges

- fetch sends no credentials cross-origin by default; set credentials: 'include' deliberately, never reflexively - The HTTP cache participates: cache: 'no-store' for always-fresh data, cache: 'force-cache' for immutable assets - keepalive: true lets a request outlive its page — the fetch-based replacement for sendBeacon, with a 64KB body cap - Response bodies are one-shot; res.json() after res.text() throws. Clone early if needed

When You Still Want a Library

Interceptors, automatic request deduplication, and schema-validated responses are application concerns, not transport concerns — solve them with a thin module of your own before adopting a dependency. The cases that genuinely justify a library today: complex upload progress requirements, HTTP/2 connection tuning in Node (use undici directly), and organization-wide instrumentation where a shared client enforces tracing headers and consistent error taxonomy across dozens of teams.

The through-line: fetch gives you correct low-level primitives and no opinions. Encode your opinions — deadlines, retries, error shapes — in one small wrapper, and keep the rest of the codebase calling that.