JavaScript Error Handling Patterns

An error‑handling pattern is useful only if it helps the application recover, preserves enough context to diagnose the failure, or gives the user a clearer outcome. Catching everything and logging "something went wrong" achieves none of those things. The right boundary depends on who can make a meaningful decision about the error.
Why Modern Error Handling Matters
Modern JavaScript applications are increasingly complex, often relying on asynchronous operations, external APIs, and dynamic user interactions. Without proper error handling, these complexities can lead to:
Unreliable applications:
Errors can cause unexpected crashes or data loss.Poor user experience:
Users may encounter confusing or frustrating behaviour.Debugging difficulties:
Uncaught or poorly managed errors make diagnosing issues harder.
Effective error‑handling strategies mitigate these problems, ensuring stability and an improved developer experience.
Key Patterns and Practices for Error Handling
1. try-catch for Synchronous Errors
The try-catch construct is a foundational error‑handling tool for managing synchronous operations. It isolates potentially error‑prone code, allowing the program to respond gracefully when an error does occur.
For example:
try {
const data = JSON.parse('{"key": "value"}');
console.log(data.key); // value
} catch (error) {
console.error('Failed to parse JSON:', error.message);
}Use case: Handling errors in operations like JSON parsing, DOM manipulation, or synchronous computations.
2. Handling Errors in Promises
Promises represent asynchronous operations, and error handling is central to their design. The .catch() method captures errors that occur within a promise chain.
fetch('https://api.example.com/data')
.then((response) => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then((data) => console.log(data))
.catch((error) => console.error('Fetch failed:', error));Every rejection needs an appropriate handling boundary, but that does not mean adding .catch() inside every helper. Return the promise when the caller owns recovery, and let that caller attach .catch() or await it inside try/catch. The example above ends by logging; its final .catch() handles the failure and leaves the resulting promise fulfilled with undefined.
3. Using async/await with try-catch
With async/await, a rejected awaited promise throws inside the async function. A local catch can handle it, or the function's returned promise can remain rejected for its caller to handle. Choose the boundary according to who can recover or present a useful failure state.
async function fetchData() {
try {
const response = await fetch('https://api.example.com/data');
if (!response.ok) {
throw new Error('Network error');
}
const data = await response.json();
console.log(data);
} catch (error) {
console.error('Error fetching data:', error.message);
}
}The fetchData() function above is a terminal logging demonstration: call it after its declaration to run it. It logs a failure and finishes, so its returned promise fulfils with undefined even after the request fails. A reusable data‑fetching helper should return the data and let failures reach the caller. If it catches an error only to add diagnostics or clean up, use throw error afterwards when the caller still needs to recover.
4. Global Error Handling
The browser exposes global error events for certain uncaught script errors and unhandledrejection events for promise rejections that lack a handler. The example listens for those event names; window.onerror is the corresponding error‑handler property, rather than an event name.
For example:
window.addEventListener('error', (event) => {
console.error('Global error:', event.message);
});
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled rejection:', event.reason);
});Use global handlers as a reporting safety net. They cannot resume the failed operation or choose the right recovery for every feature; keep that decision at an intentional application boundary.
5. Custom Error Classes
For more descriptive error messages, custom error classes allow us to create errors tailored to specific contexts:
class ApiError extends Error {
constructor(message, statusCode) {
super(message);
this.name = 'ApiError';
this.statusCode = statusCode;
}
}
try {
throw new ApiError('Resource not found', 404);
} catch (error) {
console.error(
`${error.name}: ${error.message} (Status: ${error.statusCode})`
);
}Benefit: Enables differentiation between error types and provides contextual information for debugging.
6. Logging and Monitoring
Error logging tools like Sentry or LogRocket help track errors in production environments, providing detailed reports and diagnostics. Of course, it is then important to avoid logging sensitive user data in order to maintain security and compliance. But, you knew that already anyway, right?
Wrapping Up
Modern JavaScript error handling has evolved to address the growing complexity of web applications. From try-catch for synchronous errors to production monitoring tools, these patterns ensure that applications remain reliable and user‑friendly, and that we ‑ as developers ‑ can keep on top of any issues that might arise.
Key Takeaways
- Foundational tools: Use
try/catchfor synchronous exceptions and an appropriate promise rejection handler for asynchronous failures. - Asynchronous handling: Handle a rejected
asyncfunction locally or let a callerawaitit within its recovery boundary. Global safety nets:
Implement global error handlers for uncaught errors.Custom errors:
Create error classes for specific scenarios to improve clarity.Monitoring:
Use logging and monitoring tools like Sentry to track errors in production.
Used carefully, these patterns make failures easier to isolate, report, and recover from before they become repeated user‑facing bugs.