Handling rejected promises in Express.js requests

Background

In 2015, JavaScript introduced async keyword which allows functions to await on promises, causing execution of the current context to pause until the promise resolves or rejects. The older way of handling promises them using then and catch introduced an additional level of nesting.

It’s reasonable to expect that Express handles traditional errors and rejected promises (and thrown errors within an async function) the same way. Unfortunately, Express v4 has a gotcha where rejected promises in an async function require additional treatment.

Synchronous errors

In the following typical Express request handler, the doSomething function throws an error.

app.get("/sync-example", (req, res) => {
  doSomething();
  res.status(200).send("OK");
});

What happens when a client makes a request to this endpoint?

curl -v localhost:3000/sync-example
*   Trying ::1:3000...
* Connected to localhost (::1) port 3000 (#0)
> GET /sync-example HTTP/1.1
...
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 500 Internal Server Error
...
<
... Stack trace rendered as HTML ...
* Connection #0 to host localhost left intact

The following happens:

  1. The server responds with a 500 error.
  2. The client receives a stack trace rendered as HTML.
  3. The server continues serving request.

Note that setting NODE_ENV to production causes Express to return less verbose error messages.

Asynchronous Errors

What happens when we try the same thing with an asynchronous request handler?

Express request handlers are just functions, so of course it is possible to use an async function as a request handler:

app.get("/async-example", async (req, res) => {
  await asyncDoSomething();
  res.status(200).send("OK");
});

When I tested, using Node v17.3.0 and Express 4.17.1, Node crashed with the following error:

/Users/jp/example/index-2.js:22
  throw new Error("Something went wrong.");
        ^

Error: Something went wrong.
    at asyncDoSomething (/Users/jp/example/index-2.js:22:9)
    at /Users/jp/example/index-2.js:26:9
    at Layer.handle [as handle_request] (/Users/jp/example/node_modules/express/lib/router/layer.js:95:5)
    at next (/Users/jp/example/node_modules/express/lib/router/route.js:137:13)
    at Route.dispatch (/Users/jp/example/node_modules/express/lib/router/route.js:112:3)
    at Layer.handle [as handle_request] (/Users/jp/example/node_modules/express/lib/router/layer.js:95:5)
    at /Users/jp/example/node_modules/express/lib/router/index.js:281:22
    at Function.process_params (/Users/jp/example/node_modules/express/lib/router/index.js:335:12)
    at next (/Users/jp/example/node_modules/express/lib/router/index.js:275:10)
    at expressInit (/Users/jp/example/node_modules/express/lib/middleware/init.js:40:5)

Node.js v17.3.0

And the client received an empty response.

Async Handler to the rescue

The Express error handling documentation takes pains to describe how rejected promises must be handled by calling next(err). Of course, this is quite verbose and also requires the programmer to catch every single rejected promise error (in contrast to how synchronous errors are handled).

The documentation states that Express version 5 will fix this. Express v5 has been in development since July 2014, and does not have an expected release date.

Fortunately, an npm package exists that fixes all this: express-async-handler.

In order to use it, you need to wrap all your async requests in a function call:

app.get(
  "/async-example",
  asyncHandler(async (req, res) => {
    await asyncDoSomething();
    res.status(200).send("OK");
  })
);

This is quite straightforward, but it adds unnecessary boilerplate and is quite prone to forgetting to wrap everything.

I would much prefer this behaviour to built into Express, and I have expressed this sentiment on GitHub. Such small, fundamental, libraries should really be part of core framework in order to minimise risk of left-pad type incidents.

For context, express-async-handler is 8 lines of code and Express has 12.5 million weekly downloads.

Testing with Express version 5.0

In order to test the updated behaviour in Express 5.0, I updated my package.json:

  "dependencies": {
    "express": "git+https://github.com/expressjs/express.git#7bca2737d207189e6df65d1fee6fc735d370114e"
  },

And re-ran my test with the original unwrapped code.

app.get("/async-example", asyncHandler(async (req, res) => {
  await asyncDoSomething();
  res.status(200).send("OK");
}));
$ curl -v localhost:3000/async-example

With Express v5.0 the behaviour is the same as synchronous errors in v4.x, that is:

  • The client receives a 500 response and an HTML-formatted stack trace.
  • The node process does not exit, instead an error and stack trace are shown.