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
- Create a helper that wraps any handler and forwards rejections.
- Wrap every async controller/middleware you register with Express.
- 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 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.codereliably. - 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
| Field | Meaning | Example |
|---|---|---|
error.code | Stable machine-readable identifier | VALIDATION_ERROR |
error.message | Human-readable summary | email is required |
error.details | Optional 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.