Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Centralized Error Handling for Asynchronous Express Routes

Hello! Last lesson, you kept a request path responsive by using asynchronous file I/O and moving substantial computation to a worker. Its temporary server handled failures inside each route. In an Express application, that approach soon leads to repeated response code—and it is easy to miss a failure from an asynchronous operation.

This lesson is about getting a failure into Express’s error pipeline, then handling it in one place. You’ll distinguish promise-based handlers from callback-based APIs, build a centralized JSON error handler in TypeScript, and check what happens when a response has already begun. Allow about 40 minutes, including the short implementation.


The boundary Express can see

Consider a route that awaits a file read. If the read rejects, the route’s async function returns a rejected Promise. Express 5 observes Promises returned by route handlers and middleware and forwards their rejections to error handling. Express 4 does not do this automatically; its async handlers need an explicit try/catch with next(error), or a suitable wrapper.

Watch these excerpts from Alex Rusin’s The ABSOLUTE BEST Way to Handle Errors in EXPRESS JS for the version contrast and the reason to add a custom handler:

The ABSOLUTE BEST Way to Handle Errors in EXPRESS JS

Alex Rusin demonstrates an async route failing under Express 4, then shows how Express 5 changes its route-level behavior. The second excerpt introduces a centralized handler.

Watch the version contrast, focusing on what Express 4 needs the route to do. Then watch the custom handler for its four-argument shape and its headersSent check. Use the excerpts for those mechanisms rather than copying their debug-mode response or returning internal error messages to clients.

The key condition is that Express must be able to observe the failure. This Express 5 route meets it:

app.get("/report", async (_request, response) => {
  const report = await loadReport();
  response.json(report);
});

If loadReport() rejects, the handler’s returned Promise rejects too. Express passes the failure to its error pipeline. But starting work without awaiting or returning its Promise breaks that connection:

// Do not use this pattern for work whose failure matters to the request.
app.get("/report", (_request, response) => {
  void loadReport().then((report) => response.json(report));
});

The handler has already returned. Express cannot see a later rejection from that detached Promise, and the request may never receive an appropriate response. Make the handler async and await the operation, or return the Promise chain with a rejection path.

Read the Express 5 guide’s explanation before implementing the pipeline:

Error Handling · Express.js 5.x

The official Express guide states precisely which asynchronous failures Express 5 forwards automatically—and which ones you must forward yourself.

In “Errors in asynchronous code,” read returned Promises and the async route example. Then, under “Working with promise chains,” compare the returned chain with the detached chain in the warning. Focus on whether the handler returns the Promise, not merely whether it creates one.

next(error) tells Express to skip ordinary remaining handlers and enter error-handling middleware. In Express 5, do not add try/catch solely to call next(error) around an awaited operation: Express already forwards its rejection. If you do catch an error to perform local cleanup, either handle it there or forward it once. Calling next(error) and then rethrowing the same error risks sending it through error handling twice.


Callbacks need an explicit handoff

Not every asynchronous API returns a Promise. With an error-first callback, such as node:fs’s callback-based readFile, the later callback receives an error argument. It is not a rejection of the route handler’s returned Promise. Pass that error to next:

import { readFile } from "node:fs";

app.get("/file", (_request, response, next) => {
  readFile("package.json", "utf8", (error, contents) => {
    if (error) {
      next(error);
      return;
    }

    response.type("text/plain").send(contents);
  });
});

The return matters: after forwarding the error, the callback must not also attempt to send a successful response. A timer callback has the same visibility boundary. If code inside it throws, a try/catch in the original route handler will not catch that later throw; catch it within the timer callback and call next(error). When an appropriate Promise-based API exists, using it with await often makes this path simpler.

Now read the callback and middleware portions of the same guide:

Error Handling · Express.js 5.x

These portions of the Express guide connect callback failures to next(error) and show how a custom error handler receives them.

In “Working with callback APIs,” start where callback errors are explained, and read the fs.readFile example immediately below. Near the end of that subsection, read the paragraph beginning “Finally, for asynchronous code” and its timer example, ending just before “The default error handler.” Next, in “Writing error handlers,” read the four-parameter definition beginning the handler signature. Continue through the advice to register it after routes. Also inspect the res.headersSent example immediately above “Writing error handlers,” and the later paragraph beginning response responsibility: an error handler must either finish the response or pass the error on.


Put one response policy at the end

An Express error handler has four parameters: (error, request, response, next). Register it after the routes it should handle. Once an error reaches it, this handler can log the failure and send a consistent, non-revealing response. An unexpected file or database failure should not send its internal message or stack trace to an API client.

Use your existing TypeScript and ESM project. This example assumes its config.port from earlier lessons and Express 5. Check your installed version with npm ls express @types/express; if needed, install compatible major versions:

npm install express@^5
npm install -D @types/express@^5

Add src/express-errors-demo.ts. The two failure routes deliberately read a file named error-demo-missing-file.txt; leave that file absent while testing.

// src/express-errors-demo.ts
import { readFile as readFileCallback } from "node:fs";
import { readFile } from "node:fs/promises";
import express, { type ErrorRequestHandler } from "express";
import { config } from "./config.js";

const app = express();

app.get("/demo/ok", (_request, response) => {
  response.json({ status: "ok" });
});

app.get("/demo/async", async (_request, response) => {
  // A rejected read makes this async handler's returned Promise reject.
  const contents = await readFile(
    "error-demo-missing-file.txt",
    "utf8",
  );
  response.json({ length: contents.length });
});

app.get("/demo/callback", (_request, response, next) => {
  readFileCallback(
    "error-demo-missing-file.txt",
    "utf8",
    (error, contents) => {
      if (error) {
        next(error);
        return;
      }

      response.type("text/plain").send(contents);
    },
  );
});

const errorHandler: ErrorRequestHandler = (
  error: unknown,
  _request,
  response,
  next,
) => {
  console.error("Request failed", error);

  if (response.headersSent) {
    next(error);
    return;
  }

  response.status(500).json({
    error: "Internal server error",
  });
};

app.use(errorHandler);

app.listen(config.port, () => {
  console.log(`Demo listening on http://localhost:${config.port}`);
});

The routes have different ways of delivering a failure, but neither decides how to present an unexpected failure to the client. The handler does that once. Its unknown error parameter is intentional: JavaScript can reject with values other than Error, so blindly reading error.message would be unsafe. The demonstration logs the value on the server, not in the response; production logging must also avoid sensitive data.

Build and run the compiled demo using the validated environment variables your project requires. For example, using the configuration from the previous lesson:

npm run build
GREETING=Welcome PORT=4100 node dist/express-errors-demo.js

In another terminal, inspect the status and body:

curl -i http://localhost:4100/demo/ok
curl -i http://localhost:4100/demo/async
curl -i http://localhost:4100/demo/callback

Expect a successful JSON response from /demo/ok. Both failure routes should return 500 with {"error":"Internal server error"}, while the server records their underlying file errors. /demo/ok should still work afterward. If a missing-file route succeeds, check that the named file really is absent from the directory where you started the server.


When a response is already underway

A centralized handler cannot replace a response after headers have been sent. For example, a route might write part of a streamed response and only then encounter a failed asynchronous operation. At that point, response.status(500).json(...) would attempt to start a second, incompatible response.

That is why the handler checks response.headersSent. If it is true, it delegates with next(error) instead of writing JSON; Express’s default error handler can deal with the already-started response, typically by closing the connection. Centralized handling provides a consistent response only when a response can still be sent. For routes that stream data, consider which operations must succeed before you begin writing.

If your project uses Express 4 rather than Express 5, keep the same centralized handler, but explicitly forward rejections from each async route—for example, by catching an awaited failure and calling next(error). The version difference is at the route-to-pipeline boundary; the four-argument handler and its placement remain the central pattern.


Takeaway

For Express 5, return or await promise-based work so its failure reaches Express automatically. Forward callback-based errors with next(error) because Express cannot observe them on its own. Register a four-argument error handler after your routes to send one safe, consistent response, and delegate when headers have already been sent.

This lesson used a generic 500 response for unexpected failures. The next lesson turns to graceful startup and shutdown: how the server and its external connections enter service and stop accepting work without abandoning in-flight requests.

Can't find a good explanation? Sign up and we'll make it for you

Sign up