I once spent three hours debugging why a feature silently broke in production. The API call failed, the error got swallowed by a .then() chain, and the UI just... showed nothing. No error state, no toast, no log. Just a blank section where data should've been.
That's when I stopped treating error handling as an afterthought and started treating it as architecture.
Most JavaScript error handling tutorials show you try/catch and call it a day. That's like teaching someone "use a hammer" and calling them a carpenter. Let me show you the patterns I actually use in production.
The Problem With Most Error Handling
Here's the code I see in most codebases:
async function loadUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
return user;
} catch (error) {
console.error(error);
}
}Three problems with this:
- The caller has no idea something failed. The function returns
undefinedon error — which means every caller now needs to check forundefinedAND handle the error state. console.errorin production is useless. Nobody's watching the console on your user's laptop.- It catches too much. A typo in
response.json()gets caught the same as a network failure.
Let me show you better patterns.
Pattern 1: Typed Error Classes
Stop throwing generic Error objects. Create error types that let your callers handle different failures differently:
class AppError extends Error {
constructor(message, { code, statusCode, context = {} } = {}) {
super(message);
this.name = this.constructor.name;
this.code = code;
this.statusCode = statusCode;
this.context = context;
this.timestamp = new Date().toISOString();
}
}
class NetworkError extends AppError {
constructor(message, context = {}) {
super(message, {
code: 'NETWORK_ERROR',
statusCode: 0,
context,
});
}
}
class ApiError extends AppError {
constructor(message, { statusCode, body } = {}) {
super(message, {
code: 'API_ERROR',
statusCode,
context: { body },
});
}
}
class ValidationError extends AppError {
constructor(message, fields = {}) {
super(message, {
code: 'VALIDATION_ERROR',
statusCode: 400,
context: { fields },
});
}
}Now your error handling becomes declarative:
try {
const user = await loadUser(userId);
} catch (error) {
if (error instanceof NetworkError) {
showToast('Check your internet connection');
} else if (error instanceof ApiError && error.statusCode === 404) {
router.push('/not-found');
} else if (error instanceof ApiError && error.statusCode === 401) {
router.push('/login');
} else {
// Unknown error — report to error tracking
captureException(error);
showToast('Something went wrong');
}
}Each error type maps to a specific user action. No guessing.
Pattern 2: The Safe Fetch Wrapper
Your API layer should be the most defensive code in your app. Here's the pattern I use everywhere:
async function safeFetch(url, options = {}) {
let response;
try {
response = await fetch(url, {
...options,
signal: options.signal ?? AbortSignal.timeout(10_000),
});
} catch (error) {
// Network-level failure (DNS, timeout, CORS, offline)
if (error.name === 'AbortError' || error.name === 'TimeoutError') {
throw new NetworkError(`Request to ${url} timed out`, { url });
}
throw new NetworkError(`Network failure: ${error.message}`, { url });
}
// HTTP error responses
if (!response.ok) {
let body;
try {
body = await response.json();
} catch {
body = await response.text().catch(() => null);
}
throw new ApiError(
body?.message ?? `HTTP ${response.status}`,
{ statusCode: response.status, body }
);
}
// Parse response
try {
return await response.json();
} catch (error) {
throw new ApiError('Invalid JSON response', {
statusCode: response.status,
body: null,
});
}
}Notice how each failure mode becomes a specific error type. The caller always knows what went wrong, not just that something went wrong.
The AbortSignal.timeout(10_000) is important — without it, a hung server means your UI hangs forever.
Pattern 3: Async Error Boundaries
In React, error boundaries only catch sync errors in lifecycle methods. They don't catch errors in event handlers or async code. Here's how I handle that:
function useAsyncAction() {
const [state, setState] = React.useState({
data: null,
error: null,
loading: false,
});
const execute = React.useCallback(async (asyncFn) => {
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const data = await asyncFn();
setState({ data, error: null, loading: false });
return data;
} catch (error) {
setState((prev) => ({ ...prev, error, loading: false }));
// Still throw so callers can react if they want
throw error;
}
}, []);
const reset = React.useCallback(() => {
setState({ data: null, error: null, loading: false });
}, []);
return { ...state, execute, reset };
}Usage in a component:
function UserProfile({ userId }) {
const { data: user, error, loading, execute } = useAsyncAction();
React.useEffect(() => {
execute(() => loadUser(userId));
}, [userId, execute]);
if (loading) return <Skeleton />;
if (error instanceof NetworkError) return <OfflineBanner />;
if (error instanceof ApiError && error.statusCode === 404) {
return <UserNotFound />;
}
if (error) return <GenericError error={error} />;
return <UserCard user={user} />;
}Every error state gets its own UI. No generic "something went wrong" banners for preventable errors.
Pattern 4: Retry With Exponential Backoff
Not all errors are permanent. Network failures on mobile are transient. A retry with backoff handles this gracefully:
async function withRetry(fn, {
maxAttempts = 3,
baseDelay = 1000,
shouldRetry = () => true,
} = {}) {
let lastError;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
// Don't retry client errors (4xx) — the server won't change its mind
if (error instanceof ApiError && error.statusCode >= 400 && error.statusCode < 500) {
throw error;
}
// Let caller decide
if (!shouldRetry(error, attempt)) {
throw error;
}
// Don't sleep on the last attempt
if (attempt < maxAttempts - 1) {
const delay = baseDelay * Math.pow(2, attempt);
// Add jitter to avoid thundering herd
const jitter = Math.random() * baseDelay;
await new Promise((resolve) => setTimeout(resolve, delay + jitter));
}
}
}
throw lastError;
}Real-world usage:
const data = await withRetry(
() => safeFetch('/api/dashboard'),
{ maxAttempts: 3, baseDelay: 500 }
);Key details:
- 4xx errors are not retried — the server rejected the request, retrying won't help
- Jitter prevents all clients from retrying simultaneously after a server blip
shouldRetrylets callers opt out of retry for specific errors
Pattern 5: AggregateError for Concurrent Failures
Promise.allSettled is great, but it doesn't tell you what failed in a useful way. Here's a pattern using AggregateError:
async function fetchAll(resources) {
const results = await Promise.allSettled(
resources.map(({ url, label }) =>
safeFetch(url).catch((error) => {
// Attach context so we know WHICH resource failed
error.context = { ...error.context, label };
throw error;
})
)
);
const fulfilled = [];
const errors = [];
for (const result of results) {
if (result.status === 'fulfilled') {
fulfilled.push(result.value);
} else {
errors.push(result.reason);
}
}
if (errors.length > 0 && fulfilled.length === 0) {
// Everything failed — throw aggregate error
throw new AggregateError(
errors,
`All ${errors.length} requests failed`
);
}
// Partial success — caller gets what worked + info about failures
return { data: fulfilled, partialErrors: errors };
}Usage:
const { data, partialErrors } = await fetchAll([
{ url: '/api/user', label: 'user' },
{ url: '/api/settings', label: 'settings' },
{ url: '/api/notifications', label: 'notifications' },
]);
// Show user + settings even if notifications failed
// Log partial failures for monitoring
partialErrors.forEach((error) => {
console.warn(`Failed to load ${error.context.label}:`, error.message);
});This is the pattern that prevents a single slow API from breaking your entire page.
Pattern 6: Global Error Safety Net
No matter how good your error handling is, something will slip through. Set up global handlers:
// Catch uncaught sync errors
window.addEventListener('error', (event) => {
// Ignore cross-origin script errors (they give no useful info)
if (event.message === 'Script error.') return;
captureException(event.error, {
tags: { source: 'uncaught_error' },
extra: {
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
},
});
});
// Catch unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
const error = event.reason;
// Prevent default console warning (we're handling it ourselves)
event.preventDefault();
captureException(error, {
tags: { source: 'unhandled_rejection' },
});
});Important: event.preventDefault() on unhandledrejection stops the console warning but also means you are responsible for making sure the error gets reported somewhere. Pair this with Sentry, LogRocket, or your own error tracking.
The Error Handling Decision Framework
When you're about to write error handling code, ask yourself:
| Question | Pattern |
|---|---|
| Is this a network call? | safeFetch wrapper |
| Can this fail temporarily? | withRetry |
| Multiple things happening at once? | fetchAll with partial success |
| Showing a UI for the error? | useAsyncAction hook |
| Need to distinguish error types? | Custom error classes |
| Is this a case I didn't anticipate? | Global safety net |
Common Mistakes I've Made (So You Don't Have To)
Swallowing errors silently:
// ❌ Returns undefined on error — caller can't tell failure from null data
async function getUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
return await res.json();
} catch {}
}If you catch an error, either handle it completely or re-throw it. Never do nothing.
Catching too broadly:
// ❌ Catches TypeError from a typo, not just API failures
try {
const user = await fetchUser(id);
cosnt name = user.name; // typo — this error gets swallowed too
} catch (error) {
showGenericError();
}Keep your try blocks small. Wrap only the code that can actually throw.
Using .catch() as a substitute for proper handling:
// ❌ Error goes to /dev/null
fetch('/api/track', { method: 'POST', body: data }).catch(() => {});For fire-and-forget requests, at least log failures in development:
fetch('/api/track', { method: 'POST', body: data }).catch((error) => {
if (process.env.NODE_ENV === 'development') {
console.error('Analytics failed:', error);
}
});Quick Reference
- Always throw typed errors —
new NetworkError()notnew Error('something broke') - Wrap only code that can fail in try/catch — keep blocks small
- Retry network failures with backoff and jitter, but never retry 4xx errors
- Use
Promise.allSettledfor concurrent requests — partial success is better than total failure - Set up global handlers as a safety net, not as your primary strategy
- Every error gets a user-visible response — even if it's just "try again"
Good error handling isn't about preventing errors. It's about making sure that when things break — and they will — your users aren't the ones who discover it first.