Express Error Handling
Every real Express application eventually hits a bad database query, an invalid request body, or a typo that throws a TypeError. What separates a fragile API from a reliable one is not whether errors happen, but what the application does when they do. Express has a dedicated mechanism for this: error-handling middleware, a special kind of middleware function that Express recognizes by its shape and routes every error through, so you can log it, pick the right HTTP status code, and send a clean response instead of letting the process crash or the request hang forever.
Overview: How Error Handling Works in Express
Express processes a request by walking forward through a stack of middleware and route handlers in the order they were registered with app.use(), app.get(), app.post(), and so on. Each function normally calls next() to hand control to the next one in line, or sends a response and stops the chain.
Error handling hooks into that same stack. If you call next(err) with any argument — instead of the usual bare next() — Express stops walking forward through normal middleware and instead skips ahead to the nearest error-handling middleware: a function with exactly four parameters, (err, req, res, next). Express tells error handlers apart from regular middleware purely by counting that function’s declared parameters (its arity), so the fourth parameter is not optional decoration — leaving it off silently turns your handler into ordinary middleware that never receives the error.
If your app defines no error-handling middleware at all, Express falls back to a built-in default handler that logs the stack trace to the console (in development) and sends a generic 500 response. That default handler is fine for prototypes but gives callers no useful JSON body and leaks stack traces if misconfigured, so any real API should register its own.
Synchronous vs. asynchronous errors
A synchronous throw inside a normal (non-async) middleware or route handler is caught automatically by Express and forwarded to the error handler — you do not need a try/catch for that case. An asynchronous error is different: a rejected promise or a throw inside an async function does not automatically propagate as a thrown exception to Express’s internal try/catch. In Express 5 (the current major version, requiring Node.js 18+), this gap has been closed — if a route handler or middleware is an async function, Express automatically awaits it, catches any rejection, and forwards it to next(err) for you. On Express 4, that auto-catching does not exist: you must wrap the body in try/catch and call next(err) yourself, or every unhandled rejection silently drops the request with no response ever sent.
Syntax
An error-handling middleware function always has this shape:
app.use((err, req, res, next) => {
// inspect err, decide a status code, send a response
});
| Parameter | Meaning |
|---|---|
err |
The value passed to next(err) or thrown — usually an Error instance, but can be anything |
req |
The Express request object for the failed request |
res |
The Express response object — use it to set status and send the error body |
next |
Call it with no error to fall through to the next error handler, or with an error to skip to it |
Rules that make the four-parameter signature actually work:
- It must be registered with
app.use()(notapp.get()/app.post()), because it should apply to every route. - It must be registered after all other routes and middleware — Express only looks forward, never backward, through the stack.
- You can register more than one error handler in sequence if you want to separate logging from response formatting; each calls
next(err)to pass along to the next one.
Examples
Example 1: A basic app with a 404 handler and a central error handler
const express = require("express");
const app = express();
app.get("/", (req, res) => {
res.send("Home page");
});
app.get("/users/:id", (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
const err = new Error("Invalid user id");
err.statusCode = 400;
return next(err);
}
res.json({ id, name: "Ada Lovelace" });
});
// 404 handler: runs only if no route above matched the request
app.use((req, res) => {
res.status(404).json({ error: "Not found" });
});
// Error-handling middleware: must take exactly 4 arguments, and must be
// registered after every other app.use()/app.get()/etc. call
app.use((err, req, res, next) => {
console.error(err.stack);
const status = err.statusCode ?? 500;
res.status(status).json({ error: err.message || "Internal Server Error" });
});
const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () => console.log(`Server listening on port ${PORT}`));
Output:
GET /users/abc -> 400 {"error":"Invalid user id"}
GET /users/1 -> 200 {"id":1,"name":"Ada Lovelace"}
GET /anything -> 404 {"error":"Not found"}
Notice the throwaway route throws nothing directly — it builds an Error, attaches a custom statusCode property, and passes it to next(err), which jumps straight past the 404 handler to the error handler at the bottom.
Example 2: Handling async route errors
const express = require("express");
const app = express();
function findUserById(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id === "42") {
resolve({ id, name: "Grace Hopper" });
} else {
reject(new Error(`No user with id ${id}`));
}
}, 50);
});
}
// Express 5 forwards a rejected promise from an async handler to the error
// middleware automatically, but catching it yourself lets you attach a
// meaningful statusCode instead of falling back to a generic 500.
app.get("/users/:id", async (req, res, next) => {
try {
const user = await findUserById(req.params.id);
res.json(user);
} catch (err) {
err.statusCode = 404;
next(err);
}
});
app.use((err, req, res, next) => {
const status = err.statusCode ?? 500;
res.status(status).json({ error: err.message });
});
app.listen(process.env.PORT ?? 3000);
Output:
GET /users/42 -> 200 {"id":"42","name":"Grace Hopper"}
GET /users/7 -> 404 {"error":"No user with id 7"}
Even though Express 5 would catch this rejection on its own, explicitly catching it and setting err.statusCode = 404 gives the client a meaningful 404 instead of a generic 500 — auto-forwarding is a safety net, not a substitute for deciding your own status codes.
Example 3: Custom error classes and a centralized handler
const express = require("express");
const app = express();
app.use(express.json());
class HttpError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
class NotFoundError extends HttpError {
constructor(message = "Resource not found") {
super(404, message);
}
}
class ValidationError extends HttpError {
constructor(message = "Invalid input") {
super(400, message);
}
}
const products = new Map([["1", { id: "1", name: "Keyboard", price: 49 }]]);
app.get("/products/:id", (req, res, next) => {
const product = products.get(req.params.id);
if (!product) {
return next(new NotFoundError(`Product ${req.params.id} not found`));
}
res.json(product);
});
app.post("/products", (req, res, next) => {
const { name, price } = req.body ?? {};
if (typeof name !== "string" || typeof price !== "number") {
return next(new ValidationError("name (string) and price (number) are required"));
}
const id = String(products.size + 1);
products.set(id, { id, name, price });
res.status(201).json({ id, name, price });
});
// Centralized error handler: operational errors (ones we threw on purpose)
// report their own status and message; anything else is an unexpected bug,
// so it is logged in full and hidden from the client behind a generic 500.
app.use((err, req, res, next) => {
if (res.headersSent) {
return next(err);
}
if (!err.isOperational) {
console.error(err);
}
const status = err.isOperational ? err.statusCode : 500;
const message = err.isOperational ? err.message : "Something went wrong";
res.status(status).json({ error: message });
});
app.listen(process.env.PORT ?? 3000);
Output:
GET /products/1 -> 200 {"id":"1","name":"Keyboard","price":49}
GET /products/99 -> 404 {"error":"Product 99 not found"}
POST /products {"name":"Mouse"} -> 400 {"error":"name (string) and price (number) are required"}
This pattern — a base HttpError with subclasses per situation and an isOperational flag — scales well: the handler can trust isOperational errors to have a safe, user-facing message, while anything else (a bug, a crashed dependency) is logged in full detail on the server but never leaks internals to the client.
How It Works Step by Step
- A request comes in and Express walks forward through the middleware/route stack in registration order.
- A handler either finishes the response (
res.json(),res.send(),res.end()), callsnext()to continue normally, throws synchronously, or callsnext(err). - A synchronous throw, or an explicit
next(err), makes Express stop walking through normal middleware and instead search forward for the next function with 4 parameters. - An
asynchandler that rejects behaves the same way in Express 5: the rejection is caught internally and treated exactly like a call tonext(err). - The first matching error-handling middleware runs. If it calls
next()with no argument, Express looks for the next error handler further down the stack; if it callsnext(err)again, that error (possibly a new one) continues the search. - If no error handler ever sends a response, Express’s built-in default error handler eventually sends one so the request never hangs indefinitely — but relying on that default means the client only gets a generic message.
Common Mistakes
Mistake 1: Forgetting the fourth parameter
// WRONG: only 3 parameters -- Express checks the callback's arity, so this
// is registered as ordinary middleware, never as an error handler
app.use((err, req, res) => {
console.error(err);
res.status(500).json({ error: "Internal Server Error" });
});
Because Express identifies error handlers purely by counting parameters, dropping next makes this indistinguishable from a normal middleware function — it will run on every request with err bound to req, which is almost certainly not what you want, and real errors will fall through to Express’s default handler instead.
// CORRECT: exactly 4 parameters marks this as error-handling middleware
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: "Internal Server Error" });
});
Mistake 2: Unhandled async errors on Express 4
// WRONG: on Express 4, an error here is never passed to next() -- the
// request just hangs with no response ever sent to the client
app.get("/report", async (req, res) => {
const rows = await runSlowQuery(); // rejects if the database is down
res.json(rows);
});
Even on Express 5, being explicit is safer than relying on auto-forwarding, especially in code that also has to run correctly on Express 4 or inside a non-Express async utility.
// CORRECT: catch the rejection yourself and forward it explicitly
app.get("/report", async (req, res, next) => {
try {
const rows = await runSlowQuery();
res.json(rows);
} catch (err) {
next(err);
}
});
Mistake 3: Registering the error handler in the wrong order
// WRONG: the error handler is registered before the routes, so Express
// never reaches it -- middleware only runs forward through the stack
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
app.get("/", (req, res) => {
res.send("Home");
});
// CORRECT: register every route first, the error handler last
app.get("/", (req, res) => {
res.send("Home");
});
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
Other pitfalls worth knowing: calling res.json() a second time after a response was already sent throws "Cannot set headers after they are sent to the client" — always check res.headersSent before responding from an error handler, as shown in Example 3. And logging err without ever sending a response leaves the client’s request hanging forever; a handler that catches an error must still end the response.
Best Practices
- Always give error-handling middleware exactly four parameters, even if you never use
nextinside it. - Register it after every route and other middleware — it must be last.
- Attach a
statusCode(or similar) property to errors you create so the handler can pick the right HTTP status instead of always returning 500. - Distinguish operational errors (bad input, missing resource) from programming errors (bugs) — show the former’s message to the client, log the latter and hide the details.
- Wrap
awaitcalls intry/catchinside route handlers and callnext(err), even on Express 5, so the code also works correctly if ported to Express 4 or reused outside a route. - Never let a catch block swallow an error silently — log it and either send a response or forward it with
next(err). - Check
res.headersSentin your error handler in case a partial response (e.g. a stream) was already sent before the error occurred. - Keep a dedicated 404 handler (a plain middleware with no matching route) separate from your error-handling middleware — a missing route is not the same as a thrown error.
Practice Exercises
- Build an Express app with a route
GET /divide/:a/:bthat divides two numbers from the URL params. Make it callnext(err)with a 400 status whenbis0or either param is not numeric, and verify a central error handler returns the right JSON for each case. - Write an async route that reads a JSON config file with
fs/promises. Trigger the error path by pointing it at a file that does not exist, and confirm your error handler returns a 500 with a generic message while the real error is still logged on the server. - Create two custom error classes,
UnauthorizedError(401) andForbiddenError(403), and a route that throws one or the other based on a query parameter. Extend the centralized handler so it maps each class to the correct status code without a long chain ofifstatements (a lookup keyed byerr.constructororerr.statusCodeworks well).
Summary
- Error-handling middleware is any middleware function with exactly four parameters:
(err, req, res, next). - Calling
next(err), or throwing synchronously, skips forward past normal middleware straight to the nearest error handler. - Express 5 automatically catches rejected promises from
asynchandlers; Express 4 does not — usetry/catchplusnext(err)to be safe on both. - Error-handling middleware must be registered last, after every route and other middleware.
- Custom error classes with a
statusCodeand anisOperationalflag let a single centralized handler safely expose expected errors while hiding unexpected bugs. - Always check
res.headersSentbefore sending a response from an error handler, and never let an error be silently swallowed without either logging it or ending the response.
