Node.js Error Handling and Middleware: A Practical Guide

Central error handling middleware for Express 4: AppError, the async error trap, RFC 7807 Problem Details, request ids and process-level handlers.

Executive Summary: Express 4 does not catch errors from async handlers, so one missed rejection can hang a request. Here I build error handling middleware that fixes that: an AppError class, an asyncHandler wrapper, RFC 7807 Problem Details responses, request ids for tracing, and process handlers for errors outside the request cycle.
Error handling middleware illustration: a cracked neon green pipe throws a red spark, and an orange funnel catches the flow into an envelope

Every Express API fails sooner or later. What matters is how it fails, and here I write about the error handling middleware I add to every Node.js service before it sees real traffic. The goal is one place that turns any failure into a predictable JSON response and one useful log line.

The examples use a small task API with routes, Joi validation and a service layer. I describe that structure in building REST API endpoints in Node.js and Express. Without a central handler, that kind of app leaks an HTML stack trace when a client sends broken JSON. With the handler below, the same request gets a clean 400 with a request id. The server logs the details instead.

The code runs on Node 10 LTS and Express 4.16. Express 4 has one trap that shapes everything below: it does not catch errors from async handlers. So I show that failure first and then the fix. The Express error handling guide is the official reference for the mechanics.

Errors you throw versus errors that happen

I sort every failure into two groups before I write any error handling middleware.

Operational errors are expected in a running system. A client sends invalid input, a record does not exist, or a database times out. The code is correct, and the right answer is a clear response with the right status code.

Programmer errors are bugs. Examples include reading a property of undefined, a typo in a function name or a broken assumption. The right answer is a generic 500 for the client, a full stack trace in the log and a fix in the code. Sometimes the right answer is even to restart the process, which I cover near the end.

The distinction decides what the client may see. Operational errors can explain themselves. Programmer errors must never leak their message, because that message might contain a query, a path or a secret.

So I mark the errors I throw on purpose with a small class:

class AppError extends Error {
  constructor(status, title, detail) {
    super(detail || title);
    this.name = 'AppError';
    this.status = status;
    this.title = title;
    this.expose = true; // safe to show the message to the client
  }
}

module.exports = { AppError };

The expose flag is the same convention the http-errors package uses, which is what body-parser throws. As a result, one handler can treat my errors and body-parser errors the same way.

The Express 4 trap: async errors disappear

Express 4 catches errors thrown synchronously inside a handler and passes them to your error handling middleware. However, an async function never throws synchronously. It returns a rejected promise, and Express 4 ignores the return value of a handler.

app.get('/async-unwrapped', async () => {
  throw new Error('lost');
});

Calling that route does not return a 500. Instead, the request hangs until the client gives up, and Node 10 prints this:

(node:73183) UnhandledPromiseRejectionWarning: Error: lost
    at app.get (/srv/taskapi/src/app.js:65:51)
    ...
(node:73183) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

The fix is a three-line wrapper that forwards the rejection to next:

const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

app.get('/tasks/:id', asyncHandler(async (req, res) => {
  const task = await tasksService.get(req.params.id);
  if (!task) throw new AppError(404, 'Not Found', 'No task with this id');
  res.json(task);
}));

Wrap every async handler. If that feels repetitive, the express-async-errors package patches the router to do the same thing for every route. I still prefer the explicit wrapper, because a reader can see exactly where errors go.

The central error handling middleware

Express recognises error handling middleware by its four arguments. If you drop the unused next, Express treats the function as a normal middleware and never calls it with an error. That is the most common reason a custom handler “does nothing”.

function errorHandler(logger) {
  // Keep all four arguments. Express counts them.
  return (err, req, res, next) => {
    if (res.headersSent) return next(err);

    let status = err.status || err.statusCode || 500;
    if (status < 400 || status > 599) status = 500;
    const isClientError = status < 500;

    let title = err.title || (isClientError ? 'Bad Request' : 'Internal Server Error');
    if (err.type === 'entity.parse.failed') title = 'Malformed JSON';

    logger[isClientError ? 'warn' : 'error']({
      requestId: req.id,
      status,
      method: req.method,
      path: req.originalUrl,
      message: err.message,
      stack: isClientError ? undefined : err.stack,
    });

    res.status(status).type('application/problem+json').json({
      type: 'about:blank',
      title,
      status,
      detail: isClientError && err.expose ? err.message : undefined,
      instance: req.originalUrl,
      requestId: req.id,
    });
  };
}

A few lines here carry most of the weight.

First, the headersSent check. If a response has already started streaming, you cannot change its status any more. In that case, passing the error to the default handler lets Express close the connection properly.

Second, the status guard. A library might set status to something odd, so anything outside 400 to 599 becomes a 500.

Third, the response format. I follow RFC 7807, Problem Details for HTTP APIs, with its application/problem+json media type. Clients get the same fields for every error: type, title, status, detail and instance. I add a requestId extension, which the next section explains.

Finally, the detail rule. The client only sees the message when the status is a 4xx and the error is marked as exposed. A 500 always gets the generic title, so a database message such as “password authentication failed for user” never reaches a browser.

Request ids: how a client report becomes a log line

When a user says “it failed”, you need to find the exact log line. A request id makes that a search instead of a guess.

const uuidv4 = require('uuid/v4');

function requestId(req, res, next) {
  const incoming = req.get('x-request-id');
  req.id = incoming && /^[\w-]{8,64}$/.test(incoming) ? incoming : uuidv4();
  res.set('X-Request-Id', req.id);
  next();
}

If a proxy or another service already sent an X-Request-Id, I reuse it, so one id follows the request across services. That is the simplest form of distributed tracing. However, I only accept a short, safe format. Otherwise a client could inject arbitrary text into your logs. The id goes back in a response header and into every error body, so support can ask the user for it.

For the log side of this, my notes on structured, centralized logging show where those lines should end up.

Wiring order: the 404 and the error handling middleware go last

function notFound(req, res, next) {
  next(new AppError(404, 'Not Found', `No route for ${req.method} ${req.path}`));
}

function createApp({ logger = console } = {}) {
  const app = express();

  app.use(requestId);              // first, so every log line has an id
  app.use(express.json());
  app.use('/tasks', tasksRouter);  // all real routes

  app.use(notFound);               // only reached when nothing matched
  app.use(errorHandler(logger));   // always the very last app.use
  return app;
}

Order is everything in Express. The request id middleware runs first, so even a JSON parse failure has an id. The 404 handler comes after every route, because it should only run when nothing else matched. Then the error handling middleware closes the chain. If you mount a router after it, errors from that router skip your handler and fall through to the default HTML page.

Note that the 404 handler does not send a response itself. Instead, it creates an AppError and passes it on, so 404s get the same format as every other error.

What a client sees with this error handling middleware

Here is the real output after wiring everything up. First, a bug in a synchronous handler that throws new Error('db password is hunter2'):

HTTP/1.1 500 Internal Server Error
X-Request-Id: 08ab8535-8c02-4afd-b028-57a5e58e580c
Content-Type: application/problem+json; charset=utf-8

{"type":"about:blank","title":"Internal Server Error","status":500,"instance":"/sync-boom","requestId":"08ab8535-8c02-4afd-b028-57a5e58e580c"}

The secret stays in the log, and the client only sees the request id. Next, an operational error thrown on purpose:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json; charset=utf-8

{"type":"about:blank","title":"Conflict","status":409,"detail":"A task with this title already exists","instance":"/tasks","requestId":"8d4daf16-2b7d-4ef6-87bd-6c08888f2e59"}

And a broken JSON body, which the default Express handler would answer with an HTML stack trace:

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8

{"type":"about:blank","title":"Malformed JSON","status":400,"detail":"Unexpected token b in JSON at position 1","instance":"/tasks","requestId":"33952ffa-3234-47ca-ab8e-76978de51bd6"}

Meanwhile, the server log gets one structured line per failure. The 4xx lines are warnings without a stack, and the 5xx lines are errors with the full stack trace.

I also change the Joi validation middleware. Instead of writing a 400 itself, it now calls next(new AppError(400, 'Validation Failed', ...)) and attaches the list of issues. That way, the error handling middleware stays the only place that writes an error response.

Errors outside the request cycle

Not every error happens inside a request. Timers, event emitters and forgotten promises fail on their own. For those, Node has two process-level events, documented in the Node.js 10 process docs.

process.on('unhandledRejection', (reason) => {
  // Turn a silent warning into a loud failure.
  throw reason;
});

process.on('uncaughtException', (err) => {
  logger.error({ message: 'uncaught exception, exiting', stack: err.stack });
  process.exit(1);
});

In Node 10, an unhandled rejection only prints a warning and the process keeps going. The deprecation notice above says that will change in a future release. I do not wait for it. Rethrowing the reason turns it into an uncaught exception, which my second handler logs before exiting.

Why exit at all? After an uncaught exception, the process is in an unknown state. A half-finished operation might hold a lock or leave a connection broken. Restarting is safer than guessing, so a process manager such as PM2, systemd or Kubernetes brings up a fresh copy. Where possible, the exit handler closes the HTTP server first, so requests in flight can finish.

Troubleshooting error handling middleware

Symptom Cause Fix
Custom error handler never runs It has three arguments, not four Keep (err, req, res, next) even if next is unused
Request hangs when an async handler fails Express 4 ignores rejected promises Wrap the handler with asyncHandler
HTML error page instead of JSON Handler mounted before some routes Make the error handler the last app.use
“Cannot set headers after they are sent” The handler ran after a response started Check res.headersSent and call next(err)
Stack traces in production responses The default handler with NODE_ENV unset Set NODE_ENV=production and use your own handler

When not to build all of this

A small internal script with one route does not need Problem Details and request ids. A plain try and catch is enough there. Similarly, if an API gateway in front of you already assigns request ids, reuse its header instead of creating a second one. The core rules still apply everywhere: wrap async handlers, keep four arguments and never send a 500 message to the client.

Common error handling middleware mistakes

  • Sending errors from every handler. Each handler invents its own format. Throw or call next(err) instead.
  • Exposing err.message for 500s. Database and file system messages leak internals.
  • Swallowing errors with an empty catch. The request then hangs or returns success for a failure.
  • Logging the same error in three layers. One failure becomes three alerts. Log once, in the error handling middleware.
  • Keeping the process alive after an uncaught exception. The next request runs on corrupted state.

FAQ

How does error handling middleware work in Express?

Express treats any middleware with four arguments, err, req, res and next, as an error handler. When a handler throws synchronously or calls next(err), Express skips the normal middleware and runs the error handlers in order.

Why are my async errors not caught in Express 4?

An async function returns a rejected promise instead of throwing, and Express 4 ignores the return value of handlers. Wrap each async handler in a function that catches the rejection and passes it to next, or use the express-async-errors package.

What is RFC 7807 Problem Details?

It is a standard JSON format for HTTP API errors, served as application/problem+json. It defines the fields type, title, status, detail and instance, and allows extra fields such as a request id.

Should a Node.js process exit after an uncaught exception?

Yes. After an uncaught exception the process may hold broken state, so log the error and exit with a non-zero code. A process manager then starts a clean copy.

With one error handling middleware in place, failures become as predictable as successes. Clients get the same JSON shape for every problem. Support gets a request id to search for, and secrets stay in the server log.

Last updated on 31 October 2026

Error Handling Express Middleware Node.js

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *