What you'll learn
By the end of this tutorial you'll have a complete mental model of error handling in Node.js. You'll understand the difference between errors you expect and bugs you don't, how to create custom error classes that carry useful context, how to propagate errors correctly through async code, and how to build a centralized error handler that catches everything in one place. You'll also know how to set up process-level safety nets so an unhandled rejection doesn't silently crash a production server.
Error handling is one of those topics that's easy to put off until something breaks badly in production. This tutorial is the thing you read before that happens.
Who this is for
- Node.js developers who handle errors with try/catch but aren't sure what to do with them once caught
- Anyone who's seen "UnhandledPromiseRejectionWarning" in production logs and wasn't sure where it came from
- Developers building Express APIs who want a single, consistent error response shape
You can skip this if you're already using a structured logging system, have custom error classes in place, and have tested what happens to your process when an async operation rejects without a catch. Come back when any of those three need revisiting.
What is error handling in Node.js?
It's the set of patterns that ensure unexpected conditions are caught, logged, and handled gracefully — without crashing the process when they shouldn't, and without hiding bugs that need to be fixed.
Plain English: when code goes wrong, you have to decide three things: does this error get shown to the user, does it get logged for a developer to investigate, and does the process keep running or stop? Good error handling makes those decisions consistently rather than case-by-case.
Simple idea: every error needs a destination. If an error propagates all the way up without landing anywhere, Node.js will crash (or print a warning and act unpredictably, depending on the version). Design your code so every error has a clear path to its final destination.
Prerequisites
- Comfortable with JavaScript and async/await
- A Node.js project to apply these patterns in — even a small Express app works well
- Basic familiarity with
try/catchsyntax
Setup from zero
Step 1 — Create the project
mkdir error-lab && cd error-lab
npm init -y
touch app.js errors.js
Step 2 — Install Express for the HTTP examples
npm install express
Step 3 — Create a minimal Express server
// app.js
const express = require("express");
const app = express();
app.use(express.json());
app.get("/", (req, res) => res.json({ status: "ok" }));
app.listen(3000, () => console.log("Server on port 3000"));
Run with node app.js. The error patterns below slot into this base.
> Little tip: before adding any error handling, intentionally trigger an error in your server — throw inside a route, await a rejected Promise — and watch what happens without a handler. The behavior you observe is exactly what your error handling needs to prevent. Seeing the problem firsthand makes the solution much more concrete.
The mental model
Every Node.js error falls into one of two buckets:
Operational errors — expected failure conditions. A database query returns no results. A file doesn't exist. An API call times out. The network is down. These errors are part of normal operation — your code should handle them and continue (or respond gracefully to the caller).
Programmer errors — bugs. A null dereference. A wrong argument type. A typo in a variable name. These indicate a flaw in the code itself. The right response is usually to log the error with full context and crash or restart the process — running on with a programmer error means running in an unknown state.
The practical rule: recover from operational errors, surface programmer errors as loudly as possible. Most production bugs come from trying to recover from programmer errors (suppressing them) or from treating operational errors as fatal (crashing when they were expected).
Key terms
Error class — a JavaScript class that extends Error. Custom subclasses can carry extra context like HTTP status codes, error codes, or user-facing messages.
try/catch — the standard mechanism for catching synchronous and async errors in async functions. If an await-ed Promise rejects and there's no try/catch, the rejection propagates up to the nearest caller that handles it — or becomes an unhandled rejection.
Unhandled rejection — a rejected Promise with no .catch() handler and no surrounding try/catch. In Node.js 15 and later, this crashes the process.
Error middleware — an Express middleware function with four parameters (err, req, res, next). Express routes errors to it when you call next(err) or when a synchronous error is thrown.
process.on('unhandledRejection') — a process-level event fired when a Promise rejects without a handler. A safety net, not a replacement for real error handling.
Stack trace — the list of function calls that led to the error. Essential for debugging — always preserve it when passing errors between functions.
Step-by-step
Build a custom AppError class
// errors.js
class AppError extends Error {
constructor(message, statusCode = 500, isOperational = true) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = "Resource") {
super(`${resource} not found`, 404);
}
}
class ValidationError extends AppError {
constructor(message) {
super(message, 400);
}
}
module.exports = { AppError, NotFoundError, ValidationError };
isOperational distinguishes expected failures from bugs. The error middleware will check this flag to decide whether to attempt recovery or treat the error as fatal.
Add a centralized Express error handler
// app.js — add this after all routes, before app.listen
const { AppError } = require("./errors");
app.use((err, req, res, next) => {
const statusCode = err.statusCode ?? 500;
const isOperational = err.isOperational ?? false;
// Always log the error
console.error({
message: err.message,
statusCode,
stack: err.stack,
path: req.path,
method: req.method,
});
if (!isOperational) {
// Programmer error — response is generic, but process should restart
return res.status(500).json({ error: "An unexpected error occurred" });
}
res.status(statusCode).json({ error: err.message });
});
Throw and propagate errors from async routes
// Works in Express 5 — async errors propagate automatically
app.get("/users/:id", async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError("User");
res.json(user);
});
// For Express 4 — wrap async routes to catch rejections
function asyncHandler(fn) {
return (req, res, next) => fn(req, res, next).catch(next);
}
app.get("/users/:id", asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError("User");
res.json(user);
}));
Working examples
Propagating errors through async functions
async function getUser(id) {
const user = await db.collection("users").findOne({ _id: id });
if (!user) throw new NotFoundError("User");
return user;
}
async function getUserProfile(id) {
// No try/catch here — let the error propagate to the route handler
const user = await getUser(id);
return { ...user, displayName: user.profile?.name ?? user.email };
}
Don't add try/catch at every layer. Let errors bubble up to the layer that knows how to handle them — usually the route handler or a centralized middleware.
Process-level safety nets
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled rejection:", reason);
// Give the process time to finish in-flight requests, then exit
process.exit(1);
});
process.on("uncaughtException", (err) => {
console.error("Uncaught exception:", err.message, err.stack);
process.exit(1);
});
These are safety nets for errors that slipped through — not substitutes for real error handling. An uncaughtException means a programmer error reached the top; the only safe response is to restart with a clean state.
> Little tip: pair process.exit(1) in uncaughtException with a process manager like PM2 or a container restart policy. The goal is a fast, clean restart rather than running in an unknown state. Staying up after an uncaught exception is not stability — it's running with a corrupted internal state and hoping nothing notices.
Patterns
Log everything, show little — log the full error including the stack trace to your server logs. Show users only a short, non-technical message. Leaking stack traces in API responses is a security risk.
Centralize error formatting — one error handler in Express, one error shape in the response. React and other API consumers can rely on { error: "message" } in every failure response without special-casing per route.
Always return after sending a response — once you call res.json() in an error handler or route, return immediately. Calling res.json() twice on the same response object throws "headers already sent" — one of Express's most common runtime errors.
Common mistakes
Swallowing errors in catch blocks — catch (err) {} is the source of some of the hardest-to-debug Node.js bugs. At minimum, log the error. If you're catching it to provide a fallback, make it explicit in a comment why you're doing so.
Using the same error message for every failure — generic messages like "Something went wrong" are useless for debugging. Even internal errors should have a specific log message that identifies what failed and where.
Catching and rethrowing without adding context — if you catch an error just to rethrow it, add something: err.context = { userId: id } or wrap it in a domain-specific error class. The stack trace tells you where it failed; the context tells you what the code was doing.
Troubleshooting
"Cannot set headers after they are sent" — two responses were sent from the same handler. Find every res.json() or res.send() call in the handler chain and add return before each one that's supposed to terminate the function.
Async errors not reaching Express error middleware — you're on Express 4 and haven't wrapped async routes in asyncHandler, or you're await-ing inside a forEach loop where rejections are swallowed. Use for...of for async loops, and wrap routes.
Stack traces cut off or missing — error objects lost their stack trace because someone created new Error(existingError.message) instead of passing the original error through. Always preserve the original error: cause: originalError (ES2022 pattern) or by attaching it directly.
Checklist
- [ ] A custom
AppErrorclass withstatusCodeandisOperationalfields - [ ] Domain-specific subclasses for common failures:
NotFoundError,ValidationError - [ ] Centralized Express error middleware placed after all routes
- [ ] Async Express routes wrapped in
asyncHandler(Express 4) or using native propagation (Express 5) - [ ]
process.on("unhandledRejection")logs the error and exits the process - [ ]
process.on("uncaughtException")logs the error and exits the process - [ ] No catch blocks that swallow errors silently
Practice task
Add error handling to an Express API that has a GET /posts/:id route. Create custom NotFoundError and ValidationError classes. Throw NotFoundError when the post doesn't exist. Throw ValidationError when the ID format is invalid. Add the centralized error middleware. Confirm the error responses have the right HTTP status codes and that the stack trace appears in the server log but not in the API response.
FAQ
Should I use process.exit(1) in production?
Yes, for uncaught exceptions and unhandled rejections — but pair it with a process manager (PM2, Docker restart policies, systemd) that restarts the process automatically. Staying alive after an uncaught exception means running code that's in an unknown state. A fast crash and restart is safer.
When should I throw vs. return an error?
In async functions and Express route handlers, throwing is cleaner — it propagates the error automatically up the call stack. Returning errors (as the second value of a tuple, or as a typed Result object) makes sense when the caller genuinely needs to choose between success and failure paths at the call site.
How do I handle errors in background jobs or queues?
Use the same custom error classes, but catch errors at the job level and mark the job as failed. Log the full error. Whether to retry depends on whether the error is operational (network timeout — retry makes sense) or a programmer error (invalid data shape — retrying will fail the same way).
What to learn next
Once error handling is solid: Node.js security basics (which builds directly on validation and error boundary patterns), structured logging with a library like pino for JSON logs that observability tools can parse, and testing async error paths with node:test.
Related on Baseline
- Node.js async patterns — the async foundation that error handling builds on
- Node.js security basics — validation and safe error exposure
- Node.js testing with node:test — testing that error paths behave correctly
Takeaways
Every Node.js error is either an expected failure (operational) or a bug (programmer error). Operational errors get handled gracefully; programmer errors get logged loudly and trigger a restart. Custom error classes carry status codes and context. A centralized error middleware handles the formatting. Process-level safety nets are last-resort catches — not a substitute for structured handling throughout the code.
If you remember only one thing: never write a catch block that swallows the error silently. If you catch an exception and do nothing with it, you've hidden a problem that will surface later at a completely unrelated point in the code — and that debugging session will be very unpleasant.