JavaScript Error Handling Patterns and Best Practices
Master JavaScript error handling with custom error classes, async error patterns, Result types, error boundaries, and strategies for robust production code.
What you'll learn
- ✓How to design custom error hierarchies
- ✓Patterns for handling errors in async code, promises, and event emitters
- ✓The Result pattern as an alternative to try/catch
Prerequisites
- •JavaScript try/catch basics
- •Promises and async/await
- •ES6 classes
Every JavaScript application encounters errors. The difference between fragile code and robust code is not whether errors occur, but how they are anticipated, categorized, and handled. This article covers practical patterns that go beyond basic try/catch.
Custom error classes
The built-in Error class provides a message and stack trace, but production code needs more structure. Custom error classes let you categorize errors and attach relevant data.
class AppError extends Error {
constructor(message, options = {}) {
super(message);
this.name = this.constructor.name;
this.code = options.code || "UNKNOWN_ERROR";
this.statusCode = options.statusCode || 500;
this.context = options.context || {};
}
}
class ValidationError extends AppError {
constructor(message, fields) {
super(message, { code: "VALIDATION_ERROR", statusCode: 400 });
this.fields = fields;
}
}
class NotFoundError extends AppError {
constructor(resource, id) {
super(`${resource} with id ${id} not found`, {
code: "NOT_FOUND",
statusCode: 404,
context: { resource, id },
});
}
}
class AuthenticationError extends AppError {
constructor(message = "Authentication required") {
super(message, { code: "AUTH_REQUIRED", statusCode: 401 });
}
}
Using these in practice:
function getUser(id) {
const user = database.find(id);
if (!user) {
throw new NotFoundError("User", id);
}
return user;
}
try {
const user = getUser(999);
} catch (error) {
if (error instanceof NotFoundError) {
console.log(error.statusCode); // 404
console.log(error.context); // { resource: "User", id: 999 }
}
}
The Error cause chain
ES2022 introduced the cause property, letting you chain errors to preserve the original context.
async function fetchUserProfile(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
throw new AppError("Failed to load user profile", {
code: "PROFILE_LOAD_ERROR",
cause: error, // Preserves the original error
context: { userId },
});
}
}
try {
await fetchUserProfile(123);
} catch (error) {
console.log(error.message); // "Failed to load user profile"
console.log(error.cause?.message); // "HTTP 500" (original error)
}
The cause chain lets high-level code report a user-friendly error while preserving the technical details for debugging.
Async error handling patterns
Promise chains
In promise chains, a single .catch() at the end handles errors from any step.
fetchUser(123)
.then((user) => fetchOrders(user.id))
.then((orders) => calculateTotal(orders))
.then((total) => displayTotal(total))
.catch((error) => {
// Handles errors from any step above
console.error("Pipeline failed:", error);
});
async/await with centralized handling
async function processOrder(orderId) {
try {
const order = await fetchOrder(orderId);
const payment = await chargePayment(order);
const confirmation = await sendConfirmation(payment);
return confirmation;
} catch (error) {
if (error instanceof ValidationError) {
return { success: false, errors: error.fields };
}
if (error instanceof AuthenticationError) {
redirectToLogin();
return;
}
// Unexpected error -- rethrow
throw error;
}
}
Handling errors from Promise.allSettled
When running multiple operations, Promise.allSettled lets you collect both successes and failures.
async function fetchMultipleUsers(ids) {
const results = await Promise.allSettled(
ids.map((id) => fetchUser(id))
);
const users = [];
const errors = [];
for (const result of results) {
if (result.status === "fulfilled") {
users.push(result.value);
} else {
errors.push(result.reason);
}
}
if (errors.length > 0) {
console.warn(`${errors.length} user fetches failed`);
}
return { users, errors };
}
The Result pattern
Instead of throwing exceptions, return a value that explicitly represents success or failure. This makes error handling part of the type system rather than the control flow.
class Result {
#value;
#error;
#isOk;
constructor(isOk, valueOrError) {
this.#isOk = isOk;
if (isOk) {
this.#value = valueOrError;
} else {
this.#error = valueOrError;
}
}
static ok(value) {
return new Result(true, value);
}
static err(error) {
return new Result(false, error);
}
isOk() {
return this.#isOk;
}
isErr() {
return !this.#isOk;
}
unwrap() {
if (this.#isOk) return this.#value;
throw this.#error;
}
unwrapOr(defaultValue) {
return this.#isOk ? this.#value : defaultValue;
}
map(fn) {
return this.#isOk ? Result.ok(fn(this.#value)) : this;
}
mapErr(fn) {
return this.#isOk ? this : Result.err(fn(this.#error));
}
}
Using the Result pattern:
function parseJSON(text) {
try {
return Result.ok(JSON.parse(text));
} catch (error) {
return Result.err(new ValidationError("Invalid JSON", { raw: text }));
}
}
function validateAge(data) {
if (typeof data.age !== "number" || data.age < 0 || data.age > 150) {
return Result.err(new ValidationError("Invalid age", { age: data.age }));
}
return Result.ok(data);
}
// Usage -- no try/catch needed
const result = parseJSON('{"age": 25}');
if (result.isOk()) {
const validated = validateAge(result.unwrap());
console.log(validated.unwrapOr({ age: 0 }));
} else {
console.error("Parse failed");
}
Safe wrapper for async functions
A utility that wraps any async function to return [error, data] tuples instead of throwing.
async function safe(asyncFn, ...args) {
try {
const result = await asyncFn(...args);
return [null, result];
} catch (error) {
return [error, null];
}
}
// Usage
const [error, user] = await safe(fetchUser, 123);
if (error) {
console.error("Failed:", error.message);
} else {
console.log("User:", user.name);
}
This pattern, inspired by Go’s error handling, eliminates nested try/catch blocks when calling multiple async functions sequentially.
const [err1, user] = await safe(fetchUser, userId);
if (err1) return handleError(err1);
const [err2, orders] = await safe(fetchOrders, user.id);
if (err2) return handleError(err2);
const [err3, total] = await safe(calculateTotal, orders);
if (err3) return handleError(err3);
displayTotal(total);
Global error handlers
Browser unhandled errors
window.addEventListener("error", (event) => {
reportError({
type: "uncaught",
message: event.message,
filename: event.filename,
line: event.lineno,
column: event.colno,
stack: event.error?.stack,
});
});
window.addEventListener("unhandledrejection", (event) => {
reportError({
type: "unhandled-rejection",
reason: event.reason?.message || String(event.reason),
stack: event.reason?.stack,
});
event.preventDefault(); // Prevent default console error
});
Node.js
process.on("uncaughtException", (error) => {
console.error("Uncaught exception:", error);
// Perform synchronous cleanup, then exit
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
});
Error handling strategy
A consistent strategy across your application is more important than any individual pattern.
- Throw early — validate inputs at the boundaries (API endpoints, form handlers) and throw immediately if data is invalid.
- Catch late — let errors propagate up to a handler that has enough context to respond appropriately.
- Add context — use error wrapping (
cause) to preserve the original error while adding high-level meaning. - Categorize errors — distinguish between operational errors (expected failures like network issues) and programmer errors (bugs like null reference). Handle operational errors; crash on programmer errors.
- Log consistently — every caught error should be logged with its stack trace, context, and any relevant request/user identifiers.
Summary
Robust error handling requires design, not just try/catch:
- Custom error classes categorize errors with codes, status codes, and context data.
- The
causeproperty chains errors to preserve both high-level meaning and low-level details. Promise.allSettledcollects results from multiple operations without short-circuiting on the first failure.- The Result pattern makes error handling explicit in return values rather than exception-based control flow.
- The
safe()wrapper provides Go-style[error, data]tuples for cleaner sequential async code. - Global handlers (
error,unhandledrejection) act as a safety net for errors that escape all other handlers.
Related articles
- JavaScript JavaScript Promise Combinators: all, allSettled, race, and any
Master all four JavaScript Promise combinators -- Promise.all, allSettled, race, and any -- with practical patterns for concurrency, timeouts, and resilience.
- JavaScript JavaScript Event Loop Explained: How Async Code Really Works
Understand how the JavaScript event loop handles async operations including the call stack, microtasks, macrotasks, and execution order with practical examples.
- JavaScript JavaScript Design Patterns Every Developer Should Know
Learn the most useful JavaScript design patterns with practical examples including Singleton, Observer, Factory, Strategy, and more for cleaner code.
- JavaScript The Observable Pattern in JavaScript
Implement the Observable pattern in JavaScript to build reactive event streams, compose data pipelines, and manage async flows without third-party libraries.