Skip to content
Express.js ex errors 5 min read

Handling Async Errors

Express 4’s error-routing mechanism was designed for synchronous code: throwing inside a handler, or calling next(err), hands the error to your error-handling middleware. But async functions don’t throw synchronously — they reject a promise. If you let that rejection escape, Express never sees it, the request hangs until it times out, and on older Node versions an unhandled rejection can crash the process. This page covers the patterns that bridge that gap: manual try/catch, reusable wrappers, the express-async-errors package, and Express 5’s built-in handling.

Why async errors slip past Express

Consider an async route that awaits a database call. When db.findUser rejects, the await expression throws inside the async function, which converts that throw into a rejected promise returned by the handler. Express 4 calls your handler but ignores its return value, so the rejection goes nowhere.

// BROKEN on Express 4 — the rejection is never forwarded
app.get('/users/:id', async (req, res) => {
  const user = await db.findUser(req.params.id); // rejects here
  res.json(user);
});

Output:

# No response is ever sent. The client waits until it times out,
# and Node logs:
UnhandledPromiseRejectionWarning: Error: connection refused

The fix is to make sure every rejection ends up as a next(err) call.

The manual try/catch pattern

The most explicit approach wraps the body in try/catch and forwards the caught error. This is verbose but completely transparent — there is no magic, and it works on every Express version.

app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await db.findUser(req.params.id);
    if (!user) {
      return next(Object.assign(new Error('User not found'), { status: 404 }));
    }
    res.json(user);
  } catch (err) {
    next(err); // forward unexpected failures to error middleware
  }
});

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

Output:

HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8

{"error":"User not found"}

This is correct but tedious to repeat in every handler, and it’s easy to forget the catch on a handler that “obviously can’t fail.”

A reusable asyncHandler wrapper

Rather than write try/catch everywhere, wrap each async handler in a higher-order function that attaches a .catch(next) to the returned promise. Any rejection is then automatically routed to next, and from there to your error middleware.

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

module.exports = asyncHandler;

Now your handlers stay flat — no try/catch, just business logic. The wrapper guarantees that both rejected promises and synchronous throws are forwarded.

const asyncHandler = require('./asyncHandler');

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await db.findUser(req.params.id);
  if (!user) throw Object.assign(new Error('User not found'), { status: 404 });
  res.json(user);
}));

Promise.resolve(fn(...)) normalizes the call: if fn returns a promise it’s used directly, and if fn throws synchronously the rejected promise still flows into .catch(next). This single utility is the most popular hand-rolled solution and is what libraries like express-async-handler ship.

Tip: Apply the wrapper at registration time, not inside the handler. Wrapping once per route keeps the call sites clean and makes it obvious which handlers are async-safe.

Using express-async-errors

If you’d rather not wrap each route, the express-async-errors package patches Express so that rejected promises from async handlers are forwarded automatically. Import it once, before you create your app, and ordinary throw works inside async handlers.

npm install express-async-errors
require('express-async-errors'); // monkey-patches Express — import first
const express = require('express');
const app = express();

app.get('/users/:id', async (req, res) => {
  const user = await db.findUser(req.params.id);
  if (!user) throw Object.assign(new Error('User not found'), { status: 404 });
  res.json(user); // rejections are caught and routed for you
});

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

Warning: express-async-errors mutates Express internals at require time. It works well in practice, but because it’s a global patch rather than a per-route wrapper, prefer the explicit asyncHandler approach if you want zero hidden behavior — and drop the package entirely once you move to Express 5.

Express 5 forwards rejections automatically

Express 5 finally handles this natively. A handler that returns a rejected promise — including any async function that throws — is routed to your error-handling middleware with no wrapper and no extra package. The broken example from the top of this page simply works.

const express = require('express'); // express@5
const app = express();

app.get('/users/:id', async (req, res) => {
  const user = await db.findUser(req.params.id); // a rejection here...
  if (!user) throw Object.assign(new Error('User not found'), { status: 404 });
  res.json(user);
}); // ...is forwarded to the error handler automatically

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

Note that this only covers errors thrown within the async function’s own execution. Errors raised in a detached callback, a setTimeout, or an event emitter still escape the request scope — those need their own handling regardless of Express version.

Comparing the approaches

ApproachExpress versionBoilerplateNotes
Manual try/catchAllHighExplicit, no dependencies, easy to forget
asyncHandler wrapperAllLowRecommended for Express 4; ~3 lines of code
express-async-errors4.xNoneGlobal monkey-patch; import once
Native forwarding5.xNoneBuilt in; the long-term answer

Best Practices

  • On Express 4, wrap every async handler in an asyncHandler that ends with .catch(next) — don’t rely on remembering try/catch.
  • Throw errors carrying a status property so your central handler can emit the right HTTP code.
  • Keep a single error-handling middleware at the end of the stack; all of these patterns funnel into it.
  • Prefer the explicit wrapper over express-async-errors when you want no hidden global behavior.
  • Remember that wrappers and Express 5 only catch errors inside the async function — setTimeout, event, and stream callbacks still need their own handling.
  • When upgrading to Express 5, remove express-async-errors and any wrappers that exist solely to forward rejections.
Last updated June 14, 2026
Was this helpful?