Async Error Handling Patterns
In Node.js, most errors occur in asynchronous operations — database queries, file reads, API calls. Proper error handling ensures your application does not crash and returns meaningful error messages to clients.
With async/await, use try/catch blocks to handle errors. For Express, you can create a wrapper function that automatically catches errors and passes them to the error middleware.
Example
const express = require('express');
const app = express();
// Wrapper function to catch async errors automatically
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// Without wrapper — manual try/catch (repetitive)
app.get('/api/users', async (req, res, next) => {
try {
const users = await User.find();
res.json(users);
} catch (err) {
next(err);
}
});
// With wrapper — cleaner code
app.get('/api/users', asyncHandler(async (req, res) => {
const users = await User.find();
res.json(users);
}));
app.get('/api/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
res.json(user);
})); - try/catch — Wrap async operations to catch errors
- next(err) — Pass errors to Express error middleware
- asyncHandler — Wrapper that auto-catches async errors
- throw new Error() — Create and throw custom errors
- Always handle errors — unhandled rejections crash the app
Notes
- Starting with Express 5, async errors in route handlers are automatically caught. In Express 4, you need the asyncHandler wrapper or try/catch.
Custom Error Classes and Global Handler
Custom error classes let you create specific error types with status codes and additional context. A global error handler middleware then processes all errors consistently.
This pattern keeps your route handlers clean — they just throw errors, and the global handler formats and sends the response.
Example
// utils/AppError.js — Custom error class
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true; // Expected errors (not bugs)
}
}
module.exports = AppError;
// Usage in routes
const AppError = require('./utils/AppError');
app.get('/api/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new AppError('User not found', 404);
}
res.json(user);
}));
app.post('/api/users', asyncHandler(async (req, res) => {
if (!req.body.email) {
throw new AppError('Email is required', 400);
}
const user = await User.create(req.body);
res.status(201).json(user);
}));
// Global error handler (in app.js, after all routes)
app.use((err, req, res, next) => {
console.error(`[ERROR] ${err.message}`);
// Mongoose validation error
if (err.name === 'ValidationError') {
return res.status(400).json({
error: 'Validation Error',
details: Object.values(err.errors).map(e => e.message)
});
}
// Mongoose cast error (invalid ObjectId)
if (err.name === 'CastError') {
return res.status(400).json({ error: 'Invalid ID format' });
}
// Our custom AppError
if (err.isOperational) {
return res.status(err.statusCode).json({ error: err.message });
}
// Unexpected errors — don't leak details
res.status(500).json({ error: 'Something went wrong' });
}); Notes
- Distinguish between operational errors (expected, like 'user not found') and programming errors (bugs). Only show detailed messages for operational errors.
