A fetch() call returned a 500. The textbook try/catch wrapping it never fired. The user saw a blank state. The error was in production for two weeks before anyone noticed — not because the code was wrong, exactly, but because try/catch quietly dropped it on the floor.
That is the uncomfortable truth most javascript error handling patterns articles dance around: try/catch is necessary but not sufficient. The syntax you already know is the foundation, not the ceiling. What you actually need are three production patterns that catch the errors silently lost in most codebases — and 30 lines of code to install them.
JavaScript Error Handling Patterns: What try/catch Can’t Catch
Three blind spots. Name them out loud and the rest of the article maps cleanly.
Blind spot 1: unhandled promise rejections. Any Promise without a .catch() — or a Promise that rejects after the await chain ends, or one created in a place where no one is awaiting it — bypasses your try/catch entirely. It fires the browser’s unhandledrejection event and then dies in the console where nobody is looking.
Blind spot 2: rendering errors. A React component that throws during render unmounts the whole tree. Your try/catch in an event handler does not see it. Your try/catch around ReactDOM.render does not see it. By the time the error happens, the call stack the catch belongs to has long since returned.
Blind spot 3: errors inside callbacks, timers, and listeners. Wrap setTimeout(cb, 1000) in a try/catch. The catch frame is gone the moment that line returns. When cb throws a second later, your handler never runs.
The canonical example of blind spot 1 is the fetch() from the hook. response.ok is false on 4xx and 5xx. fetch() only throws on network failures — DNS, CORS, offline. So you wrote this:
try {
const res = await fetch('/api/user');
const data = await res.json();
return data;
} catch (err) {
reportError(err);
}
You parse JSON from a 500 response. You return whatever shape that produces. Your catch never runs. Your UI shows a blank state. Your monitoring stays quiet.
Three gaps named. One pattern each, plus how they compose. Let’s start with the cheapest one.
Pattern 1: A Global Unhandled Rejection Handler
Register two listeners once at app bootstrap. That is the entire pattern.
export function installGlobalErrorHandlers({ report }) {
window.addEventListener('unhandledrejection', (event) => {
const error = event.reason instanceof Error
? event.reason
: new Error(typeof event.reason === 'string' ? event.reason : JSON.stringify(event.reason));
report(error, { layer: 'global', kind: 'unhandled-rejection', route: location.pathname });
event.preventDefault();
});
window.addEventListener('error', (event) => {
report(event.error ?? new Error(event.message), {
layer: 'global',
kind: 'window-error',
route: location.pathname,
});
});
}
A few details matter. event.reason is whatever was passed to reject() — it can be a string, a plain object, undefined. Normalize it to a real Error before you forward it anywhere or your monitoring tool will store a useless "[object Object]". Attach context the call site has lost: the current route, build hash, user id if you have one. Call event.preventDefault() on the rejection so the default “Uncaught (in promise)” console noise stops drowning out real signal.
Pipe report into Sentry, DataDog, or whatever you already use. Sentry remains the most widely adopted option for JavaScript and has a free tier that covers small teams.
For Node, the equivalent is one line: process.on('unhandledRejection', handler) and process.on('uncaughtException', handler).
Be honest about what this gives you. The net is up — but it fires after the user already saw the broken state. It tells you what is leaking. It does not stop the leak. And it has nothing to say about the React tree that just unmounted itself.
Pattern 2: React Error Boundaries at Three Levels
Error boundaries are still class components only in React 19. They catch errors during rendering, lifecycle methods, and constructors. They do not catch event handlers, async code, or anything that happens outside the render phase — Pattern 3 handles those.
One minimal boundary, reporting into the same pipeline as Pattern 1:
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
componentDidCatch(error, info) {
this.props.report(error, { layer: 'boundary', level: this.props.level, componentStack: info.componentStack });
}
render() {
return this.state.hasError ? this.props.fallback : this.props.children;
}
}
That is the easy part. The pattern every competitor leaves on the table is the placement strategy. One boundary at the root of your app is theater. Three levels, each with its own fallback UX, is a strategy.
Page boundary. Wraps the whole route. Fallback is a full-page “something broke, here is a reload button” screen. One per route. This is your last-resort UI net before the user sees a white page.
Section boundary. Wraps a feature area — the sidebar, the comments thread, the dashboard widget grid. Fallback is an inline “this section failed” card. The rest of the page keeps working. This is the level most of your boundaries should live at.
Widget boundary. Wraps a single risky widget — a chart, a third-party embed, a markdown renderer pulling user content. Fallback is a tiny inline “unavailable” state, often nearly invisible. The user may not even notice the failure. The error still lands in your pipeline.
The rule of thumb: the smaller the boundary, the smaller the blast radius. Default to section level. Reserve page level for last resort. Use widget level for known-flaky things.
If you are on Next.js App Router, error.tsx files give you a route-level boundary on the client side without writing the class yourself — same idea, framework sugar. The same logic applies to other React patterns worth knowing in 2026 when you are picking what to wrap.
Render crashes contained. But the event handler that called fetch() and got the 500 from the hook — boundaries do not catch that. The error from the original sin is still loose.
Pattern 3: The Async Boundary Wrapper
The single most reusable utility no popular article hands you. Ten lines. Drop it in a utils/tryAsync.ts and import it everywhere.
type Result<T> = [Error, null] | [null, T];
export async function tryAsync<T>(fn: () => Promise<T>): Promise<Result<T>> {
try {
return [null, await fn()];
} catch (err) {
return [err instanceof Error ? err : new Error(String(err)), null];
}
}
The call site goes from this six-line ritual:
try {
const user = await fetchUser(id);
setUser(user);
} catch (err) {
reportError(err);
setError('Could not load user');
}
To this:
const [err, user] = await tryAsync(() => fetchUser(id));
if (err) return handle(err);
setUser(user);
The Go-style tuple matters more than it looks. With try/catch, ignoring the error costs zero characters — you just delete the catch. With the tuple, ignoring err is visible in the diff. Code review can see it. ESLint can lint it. The discipline is enforced by the shape of the API, not by hope. For the same philosophy applied to resource cleanup (closing connections, removing listeners), see the using keyword for resource cleanup.
Wire the fetch() gotcha fix inside a sibling helper:
export async function fetchSafe(url: string, init?: RequestInit) {
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new FetchError(`${res.status} ${res.statusText}`, { url, status: res.status, body: body.slice(0, 200) });
}
return res;
}
Now tryAsync(() => fetchSafe('/api/user')) turns the silent 500 into an explicit FetchError with status, url, and a body snippet — exactly the context the global handler needs to be useful. If your server returns structured errors (and it should), see how to set up structured API errors on the server side.
The Result-type cousins — neverthrow, fp-ts — solve the same problem with more ceremony. Use them if your team is already there. tryAsync is the version that wins teams over in one PR.
The honest tradeoff: the wrapper is discipline, not a guarantee. It helps only when the team uses it. If you want teeth, add an ESLint rule that flags bare await in files where tryAsync is imported. Three patterns, three blind spots closed. The question now is how they live together.
How the Three Patterns Compose
Three layers. One pipeline. One sentence the reader can repeat at standup.
Layer 1 — the call site. tryAsync at every async boundary. This is where 95% of errors should be handled, with intent, by the person who knows what the operation was supposed to do.
Layer 2 — UI containment. Section and widget error boundaries. When an unhandled render error escapes a call site, the boundary contains the blast radius to a card instead of the page.
Layer 3 — the safety net. unhandledrejection plus window.onerror. Anything that escapes the first two layers gets logged with route, build hash, and user context — so you find out before the user does.
All three layers report into the same pipeline with the same shape: { error, context, layer }. One dashboard. One alert rule. One Slack channel.
The rule that fits on a sticky note: handle at the call site, contain in the UI, log at the boundary.
Ship It Monday
Loop back to the opening fetch. It now hits fetchSafe’s response.ok check, throws a FetchError, gets caught at the call site via tryAsync, or escapes to a section boundary, or — last resort — lands in the global handler with full context. Three chances to not be silent. Two weeks of blank states becomes a Sentry alert in the first hour.
The install order, in increasing risk:
- Drop
installGlobalErrorHandlers()into your app entry this afternoon. Zero risk, immediate signal. You will see things in your dashboard you did not know were happening. - Wrap each route in a page-level
ErrorBoundarythis week. One file, one fallback component. - Introduce
tryAsyncnext sprint. Migrate the five riskiest call sites — the payment flow, the auth flow, the data mutations. Let the pattern spread on its own.
These three javascript error handling patterns — global handlers, layered boundaries, and the async wrapper — work together because each covers what the others cannot. You do not need a framework. You do not need a new library. You do not need a meeting. You need 30 lines of code in the right three places — and the right monitoring setup waiting for them.