Menu

Express Error Handling: Error Middleware and Async Errors

An Express error handler is middleware with four arguments, (err, req, res, next), registered after your routes. Express 5 sends it thrown errors, next(err) calls and rejected promises from async handlers.

This page includes runnable editors. Edit, run, and see output instantly.

Express handles errors with a special middleware that takes four arguments, (err, req, res, next), and is registered after all your routes. Express sends it every error thrown inside a route, every error you pass to next(err), and in Express 5 every rejected promise from an async handler. The handler decides the status code and sends one error response, so the rest of your code can just throw.

GET /users/99 answers 404 with {"error":"No user with id 99"}, and the Console shows the line from the error handler. Send GET /users/1 and the route answers normally; the error handler only runs when something goes wrong.

Errors Express catches for you

There are three ways to reach the error handler from a route, and in Express 5 all three work the same:

GET /async answers 500 with {"error":"Rejected in an async route"}; GET /sync and GET /next end in the same handler with their own messages. Now remove next from the error handler's parameters and send GET /sync again: with three parameters Express treats it as ordinary middleware, skips it, and answers with its default HTML error page.

Async errors: Express 5 vs Express 4

The /async route works because Express 5 catches the rejected promise. Express 4 did not: the error became an unhandled rejection and the request hung.

Express 4Express 5
throw in a sync handlerCaughtCaught
next(err)CaughtCaught
Rejected promise from an async handlerNot caught, request hangsCaught
Error in a setTimeout or event callbackNot caught, process crashesNot caught, process crashes

On Express 4, wrap each async handler or install the express-async-errors package:

// Express 4 only
const asyncHandler = (fn) => (req, res, next) =>
    Promise.resolve(fn(req, res, next)).catch(next);

app.get('/users/:id', asyncHandler(async (req, res) => {
    const user = await db.findUser(req.params.id);
    res.json(user);
}));

The last row is the same in both versions: Express cannot see an error thrown in a callback it did not call. Turn callback APIs into promises (most of Node has a promise version, such as fs/promises) and await them.

A 404 handler and a consistent JSON error

An API is easier to use when every error has the same shape. Give errors a status, turn unmatched routes into a 404 error, and let one handler format them all:

GET /nope matches no route, so the 404 handler turns it into an error and the answer is {"error":"Cannot GET /nope","status":404}. Send GET /users/99 and the error thrown by the route gets the same shape. Send POST /users with the body {"name": }: express.json() fails to parse it and calls next() with an error whose status is 400, so the answer is {"error":"Unexpected token '}', \"{\"name\": }\" is not valid JSON","status":400}.

Do not leak stack traces

Error messages from your own HttpError are written for the client. Messages and stacks from everything else (a database driver, a typo that throws TypeError) can reveal file paths, queries and library versions. The handler above sends Internal server error for every 5xx and logs the real error on the server instead. if (res.headersSent) return next(err); covers an error that happens after part of the response was already sent: the only safe move then is to let Express close the connection.

Express's own default handler follows NODE_ENV: it includes the stack trace in its HTML page unless NODE_ENV is production. Set it on your production server (NODE_ENV=production node server.js), but do not rely on it: a custom handler that never sends err.stack is safer.

Common mistakes

Registering the error handler before the routes. Express only passes errors forward, so a handler above a route never sees that route's errors. Put it last, after the 404 handler.

Catching an error and doing nothing with it. A try/catch that only logs leaves the request hanging. In the catch, call next(err), throw again, or send a response.

Sending two responses. Throwing after res.json() makes the error handler try to send again. Check res.headersSent, as in the example.

For how ordinary middleware and next() work, see Express middleware.

Frequently Asked Questions

Can I have more than one error handler in Express?

Yes. They run in order, and each one either sends a response or calls next(err) to pass the error on. A common split is one handler that logs and one that sends the response.

What happens if an error handler throws?

Express passes the new error to the next error handler, and if there is none, to its built-in one, which sends a 500 page. Keep error handlers simple so they cannot fail themselves.

Which status code should an error response use?

Use 4xx when the client sent something wrong (400 bad input, 401 not logged in, 403 not allowed, 404 not found, 409 conflict) and 500 when your code failed. Never answer 200 with an error in the body.

How do I return validation errors for several fields?

Answer 400 with a list, for example { "errors": [{ "field": "email", "message": "is required" }] }. With zod, map result.error.issues to that shape in the error handler so every route reports bad input the same way.

Coddy programming languages illustration

Learn to code with Coddy

Get started