Here's a trap that catches almost every developer the first time: you write a fetch call, wrap it in a try/catch, and feel safe. But then a 404 slips through silently and your app breaks in a confusing way. Understanding the error model of fetch is one of the most important things you can learn about working with APIs.
The two kinds of errors
fetch errors fall into two completely separate categories, and they're handled differently.
Network errors happen when the request never reaches the server: no internet connection, DNSWhat is dns?The system that translates human-readable domain names like google.com into the numerical IP addresses computers use to find each other. failure, CORSWhat is cors?Cross-Origin Resource Sharing - a browser security rule that blocks web pages from making requests to a different domain unless that domain explicitly allows it. block, or a timeout. These cause the fetch() PromiseWhat is promise?An object that represents a value you don't have yet but will get in the future, letting your code keep running while it waits. to reject, which means your catch block fires.
HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. errors happen when the server responds, but with a bad status codeWhat is status code?A three-digit number in an HTTP response that tells the client what happened: 200 means success, 404 means not found, 500 means the server broke. (4xx or 5xx). These cause the fetch() Promise to resolve: the catch block does NOT fire. The server successfully communicated that something went wrong, which fetch treats as a successful communication.
try {
const response = await fetch('https://api.example.com/data');
// We got here even if the status is 404 or 500!
// response.ok tells us whether it was 200-299
} catch (networkError) {
// Only fires for: no internet, DNS failure, timeout, CORS
console.error('Network error:', networkError.message);
}This is why response.ok is not optional, it's how you catch HTTP errors.
| Error category | Cause | Promise behavior | How to catch |
|---|---|---|---|
| Network error | No internet, DNS, CORS, timeout | Rejects | try/catch or .catch() |
| HTTP error (4xx) | Client mistake (bad request, unauthorized) | Resolves | Check response.ok or response.status |
| HTTP error (5xx) | Server problem (crash, overload) | Resolves | Check response.ok or response.status |
| JSON parse error | Server returned non-JSON (HTML error page) | Throws in .json() | try/catch around response.json() |
try/catch and call it done. The catch block typically does catch (error) { console.log(error) }, catching everything generically but handling nothing specifically. A 401 (unauthorized) needs a redirect to login. A 404 needs a "not found" message. A 500 needs a retry. Lumping them all into one console.log is not error handling, it's error hiding.Handling HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. errors explicitly
Once you understand that 4xx and 5xx responses resolve normally, the fix is straightforward: check response.ok and throw if it's false.
const response = await fetch('https://api.example.com/users/999');
if (!response.ok) {
if (response.status === 404) {
throw new Error('User not found');
} else if (response.status === 401) {
// Redirect to login
window.location.href = '/login';
return;
} else if (response.status >= 500) {
throw new Error('Server error - try again later');
}
throw new Error(`Request failed with status ${response.status}`);
}
const user = await response.json();const errorData = await response.json() before throwing to extract a more useful message.A comprehensive fetch wrapper
Combining all the error cases into one reusable function keeps your call sites clean and ensures consistent error handling across your app.
async function fetchWithErrorHandling(url, options = {}) {
try {
const response = await fetch(url, {
...options,
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = { message: response.statusText };
}
const error = new Error(errorData.message || `HTTP ${response.status}`);
error.status = response.status;
error.data = errorData;
throw error;
}
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
throw new Error('Request timeout');
}
if (error.name === 'TypeError') {
throw new Error('Network error - check your connection');
}
throw error;
}
}AbortSignal.timeout(10000) is the modern way to add a timeout to a fetch call. If the request takes longer than 10 seconds, it throws an AbortError.
Retry logic with exponential backoffWhat is exponential backoff?A retry strategy where each attempt waits twice as long as the previous one, giving an overloaded server progressively more time to recover.
Some errors are transient, a server might be momentarily overloaded and recover in a second or two. Retrying automatically can save your users from having to hit refresh. But retrying too aggressively can make an overloaded server worse. Exponential backoff spaces out retries so the server has time to recover.
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
return await fetchWithErrorHandling(url, options);
} catch (error) {
lastError = error;
// Don't retry client errors (4xx) - those won't resolve by retrying
if (error.status >= 400 && error.status < 500) {
throw error;
}
// Wait before retrying: 1s, 2s, 4s
if (i < maxRetries - 1) {
const delay = Math.pow(2, i) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError;
}The Math.pow(2, i) * 1000 formula produces delays of 1000ms, 2000ms, 4000ms, doubling each time. This pattern is called exponential backoff and is a standard approach in production systems.
User-friendly error messages
Raw error messages like "HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. 403" mean nothing to a user. Map your errors to plain-English messages before they reach the UI.
function getErrorMessage(error) {
if (error.message.includes('Network error')) {
return 'Please check your internet connection and try again.';
}
if (error.status === 404) return 'The requested item was not found.';
if (error.status === 401) return 'Please log in to continue.';
if (error.status === 403) return 'You do not have permission to do that.';
if (error.status >= 500) return 'Something went wrong on our end. Please try again later.';
return 'An unexpected error occurred. Please try again.';
}
try {
const data = await fetchWithRetry('/api/data');
displayData(data);
} catch (error) {
showErrorToast(getErrorMessage(error));
}Quick reference
| Error type | Cause | How to catch |
|---|---|---|
| Network error | No internet, DNS, CORS, timeout | try/catch, Promise rejects |
| HTTP error (4xx/5xx) | Server returned error status | Check response.ok or response.status |
| JSON parse error | Server returned invalid JSON | try/catch around response.json() |
| Timeout | Request took too long | AbortSignal.timeout() + catch AbortError |