Back to Blog
Node.jsJune 28, 20264 min read

Node.js Async Error Handling in Express: A Practical Guide

Master Node.js async error handling in Express to stop unhandled promise rejections. Learn to use wrapper functions and middleware for stable APIs.

Node.jsExpress.jsAsync/AwaitError HandlingBackend DevelopmentBackend

Last month, our staging environment kept crashing every time a specific database query timed out. The logs showed an "unhandled promise rejection" that bubbled up, bypassed our standard error formatting, and killed the process. We were missing a simple connection between our async service calls and the Express request cycle.

If you aren't explicitly catching errors inside your routes, you're rolling the dice on your application's uptime.

Why Node.js Async Error Handling Matters

In modern Express (v4.x), standard middleware doesn't automatically catch errors thrown inside asynchronous functions. If you forget a .catch() block or an await statement fails, the promise rejects, and Node.js triggers an unhandledRejection event. This is a death sentence for your process.

When I started, I tried manually adding try/catch blocks to every single route. It was tedious, error-prone, and left me with hundreds of lines of boilerplate. As I covered in Node.js Security: Fixing Unhandled Promise Rejections and Async Errors, leaving these gaps open isn't just a stability issue—it's a security vulnerability that can leak stack traces or leave your event loop in a weird state.

The Async Middleware Pattern

Instead of manual blocks, the cleanest approach is an express async middleware wrapper. This function wraps your route handler, catches any rejection, and forwards it to the global next() function.

Here is the utility function I use in almost every project:

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

You wrap your route handlers like this:

JAVASCRIPT
app.get(CE9178">'/users/:id', asyncHandler(async (req, res) => {
  const user = await db.findUser(req.params.id);
  res.json(user);
}));

By passing the error to next(), you delegate the response logic to your centralized error handler. If you haven't set up a central system yet, check out my guide on Express Error Handling: Centralized Middleware for Node.js APIs to standardize your responses.

Setting Up Your Express Catch-All Handler

Once you’re forwarding errors to next(), you need a final destination for them. An express catch-all handler must be defined after all your routes. This is where you format the JSON response and log the actual error for your monitoring tools.

JAVASCRIPT
app.use((err, req, res, next) => {
  console.error(err.stack); // Always log the full stack trace internally
  
  const status = err.status || 500;
  res.status(status).json({
    error: {
      message: err.message || CE9178">'Internal Server Error',
      status: status
    }
  });
});

Comparing Approaches

ApproachProsCons
try/catch in every routeExplicit, easy to debugMassive boilerplate, high maintenance
Async Middleware WrapperClean, DRY, automaticRequires wrapping every route
Express 5.0 (Native)Native async supportStill in beta/not widely adopted

Common Pitfalls to Avoid

Even with a wrapper, I see engineers make the same mistake: forgetting to return or await a promise inside the handler. If you have multiple asynchronous calls, make sure you're handling them correctly. Using Promise.all is great for performance, but if one fails, it cascades. For more complex concurrency, I often use Promise.allSettled, which I detailed in JavaScript Promise.allSettled: Mastering Async Error Handling.

Another issue is forgetting that middleware needs to be synchronous if it's not wrapped. If you perform an async operation in custom middleware and don't catch the error, you'll hit that same dreaded unhandled promise rejection scenario.

FAQ

Why does my app crash on an async error? Node.js processes crash when a promise is rejected and no .catch() handler is attached. In Express, you must explicitly pass that error to the next() function so the framework can handle it gracefully.

Can I use the express-async-errors package? Yes. If you don't want to write your own wrapper, the express-async-errors package patches the Express router to catch these errors automatically. It's a great "set it and forget it" tool, though I prefer the explicit wrapper approach for better visibility into what's happening.

Where should the catch-all handler go? Always place it at the very end of your middleware stack, after all your routes. Express matches middleware in order, so if you place it too early, you might catch requests that were supposed to be handled by downstream routes.

Next time, I'm planning to experiment with custom error classes to distinguish between 400-level user errors and 500-level system failures. For now, this wrapper pattern has saved me about two days of debugging time per month. It's not perfect, but it turns "random crashes" into "predictable error responses," which is exactly where you want to be.

Similar Posts