Why error handling matters
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.Copy
// ❌ No error handling — app crashes silently
async function getUsers() {
const response = await fetch("/api/users");
const users = await response.json();
return users;
}
// ✅ With error handling — app stays usable
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("Failed to load users:", error);
throw error;
}
}
try/catch/finally
The core pattern for handling errors in async code:Copy
async function loadUserProfile(userId) {
try {
// Code that might fail
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const user = await response.json();
return user;
} catch (error) {
// Runs if anything in try block throws
console.error("Error loading profile:", error.message);
return null; // Return a fallback value
} finally {
// Always runs — success or failure
console.log("Request finished");
}
}
- JavaScript
- Python
Copy
try {
const data = await riskyOperation();
} catch (error) {
console.error(error.message);
} finally {
cleanup();
}
Copy
try:
data = await risky_operation()
except Exception as error:
print(error)
finally:
cleanup()
catch block.
Two types of fetch errors
This is where people get confused.fetch can fail in two different ways:
1. Network errors — fetch itself throws
Copy
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);
}
2. HTTP errors — fetch succeeds but status is bad
Copy
const response = await fetch("/api/users/999");
// fetch succeeded — we got a response
// But the status is 404 (not found)
console.log(response.ok); // false
console.log(response.status); // 404
// We need to check manually and throw
if (!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.Handling both types
Copy
async function getUsers() {
try {
const response = await fetch("/api/users");
// Type 2: HTTP error (404, 500, etc.)
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
const error = new Error(errorBody.detail || `HTTP ${response.status}`);
error.status = response.status; // Attach status for easier handling later
throw error;
}
return await response.json();
} catch (error) {
// Type 1: Network error (no internet, CORS, etc.)
// Type 2: HTTP error (thrown above)
// Both are caught here
console.error("Request failed:", error.message);
throw error;
}
}
User-friendly error messages
Don’t show raw error messages to users. Translate them into something helpful:Copy
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.";
}
// Usage
try {
const users = await getUsers();
} catch (error) {
showNotification(getErrorMessage(error)); // User sees a friendly message
}
In React
Copy
function UserList() {
const [users, setUsers] = useState([]);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadUsers() {
try {
const response = await fetch("/api/users");
if (!response.ok) {
const err = new Error(`HTTP ${response.status}`);
err.status = response.status;
throw err;
}
const data = await response.json();
setUsers(data);
} catch (error) {
setError(getErrorMessage(error));
} finally {
setLoading(false);
}
}
loadUsers();
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p className="error">{error}</p>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
Re-throwing errors
Sometimes you want to handle an error and let the caller handle it too:Copy
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 part
try {
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.
Common mistakes
Swallowing errors silently
Swallowing errors silently
Copy
// ❌ Wrong: catching the error but doing nothing
async 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-throw
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("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
Not checking response.ok before parsing
Copy
// ❌ Wrong: parsing without checking status
async function getUser(id) {
const response = await fetch(`/api/users/${id}`);
return response.json(); // Might parse a 404 error page as JSON
}
// ✅ Correct: check status first
async 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
Using .catch() and try/catch together unnecessarily
Copy
// ❌ Confusing: mixing Promise .catch() with try/catch
async 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/await
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(error);
throw error;
}
}
When using async/await, stick with
try/catch. Don’t mix in .catch() — it adds confusion without any benefit.What’s next?
Error handling keeps your app stable. Now let’s make it feel fast with loading states — showing users what’s happening while requests are in flight.Loading states
Show users what’s happening during requests