Async Error Handling

intermediate express async errors express-5

Express 4 has a famous wart: if an async route handler throws, Express doesn’t catch it. Your error middleware never runs, the request hangs forever, and you eventually get an unhandled rejection in the logs. This trips up every Node dev once.

In simple language: Express 4’s error machinery was written before async/await existed. It only knows how to catch errors thrown synchronously or passed to next(err). A rejected promise from an async function? Invisible to Express.

The bug, visualized

Broken in Express 4
async (req, res) => {
  throw new Error("boom"); // rejects
}

Promise rejection — Express never sees it

Client waits... and waits... and times out
Works (with fix)
async (req, res, next) => {
  try { ... } catch (e) { next(e); }
}

Error middleware runs

Client gets a clean 500

The naive fix: try/catch everywhere

Works, but pollutes every handler.

app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await db.users.findById(req.params.id);
    if (!user) return res.status(404).json({ error: "Not found" });
    res.json(user);
  } catch (err) {
    next(err); // hand off to error middleware
  }
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: "Server error" });
});

Repeat for every async route. Forget once → silent hang. Not great.

The wrapper fix

Wrap each async handler in a function that catches and forwards.

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.users.findById(req.params.id);
  if (!user) return res.status(404).json({ error: "Not found" });
  res.json(user);
}));

This is what express-async-handler does. It’s three lines of code — most teams just inline it.

The patch fix: express-async-errors

A monkey-patch that makes Express 4 understand async handlers. Import once at the top of your entry file:

npm install express-async-errors
import "express-async-errors"; // import for side effects, before anything else
import express from "express";

const app = express();

app.get("/users/:id", async (req, res) => {
  const user = await db.users.findById(req.params.id); // can throw freely
  if (!user) throw new Error("Not found");
  res.json(user);
});

app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message });
});

The library monkey-patches Express’s router so promise rejections automatically flow to next(err). It feels magical and it works, but the monkey-patch nature makes some teams squeamish.

Express 5: native support

Express 5 fixes this at the framework level. Async handlers that throw or reject now route to error middleware automatically. No patches, no wrappers.

// Express 5
import express from "express";
const app = express();

app.get("/users/:id", async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) {
    const err = new Error("Not found");
    err.status = 404;
    throw err; // Express 5 catches this
  }
  res.json(user);
});

app.use((err, req, res, next) => {
  res.status(err.status || 500).json({ error: err.message });
});

If you’re starting a new project in 2026, just use Express 5 and skip the workarounds entirely.

The error middleware itself

Whichever fix you pick, the error middleware looks the same. The key signature is four arguments — Express identifies error middleware by arity, not by name.

app.use((err, req, res, next) => {
  // log the real error
  req.log?.error({ err, path: req.path }, "Request failed");

  // map known errors to status codes
  if (err.name === "ValidationError") {
    return res.status(400).json({ error: err.message });
  }
  if (err.name === "UnauthorizedError") {
    return res.status(401).json({ error: "Unauthorized" });
  }

  // fallback — don't leak stack traces in prod
  const msg = process.env.NODE_ENV === "production" ? "Server error" : err.message;
  res.status(err.status || 500).json({ error: msg });
});

Register it last, after all your routes. Express walks middleware in order.

Gotcha: errors in event handlers

The fix only covers errors during request handling. If you setTimeout or setImmediate inside a handler and throw, Express can’t catch it — the call stack has already returned. Wrap those manually.

app.get("/work", (req, res) => {
  res.json({ queued: true });
  setTimeout(() => {
    try {
      doBackgroundWork();
    } catch (err) {
      logger.error({ err }, "Background work failed");
    }
  }, 0);
});

TL;DR

  • Express 4: use express-async-errors (patch) or an asyncHandler wrapper. Pick one and use it everywhere.
  • Express 5: native support, just throw.
  • Always register a 4-arg error middleware as your last app.use.
  • Don’t leak stack traces in production responses.