Async & Error Handling Best Practices
Most real-world bugs in Express services trace back to two things: callbacks that swallow errors and route handlers that throw into the void. The fix is a small, consistent discipline — write every handler with async/await, route all failures through one error middleware, model your errors as classes, and refuse to let the process die silently. This page lays out a setup that scales from a single route to a large API without ad-hoc try/catch scattered everywhere.
Always use async/await
Modern Express code should be async-first. Callbacks and raw promise chains make error flow hard to follow and easy to get wrong. With async/await, your handler reads top to bottom and a thrown error (or rejected promise) propagates like a normal exception — provided you forward it to Express.
import express from 'express';
import { getUserById } from './db.js';
const app = express();
app.get('/users/:id', async (req, res, next) => {
try {
const user = await getUserById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
} catch (err) {
next(err); // hand off to centralized error middleware
}
});
Gotcha: In Express 4.x, if an
asynchandler rejects and you forgetnext(err), the request hangs forever — the client eventually times out. Express 5.x fixes this by automatically forwarding rejected promises from async handlers to the error middleware, so you can drop mosttry/catchblocks once you upgrade.
Wrap handlers with asyncHandler
Typing try/catch in every route is noisy and easy to forget. A tiny higher-order wrapper catches rejections and calls next(err) for you. This is the single most useful helper in an Express 4.x codebase.
// asyncHandler.js
export const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
Now your handlers stay clean and any thrown error reaches the error middleware automatically:
import { asyncHandler } from './asyncHandler.js';
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await getUserById(req.params.id);
if (!user) throw new NotFoundError('User not found');
res.json(user);
}));
Define custom error classes
A bare Error carries no HTTP semantics. Define a base AppError with a status code and an isOperational flag (to distinguish expected failures like “not found” from programmer bugs), then subclass it per case. Your error middleware can read err.statusCode directly instead of guessing.
// errors.js
export class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
export class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 404);
}
}
export class ValidationError extends AppError {
constructor(message = 'Invalid request') {
super(message, 400);
}
}
| Class | Status | Use case |
|---|---|---|
AppError | custom | Base class; throw with an explicit code |
NotFoundError | 404 | Missing resource |
ValidationError | 400 | Bad input / failed validation |
Error (generic) | 500 | Unexpected bug — never expose details |
Centralize error handling
Express recognizes a middleware with four arguments (err, req, res, next) as an error handler. Register it last, after all routes. This is the one place that formats error responses, sets status codes, and logs — so every failure looks consistent to the client.
// errorMiddleware.js
export function errorHandler(err, req, res, next) {
const statusCode = err.statusCode || 500;
const isOperational = err.isOperational === true;
// Log unexpected (non-operational) errors with full detail
if (!isOperational) {
console.error('Unexpected error:', err);
}
res.status(statusCode).json({
error: isOperational ? err.message : 'Internal server error',
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
});
}
Wire it up after your routes and add a catch-all 404 for unmatched paths:
import { errorHandler } from './errorMiddleware.js';
app.use((req, res, next) => next(new NotFoundError(`Route ${req.originalUrl} not found`)));
app.use(errorHandler);
A request to a missing user now returns a clean, predictable body:
Output:
HTTP/1.1 404 Not Found
Content-Type: application/json
{ "error": "User not found" }
Handle process-level errors
Some failures escape the request lifecycle entirely: an unhandled promise rejection in a background job, or a synchronous throw outside any handler. These leave the process in an undefined state. Log them and exit so your process manager (PM2, systemd, Kubernetes) can restart a clean instance — do not try to keep limping along.
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Rejection:', reason);
throw reason; // let the uncaughtException handler take it down
});
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
process.exit(1);
});
// Graceful shutdown on termination signals
process.on('SIGTERM', () => {
console.log('SIGTERM received, closing server');
server.close(() => process.exit(0));
});
Warning: An
uncaughtExceptionmeans your app is in an unknown state — finish in-flight requests if you can, but always exit afterward. Treating it as recoverable risks corrupted data and memory leaks.
Best Practices
- Write every route handler as
asyncand route all failures to one error middleware vianext(err)or anasyncHandlerwrapper. - Use a single
asyncHandlerhigher-order function instead of repeatingtry/catchin every route (or upgrade to Express 5.x for automatic forwarding). - Model expected failures as custom error classes carrying a
statusCodeandisOperationalflag. - Register the error-handling middleware (four args) last, after all routes and a catch-all 404 handler.
- Never leak stack traces or internal messages to clients in production — gate detail behind
NODE_ENV. - Listen for
unhandledRejectionanduncaughtException, log them, and exit so a supervisor restarts a clean process. - Handle
SIGTERM/SIGINTto close connections gracefully before exit.