Error Handling
Problem
You know what’s worse than seeing an error message? Staring at an infinite loading spinner because nobody caught the error. Users wait, refresh, wait again, never realizing the request failed five seconds ago.
I’ve debugged countless 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 really drives me crazy: one component retries automatically, another gives up immediately, a third shows an error with no retry button. Half the errors aren’t even logged because someone swallowed them with an empty catch block.
Solution
My fundamental rule is simple: every async operation needs explicit error handling, no exceptions. Wrap async operations in try-catch, store errors in state, and show users something useful that explains what happened and what they can do.
The key insight is that not all errors are the same. 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.
Classify errors and handle each category appropriately, logging them with enough context to debug later: user ID, action taken, request parameters. Show actionable messages like “Check your connection and try again” rather than cryptic developer output like “Error: undefined isn’t a function.”
Whenever possible, degrade gracefully rather than failing completely. If fresh data fails but you have cached data, show it with a “Last updated 5 minutes ago” note. Something useful beats nothing at all.
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 foundation is wrapping async operations in try-catch and letting 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 distinguish between error types and handle each appropriately:
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);
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]);
if (loading) return <Spinner />;
if (error) return <ErrorDisplay error={error} onRetry={() => window.location.reload()} />;
return <div>{user.name}</div>;
} <script setup>
import { ref, onMounted } from 'vue';
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 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';
}
if (error.code === 'NETWORK_ERROR') return 'Network connection failed';
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) {
lastError = error;
if (attempt === maxRetries - 1) throw error;
}
// Exponential backoff: 1s, 2s, 4s
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
}
throw lastError;
}
Error Boundary Integration
Error boundaries catch render errors while async errors need explicit handling:
function DataComponent() {
const [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData).catch(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 keeps users moving with stale but useful information:
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 debugging becomes possible when errors are logged with full context about user actions and request parameters.
- Partial functionality beats total failure; even when the main API is down, cached data or a simplified experience keeps users moving.
- Different error types get appropriate treatment: auth failures redirect to login, network errors offer retry, validation errors highlight specific fields.
- Users stop losing work because proper error handling preserves form state, letting 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 create invisible debugging nightmares in production. - Every error type needs thoughtful consideration: retry or fail? Modal or inline? Redirect or display? These decisions take real time to make.
- 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. You can’t throw from useEffect and expect error boundaries to catch it. You must catch errors, store them in state, and render error UI yourself.
- 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 at 3am.
- Cached data fallbacks can confuse users who don’t realize they’re seeing stale information. Always make staleness visually obvious.
Summary
Error handling transforms failures from crashes into recoverable situations by catching exceptions, validating data, and providing meaningful feedback to users. Handle errors at appropriate boundaries, distinguish between errors users can fix and those requiring technical intervention, and always offer a path forward. Good error handling is invisible when things work and invaluable when they don’t.