Error Handling
Manage and display errors gracefully from failed API requests.
In this article
Error Handling
Problem
An error message is bad, but a loading spinner that never stops, because nobody caught the error, is worse. Users wait, refresh, and wait again, without ever finding out that the request failed five seconds ago.
I’ve debugged many production issues where the console showed a clear error but users saw nothing helpful. Network timeouts, 500 errors, and malformed JSON all ended up as silent failures or the dreaded “something went wrong” message that helps nobody.
The inconsistency drives me crazy: one component retries automatically, another gives up immediately, a third shows an error with no retry button. Plenty of errors aren’t even logged, because someone swallowed them with an empty catch block.
Solution
My rule is that every async operation gets explicit error handling. That means wrapping it in try-catch, storing the error in state, and showing users something useful that explains what happened and what they can do.
Different errors also need different responses. A 404 means the resource doesn’t exist, and retrying won’t help, so show “not found.” A network timeout suggests a temporary issue, so offer a retry button. A 401 means the user needs to log in, so redirect them rather than showing a generic error.
Each error gets classified, handled for its category, and logged with enough context to debug later: user ID, action taken, request parameters. The message users see should tell them what to do, like “Check your connection and try again,” rather than cryptic developer output like “Error: undefined is not a function.”
Where you can, it’s better to degrade gracefully than to fail completely. If fresh data fails to load but you have cached data, you can show the cached data with a “Last updated 5 minutes ago” note.
Example
Here’s how I handle errors at different levels, from basic try-catch to classified errors with retry logic and graceful fallbacks.
Basic Error Handling
The basic version wraps async operations in try-catch and lets callers decide how to handle failures:
async function fetchUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
return await response.json();
} catch (error) {
console.error('Failed to fetch user:', error);
throw error; // Let caller decide how to handle
}
}
Error Classification and Recovery
Custom error classes let you tell error types apart and handle each one differently:
class APIError extends Error {
constructor(message, status, code) {
super(message);
this.status = status;
this.code = code;
}
}
async function fetchUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new APIError(errorData.message || 'Request failed', response.status, errorData.code);
}
return await response.json();
} catch (error) {
if (error instanceof TypeError) {
throw new APIError('Network error - check your connection', 0, 'NETWORK_ERROR');
}
if (error instanceof APIError) throw error;
throw new APIError('Unexpected error occurred', 500, 'UNKNOWN_ERROR');
}
}
Component with Error State
Storing errors in component state allows rendering helpful UI with retry options:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
async function loadUser() {
try {
setLoading(true);
setError(null);
setUser(await fetchUser(userId));
} catch (err) {
setError(err);
window.Sentry?.captureException(err, { tags: { userId } });
} finally {
setLoading(false);
}
}
loadUser();
}, [userId, attempt]);
if (loading) return <Spinner />;
if (error) return <ErrorDisplay error={error} onRetry={() => setAttempt(a => a + 1)} />;
return <div>{user.name}</div>;
} <script setup>
import { ref, onMounted } from 'vue';
const props = defineProps({ userId: [String, Number] });
const user = ref(null);
const loading = ref(true);
const error = ref(null);
async function loadUser(userId) {
try {
loading.value = true;
error.value = null;
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
user.value = await response.json();
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
onMounted(() => loadUser(props.userId));
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">
<p>{{ error }}</p>
<button @click="loadUser(props.userId)">Try again</button>
</div>
<div v-else>{{ user.name }}</div>
</template> Centralized Error Handler
A shared handler ensures consistent error logging and user messaging across the app:
class ErrorHandler {
constructor(options = {}) {
this.logger = options.logger || console;
this.onError = options.onError;
}
handle(error, context = {}) {
this.logger.error('Error:', { message: error.message, ...context });
window.Sentry?.captureException(error, { contexts: context });
this.onError?.(error, context);
return this.getUserMessage(error);
}
getUserMessage(error) {
if (error.code === 'NETWORK_ERROR') return 'Network connection failed';
if (error instanceof APIError) {
const messages = {
404: "The requested resource wasn't found",
401: 'Please log in to continue',
403: "You don't have permission to access this",
500: 'Server error - please try again later'
};
return messages[error.status] || error.message || 'Something went wrong';
}
return 'An unexpected error occurred';
}
}
Retry Logic with Exponential Backoff
Retrying transient failures with increasing delays often recovers from temporary issues:
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, options);
if (!response.ok) {
// Don't retry client errors (4xx)
if (response.status >= 400 && response.status < 500) {
throw new APIError('Request failed', response.status);
}
lastError = new APIError('Request failed', response.status);
} else {
return await response.json();
}
} catch (error) {
// Client errors (4xx) and the final attempt aren't retried
if (error instanceof APIError || attempt === maxRetries - 1) throw error;
lastError = error;
}
// Exponential backoff between attempts: 1s, 2s, ...
if (attempt < maxRetries - 1) {
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
}
}
throw lastError;
}
Error Boundary Integration
Error boundaries only catch errors thrown during rendering, so an async error has to be stored in state and rethrown during render to reach one:
import { ErrorBoundary } from 'react-error-boundary';
function DataComponent() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
fetchData().then(setData).catch(setError);
}, []);
// Rethrow during render so the nearest error boundary catches it
if (error) throw error;
return <div>{data?.value}</div>;
}
function App() {
return (
<ErrorBoundary fallback={<ErrorMessage />}>
<DataComponent />
</ErrorBoundary>
);
}
Global Error Handler
Catching unhandled errors at the window level serves as a safety net for errors that slip through:
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled rejection:', event.reason);
window.Sentry?.captureException(event.reason);
showToast('An unexpected error occurred', 'error');
event.preventDefault();
});
window.addEventListener('error', (event) => {
console.error('Global error:', event.error);
window.Sentry?.captureException(event.error);
});
Timeout Handling
AbortController lets you cancel requests that take too long rather than waiting indefinitely:
async function fetchWithTimeout(url, timeout = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') throw new Error('Request timeout');
throw error;
}
}
Graceful Degradation
Falling back to cached data when fetches fail lets users keep working with stale but useful data:
async function fetchUserWithFallback(userId) {
try {
return await fetchUser(userId);
} catch (error) {
console.warn('Fetch failed, using cache:', error);
const cached = getCachedUser(userId);
return cached ? { ...cached, stale: true } : { id: userId, name: 'User', stale: true };
}
}
Benefits
- Users see what went wrong instead of staring at frozen screens or infinite spinners.
- Transient failures become recoverable. A flaky connection results in a “Try again” button rather than a dead end.
- Production issues are much easier to debug when errors are logged with full context about user actions and request parameters.
- Even when the main API is down, cached data or a simplified experience keeps the app partly usable.
- Different error types get appropriate treatment: auth failures redirect to login, network errors offer retry, validation errors highlight specific fields.
- Users don’t lose work, because good error handling keeps form state and lets people retry without re-entering everything.
Tradeoffs
- Try-catch blocks add boilerplate, and the main logic can get buried under error handling code.
- Empty catch blocks like
catch (e) {}silence errors in the moment, but make production problems much harder to debug. - Every error type needs a decision: retry or fail, modal or inline, redirect or display. Those decisions take time.
- Centralized handlers can oversimplify; not every 500 error should trigger the same generic toast when some need specific responses.
- In React, async error handling is awkward: an error thrown from an async callback in useEffect doesn’t reach error boundaries, so you have to catch errors, store them in state, and either render the error UI yourself or rethrow during render.
- Error messages serve two audiences: friendly for users, detailed for developers. This often means two versions of every message.
- Aggressive logging floods monitoring tools with noise; not every transient failure needs to wake someone up at 3am.
- Cached data fallbacks can confuse users who don’t realize they’re seeing stale information. Staleness needs to be visually obvious.
Summary
Error handling turns failures that would crash the app into situations users can recover from, by catching exceptions, validating data, and giving users clear feedback. That means handling errors at the right boundaries, telling apart the errors users can fix from the ones that need technical intervention, and always offering a way forward.
See also
- Error Boundary — Resilience
- Loading State — Resilience
- Retry — Resilience
- Error Handling - MDN
- try...catch Statement - MDN
- Promise Error Handling - MDN