Skip to content
Express.js best practices 4 min read

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 async handler rejects and you forget next(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 most try/catch blocks 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);
  }
}
ClassStatusUse case
AppErrorcustomBase class; throw with an explicit code
NotFoundError404Missing resource
ValidationError400Bad input / failed validation
Error (generic)500Unexpected 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 uncaughtException means 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 async and route all failures to one error middleware via next(err) or an asyncHandler wrapper.
  • Use a single asyncHandler higher-order function instead of repeating try/catch in every route (or upgrade to Express 5.x for automatic forwarding).
  • Model expected failures as custom error classes carrying a statusCode and isOperational flag.
  • 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 unhandledRejection and uncaughtException, log them, and exit so a supervisor restarts a clean process.
  • Handle SIGTERM/SIGINT to close connections gracefully before exit.
Last updated June 14, 2026
Was this helpful?