Error handling and async errors

Express · Practical · 14 min

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 throw in 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 async function that throws or awaits a failing call — Express calls next(err) for you. Express 4 needed wrappers like express-async-errors for 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 (or statusCode) 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.headersSent is true (the error happened mid-stream), delegate to next(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.

Check yourself

  1. In Express 5, an async route handler awaits a database call that rejects. What happens?
  2. Why is app.use((req, res) => res.status(404)...) placed after all routes?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: expressjs.com/en/guide/error-handling