The fetch API and working with JSON
fetch's Promise only rejects for a genuine network failure — a 404 or a 500 response is still a SUCCESSFUL fetch as far as the Promise is concerned, which is the single most common real mistake in code that uses it.
3 min read
The gotcha that trips up nearly everyone: fetch doesn't reject on HTTP error status codes
// BROKEN assumption — this does NOT catch a 404 or 500 response
try {
const res = await fetch("/api/users/999"); // returns a 404 — fetch's promise still RESOLVES
const data = await res.json();
console.log(data); // whatever the error response body happens to contain, treated as success
} catch (err) {
console.log("never reached for a 404 or 500"); // only reached for a genuine NETWORK failure
}fetch's returned Promise rejects only for a genuine network-level failure — DNS failure, no connection, CORS blocking the request entirely — never for an HTTP response that simply has an error status code. A 404 Not Found or a 500 Internal Server Error is still a completed, successful HTTP exchange as far as fetch is concerned; the Promise resolves normally, with a Response object whose .ok is false and .status is 404/500. This is a genuinely common, real mistake: code that assumes a try/catch around fetch handles all error cases, when it actually only catches network failures.
The fix: explicitly check response.ok and throw if it's false
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
throw new Error(`Request failed: ${res.status} ${res.statusText}`); // now this DOES land in a try/catch
}
return res.json();
}Checking response.ok (a boolean, true for status codes 200–299) and manually throwing when it's false converts an HTTP error status into a genuine JavaScript exception that a surrounding try/catch (or .catch()) actually catches — this explicit check is the standard, necessary pattern for any real fetch usage, since relying on fetch's own rejection behavior alone silently misses the entire category of HTTP error responses.
response.json() is itself asynchronous, and can only be called once
const res = await fetch("/api/data");
const data = await res.json(); // ALSO returns a Promise — reading the body is itself an async operation
const bodyAgain = await res.json(); // TypeError — the response body stream was already consumed above.json() (and .text(), .blob()) parses the response body, which is itself an asynchronous operation (the body may still be streaming in) — it's easy to forget the second await here, since fetch(...) itself already returned a Promise. The body is also a stream that can only be read once: calling .json() a second time on the same Response throws, since the underlying stream has already been consumed — a real, common gotcha specifically when a response needs to be inspected in more than one place (logged for debugging, then also parsed for use).
Sending JSON: the request needs both the body AND the header
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" }, // tells the SERVER how to interpret the body
body: JSON.stringify({ name: "Ada", role: "engineer" }), // the body itself must be a STRING
});fetch's body option must be a string (or another specific type like FormData) — a plain JavaScript object passed directly is silently converted to the unhelpful string "[object Object]", not the JSON the server expects, which is why JSON.stringify() is a required step, not optional syntax. The Content-Type: application/json header is a separate, equally necessary piece: it's what tells the receiving server to actually parse the body as JSON — omitting it can cause a server framework to silently fail to parse a genuinely valid JSON body, since it never knew to interpret it that way in the first place.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why does a try/catch around a `fetch()` call fail to catch a 404 or 500 response?
2. What's the standard fix for fetch not throwing on HTTP error statuses?
3. Why does sending JSON with fetch require both `JSON.stringify()` on the body AND a Content-Type header?