You know how to write async JavaScript that fetches data and chains operations. But what happens when the network drops, the server returns a 500, or the response shape is wrong? Without proper error handling, these failures crash your application or leave users staring at infinite loading spinners. Robust error handling is what separates a working demo from production-ready code.
The try/catch/finally structure
With async/awaitWhat is async/await?A syntax that lets you write asynchronous code (like fetching data) in a readable, step-by-step style instead of chaining callbacks., error handling uses the familiar try/catch syntax:
async function fetchUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Failed to fetch user:', error.message);
return null;
} finally {
console.log('Fetch attempt completed');
}
}catch (e) { console.log(e) } but never handle specific error types. A 401 (unauthorized) needs a redirect to login. A 422 (validation) needs to show field errors. A 500 (server error) needs a retry. Catching everything the same way means your app responds identically to completely different problems. Always check the error type and handle each case appropriately.What gets caught
| Error source | Example | Caught by catch? |
|---|---|---|
| Network failure | No internet, DNS error | Yes |
| Code error in try block | ReferenceError, TypeError | Yes |
| Thrown error | throw new Error(...) | Yes |
| Rejected awaited Promise | await failingPromise() | Yes |
| HTTP 404/500 from fetch | response.status === 404 | No, must check manually |
The fetch error handling trap
This is the biggest gotcha: fetch does not reject on 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. error status codes. A 404 response is a "successful" fetch.
// This will NOT catch a 404!
try {
const response = await fetch('/api/non-existent');
const data = await response.json(); // Might parse error HTML as JSON
} catch (error) {
// Only runs on network failure, not HTTP errors
}Always check response.ok:
async function safeFetch(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Fetch failed:', error.message);
return null;
}
}Custom error classes
For complex applications, generic Error objects are not enough. Create custom classes to distinguish between error types:
class NetworkError extends Error {
constructor(message) {
super(message);
this.name = 'NetworkError';
}
}
class AuthenticationError extends Error {
constructor(message) {
super(message);
this.name = 'AuthenticationError';
}
}
class ValidationError extends Error {
constructor(message, fields) {
super(message);
this.name = 'ValidationError';
this.fields = fields;
}
}Use them in an APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. client to throw specific errors:
async function apiClient(url, options = {}) {
try {
const response = await fetch(url, options);
if (response.status === 401) {
throw new AuthenticationError('Please log in again');
}
if (response.status === 422) {
const errors = await response.json();
throw new ValidationError('Validation failed', errors);
}
if (!response.ok) {
throw new NetworkError(`Server error: ${response.status}`);
}
return await response.json();
} catch (error) {
if (error instanceof AuthenticationError ||
error instanceof ValidationError ||
error instanceof NetworkError) {
throw error;
}
throw new NetworkError('Request failed: ' + error.message);
}
}Then handle each type differently:
try {
const user = await apiClient('/api/user');
displayUser(user);
} catch (error) {
if (error instanceof AuthenticationError) {
redirectToLogin();
} else if (error instanceof ValidationError) {
showFieldErrors(error.fields);
} else {
showGenericError(error.message);
}
}Error handling patterns
Retry 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.:
async function fetchWithRetry(url, maxRetries = 3) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
lastError = error;
if (error.message.includes('HTTP 4')) throw error; // Don't retry client errors
if (attempt < maxRetries - 1) {
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
}
}
}
throw lastError;
}Graceful degradation with cache fallback:
async function loadData() {
try {
const data = await fetchData();
saveToCache(data);
return data;
} catch (error) {
console.warn('Using cached data:', error.message);
const cached = getFromCache();
if (cached) return { ...cached, stale: true };
throw new Error('No data available offline');
}
}Common error handling mistakes
Empty catch blocks, errors vanish:
// Bad - error disappears silently
try { await fetchData(); } catch (e) { }
// Good - at least log the error
try { await fetchData(); } catch (error) {
console.error('fetchData failed:', error);
}Catching too broadly:
// Bad - hides programming errors
try { await riskyOperation(); } catch (e) { return null; }
// Good - only handle expected errors
try {
await riskyOperation();
} catch (error) {
if (error instanceof NetworkError) return null;
throw error; // Re-throw unexpected errors
}Forgetting async functions return Promises:
// Bad - unhandled rejection
fetchUserData(123);
// Good - handle the returned Promise
fetchUserData(123).catch(console.error);
// or
await fetchUserData(123);The finally block
finally executes whether the try succeeds or fails. Use it for cleanup that must always happen:
async function fetchWithLoadingState() {
showSpinner();
disableButton();
try {
const data = await fetchData();
displayData(data);
} catch (error) {
showError(error.message);
} finally {
hideSpinner(); // Always runs
enableButton(); // Always runs
}
}Quick reference
| Pattern | When to use |
|---|---|
try/catch around await | Every async operation that might fail |
if (!response.ok) | Every fetch call, HTTP errors are not thrown automatically |
| Custom error classes | When different errors need different handling |
finally block | Cleanup (spinners, button states, connections) |
| Re-throw unknown errors | When you only handle specific error types |
.catch() on Promise chains | When not using async/await |
| Retry with backoff | Network requests that might fail transiently |
async function safeFetch(url) {
try {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return await res.json();
} catch (error) {
console.error('Fetch failed:', error.message);
return null;
} finally {
console.log('Request completed');
}
}