JavaScript Fetch API: A Practical Guide for 2026

Fetch only rejects on network failures, not HTTP errors, so check response.ok yourself or you will parse a 404 page as data. It also has no built-in timeout. Wrap every real request in an AbortController with a setTimeout, and clear that timer in a finally block.
Part of the complete web development guide.
On this page
- The two things that trip people up first
- AbortController is not just for timeouts
- Retries: only when it is safe and only with backoff
- Reading the response body exactly once
- Streaming responses (worth knowing in 2026)
- Practical rules I follow
- FAQ
- Why does fetch not throw an error on a 404 or 500?
- What is the best way to add a timeout to fetch?
- Should I still use Axios or another library in 2026?
The fetch API has been in browsers for a decade now, and most tutorials still stop at the same five lines of .then() chaining. That is fine for a demo and useless the moment a request hangs, a server returns a 500 with an HTML error page, or you need to cancel a search as the user keeps typing. Here is how I actually use fetch in production code in 2026.
The two things that trip people up first
Fetch does not reject on HTTP errors. A 404 or a 500 resolves the promise normally. You have to check response.ok yourself, or you will happily parse an error page as if it were your data.
The second one: fetch has no timeout. If the server accepts the connection and then never responds, your promise sits there until the browser or OS gives up, which can be minutes. Every fetch call that touches a real network should have a timeout.
async function getJSON(url, { timeout = 8000, ...options } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url, { ...options, signal: controller.signal });
if (!res.ok) {
// Read the body once, then throw something useful.
const detail = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText}: ${detail.slice(0, 200)}`);
}
return await res.json();
} catch (err) {
if (err.name === "AbortError") {
throw new Error(`Request to ${url} timed out after ${timeout}ms`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
Note the finally block. If you forget to clear the timer, the timeout keeps running after a successful response, and in a long-lived page you end up with a pile of pending timers. It is a small leak, but it is a real one.
AbortController is not just for timeouts
The same signal that cancels a slow request also cancels a request you no longer care about. The classic case is a search box. The user types “web”, then “web d”, then “web dev” in under a second. Without cancellation you fire three requests and there is no guarantee they come back in order. The response for “web” can land last and overwrite the correct results.
let searchController;
async function runSearch(query) {
// Cancel whatever the previous keystroke started.
searchController?.abort();
searchController = new AbortController();
try {
const results = await getJSON(`/api/search?q=${encodeURIComponent(query)}`, {
signal: searchController.signal,
});
renderResults(results);
} catch (err) {
if (err.name !== "AbortError") showError(err);
}
}
One shared controller per input, aborted on each new call. That pattern alone fixes most “why is my autocomplete showing stale results” bugs.
Retries: only when it is safe and only with backoff
Blindly retrying is how you turn a hiccup into an outage. Two rules. First, only retry idempotent requests (GET, HEAD, PUT, DELETE) or requests you know are safe to repeat. Second, back off, and add jitter so every client is not hammering the server in lockstep.
async function fetchWithRetry(url, options = {}, attempts = 3) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await getJSON(url, options);
} catch (err) {
lastError = err;
// Do not retry client errors. The request itself is wrong.
const status = Number(err.message.match(/HTTP (d{3})/)?.[1]);
if (status && status >= 400 && status < 500) throw err;
if (i === attempts - 1) break;
// Exponential backoff with jitter: ~300ms, ~600ms, ~1200ms
const delay = 300 * 2 ** i * (0.5 + Math.random());
await new Promise((r) => setTimeout(r, delay));
}
}
throw lastError;
}
The 4xx check matters. A 401 or 422 will fail the same way on attempt three as it did on attempt one. Retrying it just wastes time and fills your logs.
Reading the response body exactly once
A response body is a stream. You can call .json(), .text(), or .blob(), but only one of them, and only once. If you log res.json() and then return res.json() again, the second call throws. This bites people in error paths, where they try to read the body for a message after already parsing it.
If you need both a parsed copy and the raw text, read the text first and parse it yourself:
const raw = await res.text();
const data = JSON.parse(raw);
console.log("raw response:", raw);
Streaming responses (worth knowing in 2026)
Streams are supported everywhere now, and for anything long-running (a chat response, a large export, a progress bar) they are the right tool. The body is a ReadableStream you can read chunk by chunk.
const res = await fetch("/api/stream");
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
processChunk(decoder.decode(value, { stream: true }));
}
The { stream: true } flag on the decoder is important. Without it, a multi-byte character split across two chunks gets mangled. That is a bug you will only notice when someone pastes an emoji or an accented character.
Practical rules I follow
- Wrap fetch once, in one helper, and use that everywhere. Scattered raw fetch calls are how timeouts and error handling get skipped.
- Always set a timeout. No exceptions for “internal” endpoints. Internal services go down too.
- Check
res.okbefore parsing. Never assume a 200. - Use
AbortControllerfor both timeouts and cancellations. Same mechanism, same cleanup. - Retry with backoff and jitter, and never retry a 4xx.
- On the server side (Node 18+), fetch is built in, so the same helper works in your API routes. Just remember Node’s fetch follows the same rules about not rejecting on HTTP errors.
For most projects this is all you need. You do not have to reach for a heavier HTTP client unless you want interceptors, request deduplication, or upload progress, and even then it is worth asking whether a 40-line helper gets you there first. If you are wiring this into a larger front end and want a second set of eyes on the architecture, that is the kind of work I do through custom web development for clients around Commack and the rest of Long Island.
FAQ
Why does fetch not throw an error on a 404 or 500?
By design, the promise only rejects on network failures (DNS, connection refused, CORS, aborts). HTTP status codes are a successful round trip, so you have to check response.ok and throw yourself if you want those treated as errors.
What is the best way to add a timeout to fetch?
Use an AbortController, call controller.abort() from a setTimeout, and pass controller.signal into the fetch options. Clear the timer in a finally block so it does not linger after a fast response.
Should I still use Axios or another library in 2026?
Only if you specifically need its features, like interceptors, automatic JSON transforms, or upload/download progress events. For typical JSON requests, a small wrapper around native fetch covers timeouts, retries, and cancellation with less code and no dependency to maintain.


