Network requests fail. Servers go down. Users lose internet. APIs return unexpected data. If you don’t handle errors, your app shows a blank screen or crashes silently.
// ❌ No error handling — app crashes silentlyasync function getUsers() { const response = await fetch("/api/users"); const users = await response.json(); return users;}// ✅ With error handling — app stays usableasync function getUsers() { try { const response = await fetch("/api/users"); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.json(); } catch (error) { console.error("Failed to load users:", error); throw error; }}
try { // These throw immediately — no response at all const response = await fetch("/api/users");} catch (error) { // TypeError: Failed to fetch // Causes: no internet, DNS failure, CORS blocked, server unreachable console.error("Network error:", error.message);}
const response = await fetch("/api/users/999");// fetch succeeded — we got a response// But the status is 404 (not found)console.log(response.ok); // falseconsole.log(response.status); // 404// We need to check manually and throwif (!response.ok) { throw new Error(`HTTP ${response.status}`);}
fetch only throws on network failures. A 404 or 500 response is not an error from fetch’s perspective — it successfully received a response. Always check response.ok.
Don’t show raw error messages to users. Translate them into something helpful:
function getErrorMessage(error) { // Prefer structured data when available const status = error.status; // Network errors // Browser messages vary ("Failed to fetch", "NetworkError", etc.) if (!status && error.name === "TypeError") { return "Can't connect to the server. Check your internet connection."; } // HTTP errors if (status === 401 || error.message.includes("401")) { return "Please log in to continue."; } if (status === 403 || error.message.includes("403")) { return "You don't have permission to do that."; } if (status === 404 || error.message.includes("404")) { return "The item you're looking for doesn't exist."; } if (status === 500 || error.message.includes("500")) { return "Something went wrong on our end. Please try again."; } // Fallback return "Something went wrong. Please try again.";}// Usagetry { const users = await getUsers();} catch (error) { showNotification(getErrorMessage(error)); // User sees a friendly message}
Sometimes you want to handle an error and let the caller handle it too:
async function getUsers() { try { const response = await fetch("/api/users"); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.json(); } catch (error) { console.error("API error:", error); // Log it throw error; // Re-throw so the caller can handle it too }}// Caller handles the user-facing parttry { const users = await getUsers(); renderUserList(users);} catch (error) { showErrorBanner(getErrorMessage(error));}
Re-throw errors when lower-level code shouldn’t decide what the user sees. Log the error for debugging, then throw it up to the component that can show a user-friendly message.
// ❌ Wrong: catching the error but doing nothingasync function getUsers() { try { const response = await fetch("/api/users"); return await response.json(); } catch (error) { // Error is caught but nobody knows about it // App shows blank screen — no data, no error message }}// ✅ Correct: handle or re-throwasync function getUsers() { try { const response = await fetch("/api/users"); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.json(); } catch (error) { console.error("Failed:", error); throw error; // Let the caller handle it }}
An empty catch block is almost always a bug. If you catch an error, either handle it (show a message, return a fallback) or re-throw it. Silent failures are the hardest bugs to find.
Not checking response.ok before parsing
// ❌ Wrong: parsing without checking statusasync function getUser(id) { const response = await fetch(`/api/users/${id}`); return response.json(); // Might parse a 404 error page as JSON}// ✅ Correct: check status firstasync function getUser(id) { const response = await fetch(`/api/users/${id}`); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.detail || `HTTP ${response.status}`); } return response.json();}
Using .catch() and try/catch together unnecessarily
// ❌ Confusing: mixing Promise .catch() with try/catchasync function getUsers() { try { const response = await fetch("/api/users").catch(err => { throw err; // Unnecessary — try/catch already handles this }); return response.json(); } catch (error) { console.error(error); }}// ✅ Clean: just use try/catch with async/awaitasync function getUsers() { try { const response = await fetch("/api/users"); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.json(); } catch (error) { console.error(error); throw error; }}
When using async/await, stick with try/catch. Don’t mix in .catch() — it adds confusion without any benefit.