Day 25/90: The fetch that "succeeded" on a 404
Day 25/90: The fetch that "succeeded" on a 404 90 Day JavaScript Challenge: Zero to Production The bug report was short: a user's saved settings kept silently resetting. The UI said "Saved!" every time. The data was n
Day 25/90: The fetch that "succeeded" on a 404
90 Day JavaScript Challenge: Zero to Production
The bug report was short: a user's saved settings kept silently resetting. The UI said "Saved!" every time. The data was not saved.
I traced it to a route rename. We had moved an endpoint, and one caller still hit the old path, so its request came back as a 404. The part that should have raised an alarm did nothing. Here is the function:
async function updateProfile(profile) {
const reply = await fetch("/account/profile", {
method: "PUT",
body: JSON.stringify(profile),
});
return reply.json();
}
No missing await, no missing return. The bug is an assumption I had carried for years: that fetch throws on a 404. It does not.
fetch rejects on network failure, not on error status
This is the rule that fixes everything once it clicks. fetch rejects its promise only when the network itself fails: DNS resolution failed, the connection dropped, the request was blocked, the host was unreachable. If the server answers at all, the promise resolves, even when the answer is a 404 or a 500.
Think of it as two separate questions:
- Did the server reply? That is what
fetchanswers. - Did the request succeed? That is what
response.okanswers.
A 500 is a completed, successful round trip that carries an error in its status line. fetch reports the round trip. So in my code, reply resolved fine, reply.json() parsed the error body, and the function returned garbage as if it were real data.
Check response.ok before touching the body
response.ok is true only for statuses 200 through 299. response.status gives the exact code. Read them before you read the body:
async function updateProfile(profile) {
const reply = await fetch("/account/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(profile),
});
if (!reply.ok) {
throw new Error(`Profile update rejected: ${reply.status}`);
}
return reply.json();
}
Now a 404 becomes a thrown error a caller can catch. Notice the Content-Type: application/json header too. Without it, many servers will not parse your JSON body, which is its own silent failure waiting to happen.
The body stream is read-once
The response body is a stream. You can consume it exactly once. This trips people in the error path: you read the error as text to log it, then try to parse it as JSON, and you get TypeError: body stream already read.
const reply = await fetch(url);
const text = await reply.text();
const json = await reply.json(); // throws: body already consumed
Pick one read per response. In an error path, prefer .text(), because error responses are frequently HTML or a bare string and would throw a second, more confusing error if you tried .json() on them.
Timeouts and cancellation with AbortController
fetch has no built-in timeout. A request that never answers hangs forever. AbortController is the escape hatch. You pass its signal into fetch, and calling abort() rejects the fetch promise with an AbortError:
async function getJson(url, timeoutMs = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const reply = await fetch(url, { signal: controller.signal });
if (!reply.ok) {
const body = await reply.text();
throw new Error(`HTTP ${reply.status}: ${body.slice(0, 120)}`);
}
return await reply.json();
} finally {
clearTimeout(timer);
}
}
The clearTimeout in finally is not optional. Without it, a fast response leaves a live timer that fires later and aborts nothing useful, or worse, in a reused controller, aborts the next request. Always pair the timer with a cleanup that runs no matter how the request ends.
The takeaway
fetch tells you the server replied. It does not tell you the request succeeded. Those are different questions, and the gap between them is exactly where a 404 disguises itself as a success. Build a wrapper that checks response.ok, reads the body once, sets the right headers, and times out, and you close that gap for every call in your app at once.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.