Fetch API Fundamentals
How fetch really works — Response.ok, one-shot bodies, JSON errors, AbortController, credentials, and production footguns.
- javascript
- fetch
- http
- async
- abortcontroller
fetch is the browser’s promise-based HTTP primitive. It replaced most XMLHttpRequest usage for new code, but it is not a high-level client: it does not throw on HTTP 404/500 by default, does not retry, and does not parse JSON until you ask.
If you only remember fetch(url).then(r => r.json()), you will ship silent failures and un-abortable spinners. Production-ready fetch is a short checklist, not a one-liner.
Promises and microtasks underneath: Promises, Event Loop.
The problem fetch solves
You need to load data or send mutations without full page navigation:
const res = await fetch('/api/profile');
const data = await res.json();
That happy path hides three sharp edges:
- Network failure vs HTTP error status
- Body consumption rules
- Cancellation when the user navigates away
Model: Request in, Response promise out
const response = await fetch(input, init);
// response is a Response — headers/status available
// body is a ReadableStream — read once via .json() / .text() / .blob() / …
| Piece | Role |
|---|---|
input |
URL string or Request object |
init |
method, headers, body, credentials, signal, cache, mode, … |
| Returned promise | Rejects on network failure / abort; fulfills on HTTP responses including 4xx/5xx |
response.ok |
status in 200–299 |
| Body methods | Async readers; each body is one-shot |
const res = await fetch('/api/items');
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const items = await res.json();
Never skip the ok check unless you intentionally handle non-2xx as data.
HTTP errors do not reject
// 404 still resolves the fetch promise
const res = await fetch('/api/missing');
console.log(res.status); // 404
console.log(res.ok); // false
// await res.json() might still parse an error payload
Rejection cases include DNS failure, offline, CORS hard failures, and abort. Treat status handling as your job.
Reading the body (once)
const res = await fetch('/api/data');
const text = await res.text();
// await res.json(); // throws — body already used
If you need two forms, clone first:
const res = await fetch('/api/data');
const clone = res.clone();
const asText = await res.text();
const asJson = await clone.json(); // only works if text was valid JSON
Or branch on Content-Type before choosing a reader.
JSON POST with correct headers
async function createItem(payload) {
const res = await fetch('/api/items', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const errBody = await res.text();
throw new Error(`Create failed: ${res.status} ${errBody}`);
}
return res.json();
}
Forgetting Content-Type on JSON bodies is a classic “server got empty fields” bug. FormData bodies should not set Content-Type manually — the browser sets the multipart boundary.
const form = new FormData();
form.append('file', fileInput.files[0]);
await fetch('/api/upload', { method: 'POST', body: form });
AbortController — cancel in-flight work
const controller = new AbortController();
const promise = fetch('/api/slow', { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(String(res.status));
return res.json();
});
// user navigates away or types a new query
controller.abort();
try {
await promise;
} catch (err) {
if (err.name === 'AbortError') {
// expected — not a product error toast
return;
}
throw err;
}
React effect pattern:
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(String(res.status));
const data = await res.json();
setResults(data);
} catch (err) {
if (err.name === 'AbortError') return;
setError(err);
}
})();
return () => controller.abort();
}, [query]);
Pair with useEffect Fundamentals. Related: AbortController.
Credentials and cookies
Default credentials is "same-origin" in modern browsers for fetch (verify your target matrix). Cross-origin cookie auth needs:
await fetch('https://api.example.com/me', {
credentials: 'include',
});
Server must allow the origin with explicit CORS (Access-Control-Allow-Credentials: true and a concrete Allow-Origin, not *). Misconfigured CORS shows up as a network failure in the console, not a JSON body.
CORS in one breath
Browsers enforce same-origin policy on frontends. Cross-origin fetch requires the server to opt in via CORS headers. Preflight (OPTIONS) happens for “non-simple” requests (custom headers, JSON content-type, etc.). You cannot fix CORS from client JS alone without a proxy.
Timeouts
fetch has no built-in timeout. Compose abort + timer:
function fetchWithTimeout(url, { timeoutMs = 8000, ...init } = {}) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
return fetch(url, { ...init, signal: controller.signal }).finally(() => {
clearTimeout(id);
});
}
If the caller also passes a signal, use AbortSignal.any([signal, controller.signal]) where supported, or wire abort listeners to merge signals.
Streaming and large responses
const res = await fetch('/api/large');
const reader = res.body.getReader();
// read chunks — useful for progress UIs
Most app code stops at .json(). Know streams exist for downloads and progressive parsing.
Parallel requests
const [user, posts] = await Promise.all([
fetch('/api/user').then((r) => {
if (!r.ok) throw new Error('user');
return r.json();
}),
fetch('/api/posts').then((r) => {
if (!r.ok) throw new Error('posts');
return r.json();
}),
]);
Promise.all fails fast; Promise.allSettled when partial UI is acceptable. See promise combinators on this site when you extend this pattern.
Footguns
- Assuming 404 rejects — check
res.ok/status. - Double-reading the body after logging
res.text()in a debug path. - No abort on unmount — state updates and wasted bandwidth.
- JSON.stringify without Content-Type.
- Catching AbortError as a red toast.
- Caching GET mutations — use proper methods; understand
cacheoption. - Leaky error messages — surfacing raw server HTML error pages to users.
- Mixing baseURL mental models — relative URLs resolve against the page URL, not your API host, unless you build absolute URLs.
Interview angle
Prompt: “How do you handle errors with fetch?”
Strong answer: “fetch only rejects on network failure or abort. HTTP error statuses still fulfill; I check response.ok or status ranges, then parse the body if useful. I use AbortController for cancellation and treat AbortError as non-fatal. Bodies are one-shot streams.”
Follow-ups: CORS, credentials, difference from Axios interceptors, how async/await sugar relates to .then chains.
Related on this site
- Promises — chaining and errors
- The Event Loop — when handlers run
- AbortController — cancellation deep dive
- useEffect Fundamentals — fetch-in-effect cleanup
- FormData and File Uploads — multipart bodies
- Cookies for Frontend Engineers — credentialed requests
Further reading
Related guides
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.
- FormData and File UploadsBuild multipart uploads with FormData, append files, inspect entries, and pair with fetch — progress and size limits included.
- PromisesSettlement, chaining, errors, Promise API helpers, and how promises plug into the microtask queue — without cargo-cult async.
- Array find, some, every, includesShort-circuiting array predicates: find, findIndex, some, every, and includes — when to use each and common interview traps.
- Array map, filter, reducemap, filter, and reduce as the core transform toolkit — immutability, chaining costs, reduce patterns, and when a plain loop is clearer.