Error handling separates amateur code from production-ready code. An unhandled error can crash your Node.js server, leave your UI in a broken state, or worse — silently corrupt data. Good error handling means anticipating what can go wrong, failing gracefully, and giving users (and developers) clear information about what happened.
This guide walks through the full toolbox: the core try/catch/finally statement, JavaScript's built-in error types, custom error classes that carry context, the traps of asynchronous error handling, and the global safety nets you need in both browsers and Node.js.
try/catch/finally
The mechanics are simple: when any statement inside try throws, execution jumps straight to the catch block with the thrown value, skipping everything in between. The finally block runs no matter what — after normal completion, after a caught error, even after a return — which makes it the reliable place for cleanup like closing connections or hiding loading indicators.
The example wraps JSON.parse because it is the archetypal throwing function: user input, API responses, and file contents routinely contain malformed JSON, so parsing without a try/catch is a crash waiting to happen. One rule of thumb keeps try blocks useful: wrap only the code that can actually throw, and keep each catch specific enough that you can respond meaningfully rather than logging a vague "something failed".
try {
const data = JSON.parse(userInput);
processData(data);
} catch (error) {
// Handle the error
console.error("Failed to parse input:", error.message);
showUserMessage("Invalid input. Please check your data.");
} finally {
// Always runs — cleanup code
hideLoadingSpinner();
}
Error Types
JavaScript ships several error subclasses, and knowing which one you are looking at is free diagnostic information. A TypeError almost always means a null or undefined sneaked into a place expecting an object; a SyntaxError at runtime usually points at JSON.parse; a ReferenceError is typically a typo or a missing import. Every error also carries three key properties — name, message, and stack — and the stack trace is the single most valuable debugging artifact, so preserve it when logging.
The instanceof pattern at the end of the example is the important habit: handle the error types you anticipated, and re-throw everything else. Catching broadly and continuing silently converts a programmer error into corrupted behavior downstream, which is far harder to trace than the original crash would have been. Since ES2022 you can also pass { cause: originalError } as the second argument when re-throwing a wrapped error, keeping the original attached for your logs.
// SyntaxError — invalid JSON, malformed code
JSON.parse("{invalid}");
// TypeError — wrong type, accessing property of null/undefined
null.toString();
undefined.map(x => x);
// ReferenceError — using an undeclared variable
console.log(nonExistentVariable);
// RangeError — value outside allowed range
new Array(-1);
// URIError — malformed URI
decodeURIComponent("%");
// Check error type
try {
riskyOperation();
} catch (error) {
if (error instanceof TypeError) {
// Handle type errors specifically
} else if (error instanceof SyntaxError) {
// Handle syntax errors
} else {
throw error; // Re-throw unexpected errors
}
}
Custom Error Classes
Built-in error types describe how something failed at the language level, but your application cares about what failed in domain terms: a resource was missing, input was invalid, a payment was declined. Custom error classes encode that meaning, and because they extend Error, they keep stack traces and work with instanceof checks everywhere.
Two details in this hierarchy matter more than they look. The statusCode field lets a single response handler map any thrown error to the right HTTP status without a chain of string comparisons. And isOperational marks errors that are expected in normal operation (a user requested a missing record) as opposed to programmer bugs (undefined is not a function). That distinction drives real decisions: operational errors get a friendly message and a 4xx response, while programmer errors deserve an alert to the team and, in Node.js, often a process restart.
class AppError extends Error {
constructor(message, statusCode, code) {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
this.isOperational = true;
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND');
this.name = 'NotFoundError';
}
}
class ValidationError extends AppError {
constructor(field, message) {
super(`Validation failed: ${message}`, 400, 'VALIDATION_ERROR');
this.name = 'ValidationError';
this.field = field;
}
}
// Usage
function getUser(id) {
const user = db.findUser(id);
if (!user) throw new NotFoundError('User');
return user;
}
Async Error Handling
Inside an async function, a rejected promise surfaces as a thrown exception at the await keyword, so ordinary try/catch works — that is the great ergonomic win of async/await. The trap is forgetting that an async function whose failure nothing handles produces an unhandled promise rejection, which in modern Node.js terminates the process by default.
The example also defuses the most common fetch misconception: fetch does not reject on HTTP error statuses. A 404 or 500 resolves normally — the promise only rejects on network-level failures like DNS errors or lost connections. Skip the response.ok check and your code will happily try to parse an error page as JSON, then fail somewhere confusing. Checking ok and throwing a structured AppError converts that silent failure into an explicit, typed one.
// ✅ Correct — try/catch with async/await
async function fetchUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new AppError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
'HTTP_ERROR'
);
}
return await response.json();
} catch (error) {
if (error instanceof AppError) {
throw error; // Re-throw known errors
}
// Wrap unknown errors
throw new AppError('Network error', 500, 'NETWORK_ERROR');
}
}
// ❌ Wrong — unhandled promise rejection
async function bad() {
const data = await fetch('/api/data'); // If this throws, it's unhandled!
}
Global Error Handlers
No matter how disciplined your local handling is, something will eventually slip through — a third-party script, an unconsidered edge case, a promise chain missing its catch. Global handlers are the final safety net, and their job is to report, not to recover: log the error to your tracking service with as much context as possible. In the browser, the error event catches uncaught exceptions and unhandledrejection catches promise rejections nobody handled.
Node.js deserves extra care. After an uncaughtException, the process is in an undefined state — half-finished writes, dangling connections — so the correct move is to log and exit, letting your process manager (PM2, systemd, Kubernetes) restart a clean instance. Trying to keep serving traffic from a corrupted process trades a brief restart for unpredictable bugs later.
// Browser — catch unhandled errors
window.addEventListener('error', (event) => {
console.error('Uncaught error:', event.error);
// Send to error tracking service
});
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection:', event.reason);
event.preventDefault(); // Prevent default browser handling
});
// Node.js — catch unhandled errors
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
process.exit(1); // Exit — the process is in an unknown state
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
// In Node 15+, this terminates the process by default
});
Express.js Error Middleware
Express recognizes error-handling middleware purely by its arity — exactly four parameters — so the err-first signature below is not optional decoration. Register it after all routes, and any error passed to next(err) or thrown synchronously in a route lands here, giving you one place to log and shape every error response instead of repeating that logic per endpoint. In Express 4, remember that errors thrown inside async route handlers must be forwarded with next(err) or a wrapper; Express 5 forwards rejected promises automatically.
Note what the handler chooses to reveal: operational errors return their real message, but unexpected ones collapse to a generic "Internal server error", and stack traces are included only in development. Leaking stack traces or internal messages to clients is both unhelpful to users and a gift to attackers mapping your system.
// Error-handling middleware (4 parameters)
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const message = err.isOperational ? err.message : 'Internal server error';
// Log the full error
console.error(`[${err.code}] ${err.message}`, err.stack);
// Send clean response to client
res.status(statusCode).json({
error: {
message,
code: err.code || 'INTERNAL_ERROR',
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
});
Best Practices
Patterns and syntax aside, most of error handling is discipline. These habits are what actually separate codebases that fail loudly and recover from codebases that fail silently and mystify:
- Never silently swallow errors — empty catch blocks hide bugs.
- Use custom error classes to distinguish operational errors (expected) from programmer errors (bugs).
- Always re-throw errors you can't handle — let them bubble up to a handler that can.
- Log errors with context — include the user ID, request URL, input data.
- Use an error tracking service in production — Sentry, Datadog, or LogRocket.
- Fail gracefully — show the user a helpful error message, not a blank page.
Wrapping Up
A robust error strategy is layered: precise try/catch where failures are expected, custom error classes that carry status codes and context, a centralized handler that shapes what clients see, and global hooks that catch whatever escapes — all wired into a tracking service so you hear about problems before your users do. None of these layers is complicated on its own; the value comes from having all of them in place.
A practical starting point for an existing project: search the codebase for empty catch blocks and give each one a real decision — log, recover, or re-throw. Then add the global handlers from this article and watch what they report for a week. Teams are routinely surprised by how many errors were already happening silently, and that list becomes a ready-made, prioritized bug backlog.
Try Our Free JavaScript Tools
Debug and format your JavaScript code instantly.