Consistent error responses — and no leaked stack traces — are the difference between a robust API and a fragile one.
An error-handling middleware has four parameters: (err, req, res, next). Express recognises it by that arity, so you must declare all four even if you don't use next. Register error handlers after all routes.
How errors reach it:
- Synchronous code: a
throwin a handler or middleware is caught by Express. - Callbacks: call
next(err)explicitly. - Async code (Express 5): if a handler or middleware returns a promise that rejects — an
asyncfunction that throws or awaits a failing call — Express callsnext(err)for you. Express 4 needed wrappers likeexpress-async-errorsfor this; Express 5 doesn't.
One catch: errors thrown in a callback inside an async function (say, inside a setTimeout or an event listener) are not part of the returned promise, so Express can't see them. Promisify such code.
Once an error is passed, Express skips all regular middleware and runs only error handlers. A handler can respond, or call next(err) to delegate to the next one. If you never handle it, Express's default handler responds 500 and prints the stack — including in the response body when NODE_ENV is not production.
A good pattern:
- Throw your own error classes with a
status(orstatusCode) and a safe message:throw new HttpError(404, "Project not found"). - Add a 404 handler after the routes:
app.use((req, res) => res.status(404).json(...))— unmatched requests aren't errors, they just fall through. - In the final error handler, log the full error, respond with the status if it's a client error, and a generic message for 500s. Never send stack traces to clients.
- If
res.headersSentis true (the error happened mid-stream), delegate tonext(err)so Express closes the connection.
Example
import express from "express";
const app = express();
class HttpError extends Error {
constructor(status, message) { super(message); this.status = status; }
}
const db = { findUser: async (id) => (id === "1" ? { id, name: "Ada" } : null) };
app.get("/users/:id", async (req, res) => {
const user = await db.findUser(req.params.id); // a rejection here -> error handler
if (!user) throw new HttpError(404, "User not found"); // thrown in async fn: forwarded too
res.json(user);
});
app.get("/boom", () => { throw new Error("db password is hunter2"); });
app.use((req, res) => res.status(404).json({ error: `No route for ${req.method} ${req.path}` }));
app.use((err, req, res, next) => { // 4 args = error handler
if (res.headersSent) return next(err);
const status = err.status ?? err.statusCode ?? 500;
if (status >= 500) console.error(err); // full details stay in the logs
res.status(status).json({ error: status >= 500 ? "Internal Server Error" : err.message });
});
app.listen(3000);Remember
- Error handlers take exactly four arguments and go after all routes
- Express 5 forwards rejected promises from async handlers automatically
- A 404 handler is ordinary middleware at the end, not an error handler
- Log details server-side; send safe messages to clients
Watch out for
- Declaring an error handler with three parameters, so Express treats it as normal middleware.
- Throwing inside a setTimeout or event callback in an async handler, which Express can't catch.