fetch() has exactly one surprising rule that catches almost everyone at least once: it does NOT reject on HTTP error statuses. Everything else about the API — the two-step body-reading process, cancellation, headers — follows from understanding what a Response object actually represents.
fetchhttpresponseabortcontrollerfundamentals
Knowledge Check
4 questions · pass at 70%
0/4
easytrue false
1. True or false: fetch('/api/users/999') will reject its promise if the server responds with a 404 status.
Interview Questions
2 questions
Cheatsheet
Download-ready reference
Cheatsheet
const response = await fetch(url, options);
→ resolves once headers/status arrive — body NOT yet read
→ response.ok → true for status 200-299
→ response.status → the numeric HTTP status code
→ REJECTS ONLY on network failure (no connectivity, DNS, CORS, abort)
— NOT on 404/500/etc! Always check response.ok yourself.
const data = await response.json(); // SEPARATE async step, reads the body
→ body can only be read ONCE — use response.clone() for a second read
POST with a JSON body:
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
Cancellation:
const controller = new AbortController();
fetch(url, { signal: controller.signal });
controller.abort(); // fetch promise rejects with AbortError
Golden rule: fetch() succeeding means "a response arrived," not "the
request was successful." Those are two different things — check .ok.
The beginner framing: fetch(url) sends an HTTP request and returns a promise that resolves once the response starts arriving — you then read the actual data out of that response, usually as JSON.
The precise mental model: fetch()'s promise resolves as soon as the server sends back headers and a status code — not the full response body. The resolved value is a Response object, a handle to an in-progress or complete HTTP exchange, not the data itself. Reading the body is a second, separate async step (.json(), .text(), .blob(), etc.), because the body can be large and is streamed.
const response = await fetch("/api/users/1");// response is a Response object here — headers/status known, body NOT yet readconsole.log(response.status); // 200console.log(response.ok); // true (status in the 200-299 range)const user = await response.json(); // SECOND await — actually reads + parses the body
The one rule that catches almost everyone: fetch does NOT reject on HTTP errors#
const response = await fetch("/api/users/999"); // server responds 404 Not Foundconsole.log(response.ok); // falseconsole.log(response.status); // 404// fetch() did NOT throw or reject here — a 404 is a completely "successful" fetch// from the network's point of view: a response WAS received.
fetch()'s promise only rejects on genuine network failures — DNS failure, no connectivity, CORS blocking, the request being aborted. An HTTP 404, 500, or any other error status is, as far as fetch is concerned, a perfectly normal completed request. You must check response.ok (or response.status) yourself — this single rule is responsible for more silent bugs than any other part of the Fetch API.
async function getUser(id) { const response = await fetch(`/api/users/${id}`); if (!response.ok) { throw new Error(`Request failed: ${response.status} ${response.statusText}`); } return response.json();
This function has the classic fetch bug. Find it before checking the solution.
async function getUser(id) { const response = await fetch(`/api/users/${id}`); const user = await response.json(); return user;}const user = await getUser(999); // server returns 404 with an error JSON bodyconsole.log
Solution
getUser never checks response.ok. When the server returns a 404, fetch() still resolves successfully (it's not a network error) — the code happily calls .json() on the error response (which might be { error: "Not found" }, with no .name property) and returns it as if it were a valid user. The bug surfaces later, confusingly, at user.name, far from where the actual problem occurred.
async function getUser(id) { const response = await fetch(`/api/users/${id
fetch() alone has no built-in timeout — combining it with AbortController and setTimeout like this is the standard pattern for adding one, and it directly exercises the cancellation mechanism from this topic.
Data-fetching libraries (TanStack Query, SWR) exist largely to handle the parts of fetch() that are tedious to get right by hand every single time: request deduplication, caching, retries, race-condition handling (an older, slower request resolving AFTER a newer one and overwriting its result), and cancellation on unmount. A raw fetch() inside a useEffect is a completely valid starting point, but it's worth recognizing exactly which of those problems you're signing up to solve yourself — particularly the race condition, which needs the same cancelled guard flag pattern shown in Async/Await's "In React" section, or an AbortController tied to the effect's cleanup function.
Covered above — the single most important fetch habit. fetch resolves successfully for 404s and 500s alike.
2. Calling .json() on a body that isn't JSON, or reading the body twice#
const response = await fetch("/api/status"); // returns plain text "OK", not JSONconst data = await response.json(); // throws: Unexpected token 'O', "OK" is not valid JSON
const response = await fetch("/api/data");const text = await response.text();const json = await response.json(); // TypeError: body stream already read
A Response body can only be read once — its stream is consumed by the first .json()/.text()/etc. call. If you need the raw data in two forms, read it once and derive the second form yourself (e.g., JSON.parse(text)), or response.clone() before reading if you genuinely need two independent reads.
3. Forgetting that fetch sends no Content-Type header by default for a JSON body#
fetch("/api/users", { method: "POST", body: JSON.stringify({ name: "Ada" }), // ❌ missing headers — server may not parse this as JSON});
Without explicitly setting headers: { "Content-Type": "application/json" }, many server frameworks won't correctly parse the request body as JSON, even though you JSON.stringify'd it correctly on the client.
Always check response.ok before reading the body as if it were successful data.
Centralize fetch logic in a small wrapper function (like the getUser fix above) rather than repeating the response.ok check at every call site.
Use AbortController for any fetch tied to a component's lifecycle — cancel on unmount or when the relevant dependency changes, to avoid race conditions and wasted network usage.
Set Content-Type explicitly whenever sending a body, and don't assume the server will infer the format.
fetch() supports HTTP caching via the standard Cache-Control/ETag mechanisms automatically — you don't need to reinvent caching for GET requests the browser can already cache correctly; use the cache option ("no-store", "force-cache", etc.) to override when needed.
Reading a large response body with .json() parses the entire payload into memory at once. For genuinely large responses, response.body (a ReadableStream) allows processing data incrementally as it arrives, rather than waiting for the whole thing.
Always cancel fetches for data the user no longer needs (navigated away, closed a modal) — an uncancelled fetch keeps consuming bandwidth and, if its result eventually arrives, can cause the exact stale-overwrite race condition described in the React section above.
}
abort
();
// cancels the in-flight request; the fetch promise rejects with AbortError
(user.name);
// TypeError: Cannot read properties of undefined
}`
);
if (!response.ok) {
throw new Error(`Failed to fetch user ${id}: ${response.status}`);
}
return response.json();
}
Always check response.ok before treating a fetch as successful — this is the single most important habit for correct fetch usage.
return response;
} catch (err) {
if (err.name === "AbortError") {
throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);