Error handling is one of those areas in JavaScript that separates production-ready code from weekend projects. While most developers learn try-catch early on, the landscape of error management has evolved dramatically — from simple exception catching to sophisticated patterns like Result types that make failures explicit and composable. This guide walks through every major error handling pattern in JavaScript, showing you when each one shines and how to combine them into a resilient error strategy for real-world applications.
Why Error Handling Matters More Than You Think
Unhandled errors are the leading cause of application crashes in production. According to industry surveys, over 60% of downtime incidents in web applications trace back to inadequate error handling — not bugs in business logic, but failures that were never anticipated or properly caught. When your application encounters an unexpected state, the difference between a graceful recovery and a white screen of death comes down to how thoughtfully you’ve structured your error handling.
Beyond preventing crashes, good error handling serves as documentation. When you explicitly handle failure cases, you communicate to other developers (and your future self) exactly what can go wrong and how the system responds. This is especially critical in large codebases where TypeScript and type-safe JavaScript can catch many issues at compile time, but runtime errors still require careful attention.
The Foundation: Try-Catch-Finally
The try-catch statement has been part of JavaScript since its early days. It remains the fundamental mechanism for catching synchronous exceptions, and understanding its nuances is essential before exploring more advanced patterns.
A try block wraps code that might throw an error. If an exception occurs, execution immediately jumps to the catch block, skipping any remaining code in the try block. The finally block, if present, always executes regardless of whether an error was thrown — making it ideal for cleanup operations like closing database connections or releasing file handles.
One subtlety that catches developers off guard: try-catch only works for synchronous code. If you place an asynchronous callback inside a try block, the catch will never fire for errors thrown inside that callback, because by the time the callback executes, the try-catch scope has already exited. This limitation is what drove the JavaScript ecosystem toward Promise-based error handling and eventually async/await.
Another common pitfall is catching errors too broadly. Wrapping an entire function in a single try-catch makes it impossible to distinguish between a network failure, a JSON parsing error, and a type error in your business logic. Granular error handling — catching specific errors at specific points — gives you far more control over recovery strategies.
Error Types and Custom Error Classes
JavaScript provides several built-in error types: Error, TypeError, RangeError, ReferenceError, SyntaxError, and URIError. Each serves a specific purpose, but in real applications, these are rarely sufficient. Custom error classes let you create a taxonomy of errors that maps to your domain.
When building REST APIs, custom error classes become essential. You need to distinguish between a 404 (resource not found), a 401 (unauthorized), a 422 (validation failure), and a 500 (internal server error). Each of these requires a different response format, different logging behavior, and different client-side handling. By creating a hierarchy of error classes, you can centralize this logic and keep your route handlers clean.
The key principle is that custom errors should carry enough context to be handled properly upstream. Include an HTTP status code, a machine-readable error code, a human-readable message, and optionally a reference to the original error that caused the failure. This structured approach to errors pays dividends when you need to debug issues in production with tools for monitoring and observability.
Async Error Handling: Promises and Async/Await
The introduction of Promises fundamentally changed how JavaScript handles asynchronous errors. Instead of the callback pattern where errors were passed as the first argument (the Node.js convention), Promises created a separate channel for errors through the .catch() method and the rejection state.
With ES6 and modern JavaScript features, async/await syntax brought asynchronous error handling back to the familiar try-catch pattern. An await expression inside a try block will throw if the Promise rejects, and the catch block will receive the rejection reason. This made asynchronous code much easier to reason about, because the error flow follows the same top-to-bottom pattern as synchronous code.
However, there are traps to watch for. Forgetting to await a Promise means your try-catch will not catch its rejection. Running multiple Promises in parallel with Promise.all rejects on the first failure and discards the results of other Promises — use Promise.allSettled when you need all results regardless of individual failures. And unhandled Promise rejections, while once silently ignored, now crash Node.js processes by default — a change that has caught many teams off guard during upgrades.
A practical pattern for async error handling is the tuple approach, where you wrap an async function to return a [error, data] tuple instead of throwing. This eliminates the need for try-catch blocks entirely and makes error handling explicit at each call site. While this pattern borrows from Go’s idiomatic error handling, it maps cleanly onto JavaScript’s destructuring syntax.
Global Error Handlers: Your Safety Net
No matter how carefully you handle errors locally, some will slip through. Global error handlers act as your last line of defense, catching unhandled exceptions and unhandled Promise rejections before they crash your application. In the browser, window.onerror and window.onunhandledrejection serve this purpose. In Node.js, the equivalent events are process.on('uncaughtException') and process.on('unhandledRejection').
For Node.js applications, particularly Express servers, a global error-handling middleware is indispensable. Express recognizes middleware functions with four parameters (err, req, res, next) as error handlers, and routes errors to them when next(err) is called or when an async handler rejects. A well-designed global error handler logs the error with full context, sends an appropriate response to the client, and avoids leaking internal details that could pose security risks.
Code Example: Express Global Error Handler with Custom Error Classes
// ─── Custom Error Hierarchy ─────────────────────────────────
class AppError extends Error {
constructor(message, statusCode, errorCode, details = null) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.errorCode = errorCode;
this.details = details;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource', id = null) {
const message = id
? `${resource} with ID '${id}' was not found`
: `${resource} was not found`;
super(message, 404, 'RESOURCE_NOT_FOUND');
this.resource = resource;
}
}
class ValidationError extends AppError {
constructor(errors) {
super('Validation failed', 422, 'VALIDATION_ERROR', errors);
}
}
class AuthenticationError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'AUTH_REQUIRED');
}
}
class RateLimitError extends AppError {
constructor(retryAfter = 60) {
super('Too many requests', 429, 'RATE_LIMIT_EXCEEDED');
this.retryAfter = retryAfter;
}
}
// ─── Async Handler Wrapper ──────────────────────────────────
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// ─── Example Route Using Custom Errors ──────────────────────
app.get('/api/users/:id', asyncHandler(async (req, res) => {
const user = await UserService.findById(req.params.id);
if (!user) {
throw new NotFoundError('User', req.params.id);
}
res.json({ data: user });
}));
app.post('/api/users', asyncHandler(async (req, res) => {
const errors = validateUserInput(req.body);
if (errors.length > 0) {
throw new ValidationError(errors);
}
const user = await UserService.create(req.body);
res.status(201).json({ data: user });
}));
// ─── Global Error-Handling Middleware ────────────────────────
app.use((err, req, res, next) => {
// Log every error with request context
const logPayload = {
error: err.message,
code: err.errorCode || 'INTERNAL_ERROR',
stack: err.stack,
method: req.method,
url: req.originalUrl,
ip: req.ip,
userId: req.user?.id || 'anonymous',
timestamp: new Date().toISOString(),
};
if (err.isOperational) {
console.warn('[OPERATIONAL ERROR]', logPayload);
} else {
console.error('[UNEXPECTED ERROR]', logPayload);
// Alert the on-call team for non-operational errors
// alertService.notify(logPayload);
}
// Determine status code
const statusCode = err.statusCode || 500;
// Build client-safe response
const response = {
status: 'error',
code: err.errorCode || 'INTERNAL_ERROR',
message: err.isOperational
? err.message
: 'An unexpected error occurred. Please try again later.',
};
// Include validation details when relevant
if (err.details) {
response.details = err.details;
}
// Add Retry-After header for rate limiting
if (err instanceof RateLimitError) {
res.set('Retry-After', String(err.retryAfter));
}
res.status(statusCode).json(response);
});
This pattern establishes a clear separation between operational errors (expected failures like missing resources or invalid input) and programmer errors (unexpected bugs). Operational errors are handled gracefully with appropriate HTTP status codes, while unexpected errors produce a generic message to the client and trigger alerts for the development team. The asyncHandler wrapper eliminates the need for repetitive try-catch blocks in every route handler.
The Result Type Pattern: Making Errors Explicit
One of the most significant shifts in modern error handling philosophy comes from functional programming: instead of throwing exceptions, you return values that explicitly represent either success or failure. This is the Result type pattern (also called Either in some traditions), and it’s gaining rapid adoption in the JavaScript ecosystem.
The core idea is simple: a function that can fail returns a Result object that is either an Ok wrapping the success value, or an Err wrapping the error. The caller must explicitly handle both cases before accessing the value. This eliminates an entire class of bugs where developers forget to wrap a call in try-catch, and it makes error handling paths visible in the code structure rather than hidden in exception flow.
Result types compose beautifully. You can chain operations that each return Results, and if any step fails, the chain short-circuits — similar to how Promises propagate rejections through .then() chains, but synchronously and with full type safety when combined with TypeScript.
Code Example: Result Type Implementation with Pattern Matching
// ─── Result Type Implementation ─────────────────────────────
class Result {
#value;
#error;
#isOk;
constructor(isOk, value, error) {
this.#isOk = isOk;
this.#value = value;
this.#error = error;
Object.freeze(this);
}
static ok(value) {
return new Result(true, value, null);
}
static err(error) {
return new Result(false, null, error);
}
static fromThrowable(fn, errorMapper = (e) => e) {
try {
const value = fn();
return Result.ok(value);
} catch (e) {
return Result.err(errorMapper(e));
}
}
static async fromAsync(promise, errorMapper = (e) => e) {
try {
const value = await promise;
return Result.ok(value);
} catch (e) {
return Result.err(errorMapper(e));
}
}
get isOk() { return this.#isOk; }
get isErr() { return !this.#isOk; }
// Unwrap with a default value
unwrapOr(defaultValue) {
return this.#isOk ? this.#value : defaultValue;
}
// Unwrap or throw
unwrap() {
if (this.#isOk) return this.#value;
throw new Error(`Called unwrap on Err: ${this.#error}`);
}
unwrapErr() {
if (!this.#isOk) return this.#error;
throw new Error(`Called unwrapErr on Ok: ${this.#value}`);
}
// Transform the success value
map(fn) {
return this.#isOk ? Result.ok(fn(this.#value)) : this;
}
// Transform the error
mapErr(fn) {
return this.#isOk ? this : Result.err(fn(this.#error));
}
// Chain operations that return Results
flatMap(fn) {
return this.#isOk ? fn(this.#value) : this;
}
// Pattern matching — handle both cases explicitly
match(handlers) {
return this.#isOk
? handlers.ok(this.#value)
: handlers.err(this.#error);
}
}
// ─── Practical Usage: User Registration Pipeline ────────────
function validateEmail(email) {
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return pattern.test(email)
? Result.ok(email.toLowerCase().trim())
: Result.err({ field: 'email', message: 'Invalid email format' });
}
function validatePassword(password) {
if (password.length < 8) {
return Result.err({
field: 'password',
message: 'Password must be at least 8 characters',
});
}
if (!/[A-Z]/.test(password) || !/[0-9]/.test(password)) {
return Result.err({
field: 'password',
message: 'Password must contain uppercase letter and number',
});
}
return Result.ok(password);
}
async function checkEmailAvailability(email) {
return Result.fromAsync(
db.query('SELECT id FROM users WHERE email = ?', [email])
.then(rows => {
if (rows.length > 0) {
throw { field: 'email', message: 'Email already registered' };
}
return email;
})
);
}
async function hashPassword(password) {
return Result.fromAsync(
bcrypt.hash(password, 12),
() => ({ field: 'password', message: 'Failed to process password' })
);
}
// ─── Composing Results with Pattern Matching ────────────────
async function registerUser(input) {
const emailResult = validateEmail(input.email);
if (emailResult.isErr) return emailResult;
const passwordResult = validatePassword(input.password);
if (passwordResult.isErr) return passwordResult;
const availabilityResult = await checkEmailAvailability(
emailResult.unwrap()
);
if (availabilityResult.isErr) return availabilityResult;
const hashResult = await hashPassword(passwordResult.unwrap());
if (hashResult.isErr) return hashResult;
return Result.fromAsync(
db.query(
'INSERT INTO users (email, password_hash) VALUES (?, ?)',
[emailResult.unwrap(), hashResult.unwrap()]
).then(result => ({
id: result.insertId,
email: emailResult.unwrap(),
})),
() => ({ field: 'general', message: 'Registration failed' })
);
}
// ─── In Route Handler ───────────────────────────────────────
app.post('/api/register', asyncHandler(async (req, res) => {
const result = await registerUser(req.body);
result.match({
ok: (user) => {
res.status(201).json({
status: 'success',
data: { id: user.id, email: user.email },
});
},
err: (error) => {
res.status(422).json({
status: 'error',
code: 'VALIDATION_ERROR',
message: error.message,
field: error.field,
});
},
});
}));
The Result type pattern shines in validation pipelines and data transformation chains. Each step explicitly returns success or failure, and the caller decides how to handle both cases through pattern matching. Unlike try-catch, there is no hidden control flow — every error path is visible in the code. This makes the code significantly easier to test, because you can verify error handling without mocking thrown exceptions.
Error Boundaries in Frontend Applications
In React applications, error boundaries are components that catch JavaScript errors in their child component tree, log them, and display a fallback UI. Without error boundaries, a single error in one component can crash the entire application. Error boundaries implement the componentDidCatch lifecycle method and static getDerivedStateFromError to intercept errors during rendering.
The strategic placement of error boundaries determines how gracefully your application degrades. Wrapping your entire app in one error boundary provides a crash-proof shell but forces a full fallback. Placing error boundaries around individual features — a comment section, a data visualization widget, a sidebar — means that one component’s failure only affects its own area while the rest of the application continues to function.
When designing frontend error handling alongside well-designed APIs, consider the full error lifecycle: the API returns a structured error response, the fetch layer parses it into a Result or typed error, the component handles the error state in its UI, and the error boundary catches any rendering failures in that error UI. Each layer adds resilience.
Retry Patterns and Circuit Breakers
Not all errors are permanent. Network timeouts, rate limits, and temporary service outages are transient failures that can succeed on retry. A retry pattern wraps an operation in a loop that re-attempts the operation a configurable number of times with increasing delays between attempts — a strategy called exponential backoff.
The circuit breaker pattern takes this further. Like an electrical circuit breaker, it monitors failure rates and “trips open” when failures exceed a threshold, preventing the application from making requests to a service that is clearly down. After a cooldown period, the circuit allows a single test request through, and if it succeeds, the circuit closes and normal operation resumes. This pattern prevents cascading failures in microservice architectures where one failing service can overwhelm others with retry storms.
Implementing these patterns requires careful thought about which errors are retryable. A 500 (Internal Server Error) might resolve on retry, but a 400 (Bad Request) will fail every time. A timeout might succeed on the next attempt, but an authentication failure will not. Classifying errors by retryability is where your custom error hierarchy pays off — you can check err.isRetryable rather than pattern-matching on error messages. Teams using tools like Taskee can track recurring error patterns and assign investigation tasks when circuit breakers trip frequently.
Logging and Error Reporting
An error that’s caught but not logged is nearly as dangerous as an unhandled error. Structured logging transforms error data into queryable records that can be searched, filtered, and aggregated. Every logged error should include: a timestamp, the error message and stack trace, request context (URL, method, user ID), the severity level, and a correlation ID that lets you trace a request across multiple services.
Error reporting services like Sentry, Datadog, or Bugsnag automate much of this process, capturing errors with full context, deduplicating them, and alerting your team when new issues appear or error rates spike. Integrating these services early in your project saves enormous debugging time later. For projects managed with Toimi, connecting error monitoring dashboards to your project workflow ensures that production errors become actionable tasks rather than silent failures.
Consider the level of detail in your error logs carefully. In development, a full stack trace with local variable values is invaluable. In production, you need to balance debugging utility against privacy and security — never log passwords, API keys, personal data, or full request bodies containing sensitive information. Ensuring your code follows best practices from a solid code quality setup with ESLint and Prettier can help catch logging anti-patterns before they reach production.
Testing Error Handling
Error paths are often the least tested part of an application, yet they’re the most critical when things go wrong in production. A comprehensive test suite should verify: that functions throw the correct error type for each failure scenario, that error messages contain useful information, that error handlers produce the right response format, that retry logic respects backoff delays and max attempt limits, and that circuit breakers trip and recover correctly.
Testing with Result types is particularly straightforward. Since errors are values rather than thrown exceptions, you can assert on them directly: check that a function returns an Err with the expected error code, verify that the Ok branch transforms data correctly, and confirm that chained operations short-circuit at the right point. This testability is one of the strongest arguments for adopting the Result pattern in codebases where reliability is paramount.
Choosing the Right Pattern
There is no single best error handling pattern. The right choice depends on the context. Use try-catch for operations where you need to handle specific exception types and where the error recovery is localized. Use Result types for data validation pipelines, transformation chains, and any function where callers should be forced to consider the failure case. Use global error handlers as a safety net, not as your primary error handling strategy. Use retry patterns for network operations and external service calls. Use circuit breakers when you have dependencies on services that can fail independently.
In practice, most production applications combine all of these patterns. Route handlers use try-catch or asyncHandler wrappers. Validation functions return Result types. Express middleware provides a global error handler. Network clients implement retries with exponential backoff. And error boundaries in the frontend ensure that rendering failures don’t take down the entire page.
The evolution from simple try-catch to Result types reflects a broader trend in the JavaScript ecosystem toward making failure handling explicit, composable, and type-safe. As your applications grow in complexity, investing in a robust error handling architecture is one of the highest-leverage improvements you can make to both developer experience and production reliability.
Frequently Asked Questions
What is the difference between operational errors and programmer errors in JavaScript?
Operational errors are expected failures that occur during normal application operation — network timeouts, invalid user input, files not found, or database connection drops. These should be anticipated and handled gracefully with appropriate error messages and recovery strategies. Programmer errors are bugs: calling a function with the wrong number of arguments, accessing a property on undefined, or logic errors that violate assumptions. Programmer errors indicate code that needs to be fixed, while operational errors indicate conditions that need to be handled. The distinction matters because operational errors should be caught and responded to, while programmer errors should surface quickly (often by crashing) so they can be identified and fixed.
When should I use Result types instead of try-catch in JavaScript?
Result types are most valuable when failure is an expected part of the operation — validation functions, parsing, data transformations, and any function where the caller should be forced to consider the failure case. They excel in pipelines where multiple fallible operations chain together, because each step explicitly passes success or failure to the next. Use try-catch when you need to catch unexpected exceptions from third-party libraries, when dealing with truly exceptional conditions, or when the error handling is localized and doesn’t need to compose with other operations. Many teams adopt a hybrid approach: Result types for their own business logic and try-catch at the boundaries where they interact with external APIs and libraries.
How do I handle errors in Promise.all without losing successful results?
Use Promise.allSettled() instead of Promise.all(). While Promise.all() rejects immediately when any single Promise rejects (discarding all other results), Promise.allSettled() waits for all Promises to complete and returns an array of result objects, each with a status of either “fulfilled” (with a value) or “rejected” (with a reason). You can then filter the results to separate successes from failures. Another approach is wrapping each Promise with a catch that converts rejections into Result objects, letting you use Promise.all() while still preserving all outcomes. This pattern is especially useful for batch operations like sending notifications or syncing data, where partial success is acceptable.
What should I include in error logs for production JavaScript applications?
Production error logs should include: the error message and error code, a stack trace, a timestamp in ISO 8601 format, the HTTP method and URL for web requests, a user identifier (anonymized if needed), a correlation or request ID for tracing across services, the severity level (error, warning, info), and any relevant contextual data like query parameters or feature flags. Never log sensitive information — passwords, API keys, credit card numbers, or personal identification data. Structure your logs as JSON so they can be parsed and queried by log aggregation tools. Include the application version or deployment ID so you can correlate error spikes with specific releases.
How do error boundaries work in React, and where should I place them?
Error boundaries are React class components that implement static getDerivedStateFromError() and componentDidCatch() lifecycle methods. They catch JavaScript errors during rendering, in lifecycle methods, and in constructors of their child component tree, displaying a fallback UI instead of crashing the entire application. Place them strategically at multiple levels: one at the application root as a last resort, and additional boundaries around independent features like sidebars, comment sections, data widgets, or modal dialogs. This way, a crash in one feature shows a local fallback while the rest of the application remains functional. Note that error boundaries do not catch errors in event handlers, asynchronous code, or server-side rendering — use try-catch for those cases.