Free Ebook cover Express.js Beyond Basics: Middleware, Architecture, and Maintainability

Express.js Beyond Basics: Middleware, Architecture, and Maintainability

New course

13 pages

Async Error Handling Patterns for Express.js

Capítulo 5

Estimated reading time: 9 minutes

+ Exercise

Why async errors are tricky in Express

In Express, synchronous errors thrown inside a route handler are caught and forwarded to the error middleware automatically. Async code changes the story: if an error happens after the current call stack (for example, inside an await or a promise callback), Express won’t catch it unless you explicitly propagate it. The goal is reliable capture of async failures without sprinkling try/catch everywhere.

Two rules keep your app predictable:

  • Every async handler must either call next(err) or be wrapped so that rejections are forwarded automatically.
  • All errors should converge into a central error handler that formats responses consistently.

Pattern 1: Async wrapper helper (asyncHandler)

The most common pattern is a wrapper that converts a promise rejection into next(err). This keeps controllers clean and avoids repeated try/catch.

Step-by-step: implement and use asyncHandler

  1. Create a helper that wraps any handler and forwards rejections.
  2. Wrap every async controller/middleware you register with Express.
  3. Ensure you have a centralized error middleware at the end of the chain.
// utils/asyncHandler.js (CommonJS) or export default in ESM
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

module.exports = { asyncHandler };
// routes/users.js
const express = require('express');
const { asyncHandler } = require('../utils/asyncHandler');
const { usersController } = require('../controllers/usersController');

const router = express.Router();

router.get('/:id', asyncHandler(usersController.getById));
router.post('/', asyncHandler(usersController.create));

module.exports = router;

Important detail: the wrapper uses Promise.resolve(...) so it works whether the handler is truly async or just returns a promise.

Pattern 2: Promise-returning middleware convention

Another approach is to standardize that middleware/handlers always return a promise and never call next on success. You still need a small adapter to connect that convention to Express’s callback-based API.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

Adapter for promise-returning handlers

// utils/promiseMiddleware.js
const promiseMiddleware = (fn) => (req, res, next) => {
  const p = fn(req, res);
  Promise.resolve(p).then(() => undefined).catch(next);
};

module.exports = { promiseMiddleware };
// controllers/healthController.js
const healthController = {
  ping: async (req, res) => {
    res.json({ ok: true });
  }
};

module.exports = { healthController };
// routes/health.js
const express = require('express');
const { promiseMiddleware } = require('../utils/promiseMiddleware');
const { healthController } = require('../controllers/healthController');

const router = express.Router();
router.get('/ping', promiseMiddleware(healthController.ping));

module.exports = router;

This convention can be useful when you want handlers to look like “pure” functions ((req, res) => Promise) and keep next reserved for error propagation only.

Centralized error propagation with next(err)

Even with wrappers, you still need to create meaningful errors and propagate them. The key is: throw inside async functions (so the wrapper catches it), or explicitly return next(err) in non-async contexts.

Example: controller propagating a service error

// controllers/usersController.js
const { usersService } = require('../services/usersService');

const usersController = {
  getById: async (req, res) => {
    const user = await usersService.getById(req.params.id);
    res.json({ data: user });
  }
};

module.exports = { usersController };

If usersService.getById throws (or returns a rejected promise), the wrapper forwards it to the error middleware.

Consistent error shapes across layers

When controllers, services, and repositories throw arbitrary errors, the error handler becomes a pile of special cases. A maintainable approach is to normalize errors into a predictable shape as early as possible.

Define a base AppError and specific subclasses

// errors/AppError.js
class AppError extends Error {
  constructor(message, { statusCode = 500, code = 'INTERNAL', details = null, cause = undefined } = {}) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.code = code;
    this.details = details;
    if (cause) this.cause = cause; // Node.js supports Error cause; keep optional
  }
}

class NotFoundError extends AppError {
  constructor(message = 'Resource not found', opts = {}) {
    super(message, { statusCode: 404, code: 'NOT_FOUND', ...opts });
  }
}

class ValidationError extends AppError {
  constructor(message = 'Validation failed', opts = {}) {
    super(message, { statusCode: 400, code: 'VALIDATION_ERROR', ...opts });
  }
}

class ConflictError extends AppError {
  constructor(message = 'Conflict', opts = {}) {
    super(message, { statusCode: 409, code: 'CONFLICT', ...opts });
  }
}

module.exports = { AppError, NotFoundError, ValidationError, ConflictError };

Why subclasses help: they encode HTTP status and a stable code so your error handler can be simple and your clients can react predictably.

Repository: wrap low-level errors into domain-friendly errors

// repositories/usersRepository.js
const { ConflictError } = require('../errors/AppError');

const usersRepository = {
  async insert(user) {
    try {
      // Example: db.users.insert(user)
      return await db.users.insert(user);
    } catch (err) {
      // Example mapping: duplicate key from DB driver
      if (err.code === '23505' || err.code === 'ER_DUP_ENTRY') {
        throw new ConflictError('Email already exists', {
          details: { field: 'email' },
          cause: err
        });
      }
      throw err;
    }
  }
};

module.exports = { usersRepository };

Service: enforce business rules and throw typed errors

// services/usersService.js
const { NotFoundError, ValidationError } = require('../errors/AppError');
const { usersRepository } = require('../repositories/usersRepository');

const usersService = {
  async getById(id) {
    if (!id) throw new ValidationError('id is required');

    const user = await usersRepository.findById(id);
    if (!user) throw new NotFoundError('User not found', { details: { id } });

    return user;
  },

  async create(payload) {
    if (!payload?.email) {
      throw new ValidationError('email is required', { details: { field: 'email' } });
    }
    return await usersRepository.insert(payload);
  }
};

module.exports = { usersService };

Controller: keep it thin; let errors bubble

// controllers/usersController.js
const { usersService } = require('../services/usersService');

const usersController = {
  create: async (req, res) => {
    const user = await usersService.create(req.body);
    res.status(201).json({ data: user });
  }
};

module.exports = { usersController };

With this setup, the controller doesn’t need to know about database error codes or business rule details; it just awaits and responds.

Central error middleware: one place to format responses

Your error middleware should do three things: (1) detect known error types, (2) produce a stable response shape, (3) avoid leaking sensitive details.

// middleware/errorHandler.js
const { AppError } = require('../errors/AppError');

function errorHandler(err, req, res, next) {
  const isAppError = err instanceof AppError;

  const statusCode = isAppError ? err.statusCode : 500;
  const code = isAppError ? err.code : 'INTERNAL';

  // Keep response consistent
  const body = {
    error: {
      code,
      message: isAppError ? err.message : 'Unexpected error'
    }
  };

  // Optionally include details for known errors
  if (isAppError && err.details) body.error.details = err.details;

  res.status(statusCode).json(body);
}

module.exports = { errorHandler };

Place this middleware after all routes. Any wrapper that calls next(err) will end up here.

Avoiding unhandled promise rejections

Unhandled promise rejections typically happen when a promise rejects and nothing awaits it or attaches a .catch. In Express apps, the most common causes are:

  • Async route handlers not wrapped (rejection never reaches Express).
  • “Fire-and-forget” async work started in a request without a .catch.
  • Errors thrown inside callbacks of non-promise APIs (timers, event emitters) without forwarding.

Rule: never start async work without handling its rejection

// Bad: rejection may become unhandled
router.post('/send', asyncHandler(async (req, res) => {
  emailService.sendWelcomeEmail(req.body.email); // not awaited, no catch
  res.status(202).json({ queued: true });
}));

// Better: explicitly catch and log/track
router.post('/send', asyncHandler(async (req, res) => {
  emailService.sendWelcomeEmail(req.body.email)
    .catch((err) => req.log?.error?.(err));
  res.status(202).json({ queued: true });
}));

If the background task must affect the HTTP response, then you should await it so failures propagate normally.

Process-level safety net (useful, not a substitute)

You can add a process-level handler to surface issues in logs and crash/restart depending on your operational strategy. This is not a replacement for correct per-request error propagation.

process.on('unhandledRejection', (reason) => {
  // log reason; consider exiting in production
});

process.on('uncaughtException', (err) => {
  // log err; consider exiting in production
});

Errors inside middleware chains

Middleware chains introduce two common failure modes:

  • An async middleware throws/rejects and is not wrapped.
  • A middleware calls next() and later throws (for example, in a timer), which Express cannot associate with the request flow.

Wrap async middleware too (not just controllers)

// middleware/requireUser.js
const { UnauthorizedError } = require('../errors/AuthErrors');

const requireUser = async (req, res, next) => {
  const token = req.headers.authorization;
  if (!token) throw new UnauthorizedError('Missing auth token');
  req.user = await authService.verify(token);
  next();
};

// usage
router.get('/me', asyncHandler(requireUser), asyncHandler(usersController.me));

Even though requireUser calls next() on success, it can still throw before that; wrapping ensures rejections are forwarded.

Be careful with errors after calling next()

// Risky: error happens after next() in a different tick
const middleware = (req, res, next) => {
  next();
  setTimeout(() => {
    throw new Error('Boom');
  }, 0);
};

That thrown error won’t be caught by Express error middleware because it happens outside the request chain. Prefer promise-based flows you can await, or handle errors inside the callback and report them to your monitoring system.

When to use custom Error subclasses (and when not to)

Custom subclasses are most valuable when you need predictable behavior across many endpoints:

  • HTTP mapping: status codes and stable error codes (NOT_FOUND, VALIDATION_ERROR).
  • Client contracts: clients can branch on error.code reliably.
  • Security: you can safely expose messages for known errors while hiding internals for unknown ones.

Avoid creating a subclass for every tiny case. Prefer a small set of categories (validation, auth, not found, conflict, rate limit) and attach specifics in details.

Example response shape contract

FieldMeaningExample
error.codeStable machine-readable identifierVALIDATION_ERROR
error.messageHuman-readable summaryemail is required
error.detailsOptional structured metadata{ "field": "email" }

Choosing between try/catch and wrappers

Wrappers remove repetitive boilerplate, but try/catch still has a place when you want to translate an error at a specific boundary.

Translate at the boundary that has the right context

// services/paymentsService.js
const { AppError } = require('../errors/AppError');

async function charge(customerId, amount) {
  try {
    return await paymentProvider.charge({ customerId, amount });
  } catch (err) {
    // Translate provider-specific failure into your app's taxonomy
    throw new AppError('Payment failed', {
      statusCode: 502,
      code: 'PAYMENT_PROVIDER_ERROR',
      details: { customerId },
      cause: err
    });
  }
}

module.exports = { charge };

Here, try/catch is not scattered everywhere; it’s used intentionally to normalize an external dependency’s errors into your app’s error model.

Now answer the exercise about the content:

In an Express app using async route handlers, what approach best ensures async failures are consistently captured and formatted without adding try/catch in every controller?

You are right! Congratulations, now go to the next page

You missed! Try again.

Express won’t catch errors that occur after the current call stack unless they’re propagated. Wrapping async handlers converts rejections into next(err), and a centralized error middleware then formats a stable response shape.

Next chapter

Centralized Error Handling: Consistent Responses and Debuggability

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.