Modern Fetch API Patterns
Timeouts, retries, cancellation, and progress with the built-in fetch.
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.