Skip to content
Express.js ex middleware 4 min read

Async Middleware & Error Propagation

Modern Express applications lean heavily on async/await for database queries, HTTP calls, and file I/O. But Express middleware was designed around a synchronous control flow, and a rejected promise inside an async handler does not automatically reach your error-handling middleware in Express 4. Understanding how to bridge that gap — manually in Express 4, automatically in Express 5 — is the difference between clean error pages and silent hangs or crashes.

How async errors escape Express 4

Express decides where to route an error by watching for the next(err) call. When you write a synchronous handler and throw, Express catches it and forwards the error for you. Async functions break this guarantee: an async handler returns a promise, and when that promise rejects, Express has already returned from the function call and never sees the rejection.

// Express 4 — BROKEN: a rejected promise is never forwarded
app.get('/users/:id', async (req, res) => {
  const user = await db.findUser(req.params.id); // if this throws...
  res.json(user); // ...this line never runs, and Express never gets next(err)
});

If db.findUser rejects, the request hangs until the client times out, and Node logs an UnhandledPromiseRejection. The error handler below never fires.

Warning: A try/throw in a synchronous handler is caught by Express, but a rejected promise in an async handler is not — in Express 4 you must explicitly forward it with next(err).

Forwarding rejections manually

The portable fix is to wrap the body in try/catch and pass the caught error to next. This works in every Express version.

app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await db.findUser(req.params.id);
    if (!user) {
      const err = new Error('User not found');
      err.status = 404;
      throw err;
    }
    res.json(user);
  } catch (err) {
    next(err); // hands control to the error-handling middleware
  }
});

A centralized error handler (four arguments, registered last) renders the response:

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"}

A reusable async wrapper

Writing try/catch in every route is repetitive. A small higher-order function captures the pattern: call the handler, and if its returned promise rejects, route the error to next.

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

module.exports = asyncHandler;
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);
  })
);

The handler now reads cleanly — throw anywhere, and the wrapper forwards the rejection. This works for regular middleware too, not just route endpoints.

Express 5 forwards rejections automatically

Express 5 closes the gap. The router awaits the value returned by your middleware and route handlers, so a rejected promise (or a thrown error inside an async function) is forwarded to next(err) for you. No wrapper required.

// Express 5 — the rejection is auto-forwarded to the error handler
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);
});

This applies to app.use middleware and Router-level middleware as well. The behavior only kicks in for functions that return a promise, so synchronous handlers are unaffected.

Tip: Express 5 still does not catch errors thrown in a synchronous callback you pass to something else (for example inside a setTimeout or an event listener). For those, catch and call next(err) yourself.

The express-async-errors helper

If you are on Express 4 and prefer not to wrap every handler, the express-async-errors package patches the Router to await handler results — giving you Express 5-style behavior without upgrading. Import it once, before your routes are defined.

npm install express-async-errors
require('express-async-errors'); // patch must run before routes
const express = require('express');
const app = express();

app.get('/users/:id', async (req, res) => {
  const user = await db.findUser(req.params.id);
  res.json(user); // rejections now reach the error handler automatically
});

Approach comparison

ApproachExpress versionBoilerplateNotes
Manual try/catch + next(err)4 and 5HighExplicit, no dependencies, always works
asyncHandler wrapper4 and 5LowOne small utility, wrap each handler
express-async-errors4NoneGlobal monkey-patch, import once
Native auto-forwarding5NoneBuilt in, the recommended path

Best Practices

  • On Express 5, rely on native auto-forwarding and drop wrappers; on Express 4, standardize on a single asyncHandler or express-async-errors so every route behaves consistently.
  • Always register a four-argument error handler (err, req, res, next) last, after all routes and middleware.
  • Attach a status property to errors so the error handler can choose the correct HTTP status code.
  • Never mix patterns — choosing both a wrapper and the global patch can double-forward errors.
  • Catch errors from callbacks that Express cannot see (timers, streams, event emitters) and call next(err) manually.
  • Avoid sending a response after calling next(err); the error handler owns the response from that point on.
Last updated June 14, 2026
Was this helpful?