You call an API with fetch, and instead of data you get a red “Failed to fetch” in the console, or a warning about an unhandled promise rejection, or nothing happens at all. Asynchronous errors confuse a lot of developers because the code looks correct and runs in the right order on the page, yet still fails. This guide explains what each common fetch and async error means and shows the exact pattern that handles them properly.
What “Failed to fetch” actually means
This error is misleading because it sounds like the server returned an error. It does not mean that. Failed to fetch means the request never successfully reached the server or never got a usable response back at all. The usual causes are a wrong or unreachable URL, no internet connection, the server being down, or, most commonly in development, a CORS block. Notice that a normal server error like a 404 or 500 does not trigger this. Fetch treats those as successful responses, which is its own source of confusion, covered below.
The CORS cause and how to recognize it
If your console shows a message mentioning CORS or “Access-Control-Allow-Origin” alongside the failed fetch, the request was blocked by the browser’s cross origin security policy. This happens when your page tries to fetch from a different domain that has not given permission for your origin to read its responses.
CORS is enforced by the browser, and the permission has to be granted by the server you are calling, not by your front end code. You cannot fix a true CORS block purely from JavaScript in the browser. The server must send the right headers, or you route the request through your own backend.
Practical ways to handle it: if you own the API, configure it to send the appropriate Access-Control-Allow-Origin header. If you do not own it, call it from your own server side code instead of directly from the browser, and have your front end talk to your server. During local development, some teams use a proxy for the same reason.
Unhandled promise rejection: missing catch
The word “in promise” is the clue. It means a promise was rejected and nothing caught the rejection. Fetch returns a promise, and if it fails without a .catch() or a try and catch around it, the error escapes unhandled. This code has no safety net:
fetch("/api/data")
.then(res => res.json())
.then(data => console.log(data));
// no .catch, so any failure is unhandled
Add a catch so failures are handled gracefully:
fetch("/api/data")
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error("Fetch failed:", err));
With async and await, wrap the call in try and catch instead:
async function loadData() {
try {
const res = await fetch("/api/data");
const data = await res.json();
console.log(data);
} catch (err) {
console.error("Fetch failed:", err);
}
}
The silent failure: not checking response.ok
This is the trap that catches almost everyone. Fetch only rejects on network failures. If the server responds with a 404 or 500 error, fetch considers that a successful response and does not throw. So your .catch never runs, and your code tries to use error data as if it were real:
const res = await fetch("/api/data");
// res.ok is false on a 404, but no error was thrown
const data = await res.json();
You have to check response.ok yourself and throw if it is false:
const res = await fetch("/api/data");
if (!res.ok) {
throw new Error("Server responded with status " + res.status);
}
const data = await res.json();
Now a 404 or 500 actually reaches your catch block instead of silently passing through.
Cannot read JSON when the response is not JSON
This very common error means you called res.json() but the server actually returned HTML, not JSON. The in the error is a giveaway that you received an HTML error page, often a 404 page, and tried to parse it as JSON. The fix is the response.ok check above, which stops you from parsing an error page, plus confirming the URL is correct and the endpoint really returns JSON.
The complete async fetch pattern
Putting every fix together, here is a fetch function that handles network failures, server error codes, and parsing in one clean structure. This pattern prevents all the errors above:
async function getData(url) {
try {
const res = await fetch(url);
// catch server error codes that fetch ignores
if (!res.ok) {
throw new Error("HTTP error, status " + res.status);
}
const data = await res.json();
return data;
} catch (err) {
// catches network failures, thrown status errors, and bad JSON
console.error("Could not load data:", err.message);
return null;
}
}
Calling it is then safe, because a failure returns null instead of crashing:
const data = await getData("/api/data");
if (data) {
// use the data
} else {
// show a friendly error message to the user
}
Frequently asked questions
Why does my fetch work in Postman but fail in the browser?
Because Postman is not a browser and does not enforce CORS. The browser does. If a request succeeds in Postman but gives a CORS or “Failed to fetch” error in the browser, the cause is almost certainly the cross origin policy, which only applies in the browser environment.
Why does my catch block never run even though the request failed?
Because fetch does not reject on HTTP error statuses like 404 or 500. It only rejects on network level failures. To make server error codes reach your catch, check response.ok and throw an error yourself when it is false.
Should I use async await or then and catch?
Both work and do the same thing. Async and await reads more like normal sequential code and is easier to follow once you have several steps, while then and catch is more compact for a single call. Pick one style and stay consistent within a project.
How do I show a loading state while fetching?
Set a loading flag to true before the fetch, then set it to false in a finally block so it always clears whether the request succeeded or failed. The finally block runs after both the try and the catch, which makes it the right place to stop a spinner.
Once you check response.ok, always include a catch, and remember that fetch ignores HTTP error codes, asynchronous errors become predictable. The complete pattern above handles the common cases and gives your users a clean failure instead of a broken page.

