Express.js remains the cornerstone of Node.js development for good reason - it's intuitive, flexible, and backed by a massive ecosystem of middleware. But beneath its simple facade lurks an unexpected gotcha that can leave even senior developers scratching their heads when their servers mysteriously crash.
The problem: Express doesn't handle Async errors
When JavaScript evolved to include Promises and async/await, Express.js didn't keep up. This creates a dangerous situation where errors in async functions aren't properly caught by Express's error handling system.
Let's break down what happens with a clear example:
// Example 1: Regular synchronous route handler
app.get("/boom", (req, res) => {
throw "This will be handled";
});
// Example 2: Async route handler with the same error
app.get("/boomasync", async (req, res) => {
throw "This will not be handled";
});
What happens behind:
In Example 1, when an error is thrown, Express catches it automatically. Your error middleware runs as expected, and a 500 response is properly sent to the client. Everything works as documented.
However, in Example 2, when an error is thrown in an async function, the behavior changes dramatically. The error becomes a rejected Promise that Express never sees. Your carefully crafted error middleware is never called. No response is sent to the client, leaving them staring at a loading screen until their browser times out. Meanwhile, Node.js logs an "unhandled promise rejection" warning in your console. Even worse, in modern Node.js versions, this unhandled rejection will actually crash your entire server.
Here are five solutions to this problem, from simplest to most comprehensive:
Solution 1: Add Try/Catch to every Async Handler
app.get("/boomasync", async (req, res, next) => {
try {
// Your async code here
throw "This error will now be handled";
} catch (error) {
next(error); // Forward to Express error handler
}
});
This solution is simple to understand and provides explicit error handling. The downside is that it requires adding try/catch blocks to every single route handler, which can be tedious and easy to forget.
Solution 2: Use the express-async-errors package
// Add this at the top of your application
require('express-async-errors');
// Now your async errors will be caught automatically
app.get("/boomasync", async (req, res) => {
throw "This will now be handled correctly";
});
With just one line at the top of your application, this package patches Express internals to properly handle async errors. It's simple and works globally, though some developers may be uncomfortable with the "magic" happening behind the scenes through monkey-patching.
Solution 3: Create a wrapper function
// Define a wrapper function
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Use it with your route handlers
app.get("/boomasync", asyncHandler(async (req, res) => {
throw "This will be handled properly";
}));
This approach strikes a balance between explicitness and convenience. You'll need to wrap every async handler with the asyncHandler function, but it's cleaner than adding try/catch blocks everywhere.
Solution 4: Upgrade to Express 5 (Alpha)
npm install express@5.0.0-alpha.8
Express 5 has built-in support for handling Promise rejections properly. However, it's been in alpha for over 7 years, so there's some risk in using it in production environments.
Solution 5: Switch to a modern framework
Consider alternatives like Koa, Fastify, or Nest.js that handle Promises natively from the ground up.
// Example with Koa
const Koa = require('koa');
const app = new Koa();
app.use(async ctx => {
// Errors here are automatically caught!
throw new Error('This error will be handled correctly');
});
Modern frameworks were designed with Promises in mind and offer better TypeScript support. The main drawback is the learning curve and potentially smaller ecosystem compared to Express.
TypeScript considerations
When using Express with TypeScript, you'll encounter another issue: middleware typically adds properties to the request object that TypeScript doesn't know about. This requires additional type declarations to prevent compiler errors.
The solution is to use module augmentation in your TypeScript code. For example, when using the pino logger, you'd need to declare the log property on the Request interface. This adds complexity to your TypeScript setup that more modern frameworks might handle better out of the box.
Testing your implementation
After implementing any solution, you should test it thoroughly by creating routes that throw errors in async functions. Make requests to these routes and verify that your error handler middleware runs, a proper 500 response is sent to the client, and most importantly, that your server stays running.
By understanding and addressing this Express.js limitation, you'll create more robust applications that properly handle errors and provide better user experiences. Don't let unhandled promise rejections silently crash your production servers!