Recently, in October, the Express team announced the release of Express 5, a long-awaited update that brings several new features and improvements.

After nearly a decade of development, Express 5 is finally here! The commitment to releasing a major version after such a long time demonstrates the framework's stability and the team's dedication to keeping it aligned with modern JavaScript standards and best practices.

One feature that particularly caught my attention is native support for Promises. When I first read about it, I was both surprised and intrigued by its simplicity—and it left me wondering why it hadn't been implemented earlier.

Before diving into this feature, I recommend reviewing the following prerequisite topics:

  1. Promises
  2. Async/Await
  3. Middleware in Express
  4. Error Handling in Express

Express 5 Promise Support

According to the Express documentation:

"The main change in Express 5 regarding Promise support is the addition of basic support for returned, rejected Promises in the router."

In other words, Express 5 now automatically handles rejected Promises from middleware and route handlers, eliminating the need for explicit error handling with next(error).

Did Express Not Support Promises Before?

Yes, we could use Promises in Express 4, but handling errors required manually passing them to the next middleware using next(error). This approach often led to redundant try-catch blocks, making the code more verbose and less maintainable.

Let's look at how error handling was done in Express 4.

Express 4: Traditional Error Handling

Example Code (Express 4)

import express from "express"; // Using Express 4.21.2
const app = express();
const port = 8181;

const users = [
  { id: 0, name: "tj", email: "[email protected]", role: "member" },
  { id: 1, name: "ciaran", email: "[email protected]", role: "member" },
  { id: 2, name: "aaron", email: "[email protected]", role: "admin" },
];

const getUserById = async (id: number) => {
  throw new Error("Database Error: Can't connect to database");
};

async function loadUser(req, res, next) {
  try {
    throw new Error("Database Error: Can't connect to database");
  } catch (error) {
    next(error); // Explicit error passing
  }
}

app.get("/user/:id", loadUser, async function (req, res, next) {
  try {
    const user = await getUserById(Number(req.params.id));
    res.send("Viewing user " + user!.name);
  } catch (error) {
    next(error);
  }
});

app.use(function (err, req, res, next) {
  console.log("Received error:", err.message);
  res.status(500).send(err.message);
});

app.listen(port, () => console.log(`Server running on port ${port}`));

Problems with Express 4 Error Handling

  • Boilerplate Code: Every async middleware or route handler needed try-catch blocks.
  • Explicit Error Forwarding: Errors had to be manually passed to next(error).
  • Redundant Code: Many functions contained repetitive error handling logic.

A Workaround: Using an Error Handling Wrapper

A common way to reduce repetition was to create a wrapper function:

import express from "express"; // Using 4.21.2
const app = express();
const port = 8181;

// Dummy users
const users = [
  { id: 0, name: "tj", email: "[email protected]", role: "member" },
  { id: 1, name: "ciaran", email: "[email protected]", role: "member" },
  {
    id: 2,
    name: "aaron",
    email: "[email protected]",
    role: "admin",
  },
];

// A wrapper function to handle errors in async handlers and pass them to the next middleware(error handler)
const handleErrorWrapper = (fn: Function) => {
  return function (req: any, res: any, next: Function) {
    fn(req, res, next).catch((err: Error) => next(err));
  };
};

// Simulating method to get user by ID from the database
const getUserById = async (id: number) => {
  throw new Error("Database Error: Can't connect to database");
  return users.find((user) => user.id === id);
};

async function loadUser(req, res, next) {
  throw new Error("Database Error: Can't connect to database");
  var user = users.find((user) => user.id === req.params.id);
  if (user) {
    req.user = user;
    next();
  } else {
    next(new Error("Failed to load user " + req.params.id));
  }
}

function andRestrictTo(error, req, res, next) {
  console.log("Received the error in next middleware", error.message);
  if (req.authenticatedUser.id == req.params.id) {
    next();
  } else {
    next(new Error("Unauthorized"));
  }
}

app.use(function (req, res, next) {
  req.authenticatedUser = users[0];
  next();
});

app.get("/", function (req, res) {
  res.send(users);
});

app.get(
  "/user/:id",
  loadUser,
  handleErrorWrapper(async function (req, res) {
    const user = await getUserById(Number(req.params.id));
    res.send("Viewing user " + user!.name);
  }),
);

app.delete(
  "/user/:id",
  handleErrorWrapper(loadUser),
  andRestrictTo,
  function (req, res) {
    res.send("Deleted user " + req.user.name);
  },
);

app.use(function (err, req, res, next) {
  console.log("Received the error", err.message);
  res.status(500).send(err.message);
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

Express 5: Native Promise Error Handling

With Express 5, we no longer need to manually handle errors in async functions. If a route or middleware returns a rejected Promise, Express automatically catches and forwards the error.

Example Code (Express 5)

import express from "express";
const app = express();
const port = 8181;

const users = [
  { id: 0, name: "tj", email: "[email protected]", role: "member" },
  { id: 1, name: "ciaran", email: "[email protected]", role: "member" },
  { id: 2, name: "aaron", email: "[email protected]", role: "admin" },
];

const getUserById = async (id: number) => {
  throw new Error("Database Error: Can't connect to database");
};

async function loadUser(req, res, next) {
  throw new Error("Database Error: Can't connect to database");
}

app.get("/user/:id", loadUser, async function (req, res) {
  const user = await getUserById(Number(req.params.id));
  res.send("Viewing user " + user!.name);
});

app.use(function (err, req, res, next) {
  console.log("Received error:", err.message);
  res.status(500).send(err.message);
});

app.listen(port, () => console.log(`Server running on port ${port}`));

Why This is a Big Improvement

  • Cleaner Code: No need for try-catch in every async function.
  • Less Boilerplate: Eliminates repetitive error handling logic.
  • Automatic Error Forwarding: Express 5 automatically catches rejected Promises and forwards them to error-handling middleware.
  • Better Readability: The new approach is more intuitive and aligns with modern JavaScript practices.

How Might This Be Implemented Internally?

The Express 5 team likely implemented this using a mechanism similar to this high-level wrapper:

function wrapWithErrorHandler(callback, errorHandler) {
  return function (...args) {
    try {
      callback(...args);
    } catch (error) {
      errorHandler(error, ...args);
    }
  };
}

// Example usage:
function handler(req, res, next) {
  // This will throw an error
  throw new Error("Something went wrong!");
}

function errorMiddleware(err, req, res, next) {
  console.error("Error:", err.message);
  // Handle the error (e.g., send a response to the client)
  res.status(500).send("Internal Server Error");
}

const wrappedFunction = wrapWithErrorHandler(handler, errorMiddleware);

// Simulate a request
const req = {}; // Mock request object
const res = { status: (code) => ({ send: (message) => console.log(message) }) }; // Mock response object
const next = () => {}; // Mock next function

wrappedFunction(req, res, next);

This concept extends the router and middleware execution to automatically catch errors in async functions, making the framework more resilient and easier to use.

Conclusion

Express 5’s native support for Promises is a small but powerful improvement that significantly enhances developer experience. It simplifies error handling, improves code readability, and eliminates unnecessary boilerplate.