Errors are unavoidable in production applications. What distinguishes reliable software from fragile software is not the absence of errors but the quality of the handling: whether the application recovers gracefully, whether the user receives useful feedback, whether the developer gets enough context to diagnose the problem, and whether sensitive information stays out of error messages and logs.
These five patterns cover the most important categories of error handling in modern JavaScript, from the structure of individual try/catch blocks to the global safety nets that catch what everything else misses.
What this covers:
Structured try/catch with context-rich error messages
UI fallbacks and React Error Boundaries
Centralized error logging with metadata
Async error wrappers to reduce boilerplate
Global error handlers for browsers and Node.js
1. Structured Try/Catch with Context
A bare catch (error) block that rethrows or logs error.message provides the error but not the situation that produced it. Adding context at the point of catching makes errors significantly easier to diagnose.
function parseUserData(data) {
try {
return JSON.parse(data);
} catch (error) {
throw new Error(`Failed to parse user data: ${error.message}`);
}
}
The wrapping error message tells the developer where in the application the failure occurred. Without it, a SyntaxError: Unexpected token in a stack trace may require tracing through the call stack to find the source.
This pattern is particularly valuable around third-party library calls. When an external library throws, the error message often lacks the application context needed to understand why it was called with invalid input. A wrapper error bridges that gap:
async function sendNotification(userId, message) {
try {
return await notificationService.send({ userId, message });
} catch (error) {
throw new Error(
`Notification failed for user ${userId}: ${error.message}`
);
}
}
One consideration: wrapping with new Error(...) loses the original stack trace. To preserve the original error as context while adding a new message, pass it as the cause option (available in Node.js 16.9+ and modern browsers):
throw new Error(`Failed to parse user data`, { cause: error });
2. UI Fallbacks and React Error Boundaries
In browser applications, an unhandled error in a component can propagate and crash the entire UI. For React specifically, a JavaScript error in a component tree that is not contained by an Error Boundary unmounts the whole tree, producing a blank screen.
For simple conditional rendering, a null or missing data check prevents the component from breaking:
function UserProfile({ data }) {
if (!data) {
return (
<p>Unable to load user profile. Please refresh the page.</p>
);
}
return <div>{data.name}</div>;
}
For containing rendering errors at the component level, React Error Boundaries provide a more systematic approach:
import { Component } from "react";
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
logError(error, { componentStack: info.componentStack });
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <p>Something went wrong.</p>;
}
return this.props.children;
}
}
Usage:
<ErrorBoundary fallback={<p>Failed to load this section.</p>}>
<UserDashboard />
</ErrorBoundary>
Error Boundaries only catch errors during rendering and in lifecycle methods. They do not catch errors in event handlers (use try/catch there directly) or in asynchronous code.
The react-error-boundary package provides a hook-based alternative that covers more cases and is less boilerplate than a class component.
3. Centralized Error Logging
Logging errors in different places with inconsistent formats makes debugging harder than it needs to be. Routing all error logging through a single function ensures consistent formatting, consistent metadata, and a single point of integration with external monitoring services.
function logError(error, context = {}) {
const entry = {
timestamp: new Date().toISOString(),
message: error.message,
stack: error.stack,
...context,
};
console.error(entry);
// Forward to monitoring service
if (typeof window !== "undefined") {
// Browser: Sentry, LogRocket, Datadog RUM
window.__errorService?.captureException(error, { extra: context });
} else {
// Node.js: Sentry, Datadog APM, custom transport
errorService.captureException(error, { extra: context });
}
}
The context parameter is where the metadata that makes logs actionable gets added:
logError(error, {
userId: session.userId,
requestUrl: req.url,
requestId: req.headers["x-request-id"],
environment: process.env.NODE_ENV,
});
A log entry with the user ID, request URL, and environment alongside the stack trace can be diagnosed in minutes. The same error without context may require reproducing the scenario.
One constraint: do not log raw request bodies, form data, passwords, or any PII. The context should be enough to identify the scenario, not enough to reconstruct sensitive user data.
4. Async Error Wrappers
In a codebase with many async functions, every function that can fail requires a try/catch block. A higher-order function that wraps the error handling removes the repetition and ensures the handling is consistent.
const withErrorHandling = (fn, context) => async (...args) => {
try {
return await fn(...args);
} catch (error) {
logError(error, { context, args: args.map(String) });
throw error;
}
};
Usage:
const fetchUserData = withErrorHandling(
async (userId) => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
"fetchUserData"
);
The wrapper logs the error with context and rethrows it. The caller still receives the error and can decide whether to handle it or propagate it further. The logging is guaranteed regardless of which code path triggered the error.
This pattern works well in Express route handlers and Next.js API routes to avoid repetitive try/catch blocks:
// Express
router.get("/users/:id", withErrorHandling(async (req, res) => {
const user = await getUserById(req.params.id);
res.json(user);
}, "GET /users/:id"));
For TypeScript projects, the wrapper can be typed with generics to preserve the argument and return types of the wrapped function.
5. Global Error Handlers
Structured try/catch and async wrappers handle expected failure paths. Global error handlers catch the failures that escape every other layer: uncaught exceptions, unhandled promize rejections, and errors in third-party code.
In the browser:
window.addEventListener("error", (event) => {
logError(event.error, {
type: "uncaught_exception",
filename: event.filename,
line: event.lineno,
col: event.colno,
});
});
window.addEventListener("unhandledrejection", (event) => {
logError(
event.reason instanceof Error
? event.reason
: new Error(String(event.reason)),
{ type: "unhandled_rejection" }
);
});
In Node.js:
process.on("uncaughtException", (error) => {
logError(error, { type: "uncaught_exception" });
// Allow time for logs to flush before exiting
process.exit(1);
});
process.on("unhandledRejection", (reason, promise) => {
logError(
reason instanceof Error ? reason : new Error(String(reason)),
{ type: "unhandled_rejection" }
);
// Node.js will exit in future versions if this is not handled
});
Two important notes on the Node.js handlers:
process.exit(1) is correct after an uncaughtException. The process is in an unknown state at this point and continuing to run risks data corruption or inconsistent behavior. The exit signal tells a process manager (PM2, Kubernetes) to restart the process.
The unhandledRejection handler should not exit unconditionally. Many libraries produce unhandled rejections that are informational rather than fatal. Log and monitor these, but evaluate case-by-case whether a rejection warrants a restart.
Combining the Patterns
These five patterns work at different layers of the application and are designed to be used together:
Structured try/catch at the function level adds context to errors as they are caught
UI fallbacks and Error Boundaries contain rendering failures to the affected component
Centralized logging ensures every error is captured with consistent metadata
Async wrappers reduce boilerplate and standardize handling across async code paths
Global handlers catch the failures that escape all the layers above
A production-ready error handling strategy uses all five rather than relying on any single one.
Key Takeaways
Always add context to errors at the point of catching. The error message alone is rarely enough to diagnose a production failure.
React Error Boundaries prevent component tree crashes from reaching the global level. Wrap subtrees that load external data or render user content.
Centralized logging with consistent metadata (user ID, request URL, environment) is more valuable than scattered console.error calls.
Async wrappers reduce repetitive try/catch blocks and standardize logging across async functions and route handlers.
Global handlers in the browser and Node.js are the last line of defense, not the primary error handling strategy. They catch what everything else misses.
Never log PII or sensitive request data. Log enough to reproduce and diagnose, not enough to expose user information.
Conclusion
Good error handling is not defensive coding for its own sake. It produces applications that fail predictably, recover where possible, give developers the information they need to diagnose problems quickly, and give users feedback that is helpful rather than alarming.
The patterns here cover the full stack from individual function calls to global safety nets. Applying them consistently means errors become diagnosable events rather than mysterious failures.
Using a specific error handling pattern in production that has made debugging significantly easier? Share it in the comments.




