Error Handling Overview
Every non-trivial Express application eventually hits something that goes wrong: a missing record, a failed database connection, a malformed request body, or an unexpected bug. How those failures are surfaced determines whether your users see a helpful JSON message or a hung request and a crashed process. Express has a small but precise model for this: errors flow through a dedicated channel that ends at error-handling middleware. Understanding that flow is the foundation for everything else in this section.
The default error handler
Express ships with a built-in error handler that runs when an error reaches the end of the middleware stack and you have not registered your own. In synchronous route handlers and middleware, simply throwing — or calling next(err) with any truthy value — hands control to this handler.
const express = require('express');
const app = express();
app.get('/boom', (req, res) => {
throw new Error('Something failed');
});
app.listen(3000);
The default handler sets the response status to the error’s status (or statusCode) property, falling back to 500, and writes the error. In development (NODE_ENV unset or development) it includes the stack trace; in production it sends only the generic message so internal details are never leaked.
Output:
HTTP/1.1 500 Internal Server Error
Content-Type: text/html; charset=utf-8
Error: Something failed
at /app/index.js:5:9
...stack trace...
Tip: The default handler is a safety net, not a strategy. Once headers have already been sent, Express delegates back to Node’s default behavior and closes the connection, so always register your own handler to control the response shape.
How next(err) routes to error middleware
Express distinguishes ordinary middleware from error-handling middleware by arity — the number of declared arguments. A function with four parameters, (err, req, res, next), is treated as an error handler. Regular middleware uses three.
When you call next() with no argument, Express advances to the next matching regular middleware or route. When you call next(err) with a value, Express skips all remaining regular handlers and jumps straight to the next error-handling middleware in the stack.
app.get('/users/:id', async (req, res, next) => {
try {
const user = await db.findUser(req.params.id);
if (!user) {
const err = new Error('User not found');
err.status = 404;
return next(err); // jump to the error handler
}
res.json(user);
} catch (err) {
next(err); // forward unexpected failures
}
});
// Error-handling middleware — note the four arguments, registered last
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message });
});
Output:
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
{"error":"User not found"}
Because routing depends on argument count, the four-parameter signature is required even if you never use next inside the body. Drop the fourth argument and Express treats the function as ordinary middleware that will never receive the error.
Warning: Error-handling middleware must be registered after all routes and other middleware. Express walks the stack in registration order, so a handler defined too early will never see errors thrown by routes defined below it.
Operational versus programmer errors
Not all errors deserve the same treatment. A widely used distinction separates operational errors — expected runtime conditions in a correct program — from programmer errors, which are bugs.
| Operational errors | Programmer errors | |
|---|---|---|
| Examples | Invalid input, 404, failed DB query, network timeout | undefined is not a function, bad argument, broken logic |
| Predictable? | Yes — anticipated and handled | No — a defect in the code |
| Response | Return a meaningful status and message | Log, alert, and fix the code |
| Recovery | Retry, validate, return 4xx | Often safest to crash and restart |
Operational errors should be caught, given an appropriate status code, and forwarded to your error handler so the client gets a clean response. Programmer errors signal that the process may be in an undefined state; the robust strategy is to log them and let a supervisor (PM2, systemd, Kubernetes) restart the worker rather than limp along with corrupted state.
// Operational: anticipated, handled gracefully
function findUser(id) {
if (!isValidId(id)) {
throw Object.assign(new Error('Invalid user id'), { status: 400 });
}
return db.findUser(id);
}
Marking errors as operational — for example with an isOperational flag on a custom error class — lets a top-level handler decide whether to recover or exit. Anything not flagged as operational is treated as a bug.
Best Practices
- Register exactly one centralized error-handling middleware with the
(err, req, res, next)signature, after every route and middleware. - Attach a
status(orstatusCode) property to errors so the handler can emit the correct HTTP code instead of defaulting to500. - Distinguish operational errors (handle and respond) from programmer errors (log and let the process restart).
- Never leak stack traces or internal messages to clients in production; gate detail on
NODE_ENV. - In Express 4, forward rejected promises explicitly with
next(err)or a wrapper; Express 5 forwards them automatically. - Stop touching the response once you call
next(err)— the error handler owns it from that point on.