This Fixes: Unhandled Promise Rejections Crashing Your Express API

An async route handler throws, the promise rejects with nobody listening, and Node takes the whole process down with it. One bad database call, and every in-flight request on that instance dies with it.

This is the most common way I see Express APIs fall over in production, and it is entirely preventable. The fix is three small pieces wired together: correct async error propagation, one central error-handling middleware, and a last-resort process handler that crashes cleanly instead of limping on. None of it is clever. Most teams simply never wired it up, or wired up half of it and assumed the rest.

Why the process actually dies

In Express 4, the router wraps your route handler in a synchronous try/catch. That catch only sees synchronous throws. When an async function rejects, the error escapes as an unhandled promise rejection, and the router never hears about it. Your error middleware is never called, the request hangs until it times out, and — this is the part people miss — the process itself is now in trouble.

Since Node 15, the default behaviour for an unhandled rejection is to terminate the process. Node 22 keeps that default. So a single unawaited rejection in one request does not just break that request; it kills the server for every other connection on that instance. On a single-process deployment, that is a full outage triggered by one unlucky query.

Express 5 forwards async errors — but only the ones it can see

Express 5 changes the default. Per the official guide, route handlers and middleware that return a Promise will call next(value) automatically when they reject or throw. So this now reaches your error middleware without any wrapper:

app.get('/users/:id', async (req, res) => {
  const user = await getUserById(req.params.id); // throws? Express 5 forwards it
  res.json(user);
});

That removes most of the pain. But read the wording carefully: it catches the promise the handler returns. It does not catch fire-and-forget work. A promise you start but never await or return, a setTimeout callback that throws, an event emitter that emits 'error' with no listener — none of those are visible to the router, and each still becomes an unhandled rejection or an uncaught exception that can take the process down. “Upgrade to Express 5 and delete the wrapper” is only half the story. You still need the last-resort net, and the wrapper is still worth keeping for older codebases and for making intent explicit.

The async wrapper, still useful

On Express 4 this wrapper is mandatory; on Express 5 it is belt-and-braces and harmless. It resolves whatever the handler returns and routes any rejection to next, which is exactly what the error middleware expects.

import { RequestHandler } from 'express';

// Wraps an async handler so a rejected promise always reaches next()
export const asyncHandler =
  (fn: RequestHandler): RequestHandler =>
  (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };

// Usage
app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await getUserById(req.params.id);
  if (!user) throw new AppError('User not found', 404);
  res.json(user);
}));

One error middleware, four arguments

Express identifies error-handling middleware by its arity: it must take exactly four arguments, (err, req, res, next). Drop one and it silently degrades into an ordinary middleware that never runs on error — a bug that is invisible until the day you need it. Register it last, after every route.

The other thing the handler must do is distinguish an operational error from a programmer error. An operational error is expected: a missing record, a failed validation, a 503 from an upstream service. You know what it means and you can respond with a clean status. A programmer error is a bug — undefined is not a function — and it means your process may now be in an inconsistent state. Treat the two differently.

import { ErrorRequestHandler } from 'express';

export class AppError extends Error {
  readonly statusCode: number;
  readonly isOperational: boolean;

  constructor(message: string, statusCode = 500, isOperational = true) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    Error.captureStackTrace(this, this.constructor);
  }
}

// Four arguments, or Express will not treat this as an error handler
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
  const isOperational = err instanceof AppError && err.isOperational;
  const statusCode = err instanceof AppError ? err.statusCode : 500;

  if (!isOperational) {
    // Programmer error: log the full detail, never leak it to the client
    console.error('Unexpected error:', err);
  }

  res.status(statusCode).json({
    error: isOperational ? err.message : 'Internal Server Error',
  });
};

Catching the class of bug that produces programmer errors is cheaper before it ships than after. A good deal of it — the unhandled type, the typo, the missing await — is caught by strict TypeScript and linting run in a pre-commit baseline that actually runs rather than one everyone bypasses.

The last-resort handler is for crashing cleanly, not carrying on

The final piece is the one most often misused. process.on('uncaughtException') and process.on('unhandledRejection') are not a way to keep a broken process running. Node’s own guidance is blunt: after an uncaught exception the process is in an undefined state, and continuing to serve requests from it risks corrupt data and worse. The correct move is to log, stop accepting new connections, and exit so your supervisor restarts a clean instance.

const server = app.listen(3000);

// Convert a stray rejection into an exception, then handle it in one place
process.on('unhandledRejection', (reason) => {
  throw reason;
});

process.on('uncaughtException', (err) => {
  console.error('Fatal, shutting down:', err);
  server.close(() => process.exit(1)); // let systemd / Kubernetes / PM2 restart us
});

A clean crash-and-restart is not a failure of engineering; it is the design. It bounds the blast radius to the in-flight requests on one instance and hands recovery to your orchestrator. That is what keeps mean time to recovery low, which is one of the numbers worth instrumenting alongside change-failure rate rather than guessing at. And because the crash interrupts requests mid-flight, clients will retry — which is exactly why write endpoints need to be safe to retry rather than quietly doubling a charge.

Wire all four pieces — async propagation, the wrapper where you need it, one four-argument error middleware, and a process handler that exits cleanly — and a rejected promise becomes a logged 500 and, at worst, a two-second restart. Wire none of them, and it stays what it is today: a single unlucky query with the power to take down every request on the box.

Build and rescue work

Hands-on delivery of this kind is handled by Sixteen Pillars Studio.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Most technology problems are not technology problems. They are control problems.

The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.