If you're building backend services in Node.js, you’ve probably faced bugs that feel like they appear out of nowhere—silent failures, unexpected crashes, or just inconsistent behavior that doesn't show up in local testing. We certainly did. Our team had been working on a relatively complex Node.js backend powering APIs for a client-facing web dashboard. But no matter how many unit tests we wrote or code reviews we did, strange issues kept creeping into production.
Then, we made a small but powerful change: we introduced one middleware function. It didn’t refactor our logic or change any major architecture. It simply caught what was already there—errors hiding in plain sight. To our surprise, that one middleware fixed over 90% of our runtime bugs.
Let’s break it all down—what the middleware was, why it worked, and how you can implement it in your own projects to dramatically improve stability and maintainability.
The Context: A Growing Node.js Codebase
When we first started building our Node.js backend, we followed the standard practices:
- Express.js for routing
- MongoDB with Mongoose for data persistence
- JSON-based REST APIs
- A handful of routes for CRUD operations
It started simple. But over time, as we added features—authentication, complex data filtering, pagination, file uploads, rate limiting, and so on—the bugs began multiplying. Here are just a few examples of the issues we faced:
- Silent crashes: API would randomly fail and just return a 500 error.
- Uncaught promise rejections: Async functions inside route handlers failed silently.
- Lack of consistent error formatting: Logs were a mess, hard to trace.
- Multiple exit points in functions: Hard to debug stack traces.
- Nested try/catch blocks: Quickly turned messy and inconsistent.
The scary part? These weren’t bugs you could catch with a linter. And even worse, many were intermittent.
The Turning Point: Introducing Centralized Error-Handling Middleware
We stepped back and asked ourselves: “Where are all these errors actually happening?”
The answer? Almost always inside asynchronous route handlers—those little async (req, res) => {}
functions we casually throw into Express routes. They might look clean, but if anything throws an error and it’s not properly try/catch
'd, Express just doesn’t know what to do with it. The request hangs, or worse, crashes the app.
Then, we discovered the solution that changed everything.
The Middleware That Saved Us
const asyncHandler = fn => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
That’s it. Just five lines.
We wrapped every single route handler in asyncHandler
. Suddenly, every unhandled rejection and every async bug that used to slip through the cracks was now being caught and passed down to our centralized error handler.
And from there, logging and debugging became predictable, consistent, and manageable.
How We Used It in Practice
Before:
app.get('/api/users', async (req, res) => {
const users = await User.find(); // what if this throws?
res.json(users);
});
If User.find()
throws, Express won’t catch it. The app might crash or hang.
After:
app.get('/api/users', asyncHandler(async (req, res) => {
const users = await User.find();
res.json(users);
}));
Now, if anything fails in the async block, the asyncHandler
catches it and forwards it to Express's next()
function, which invokes the error-handling middleware.
Setting Up the Error Handling Middleware
Once you use asyncHandler
, you need a good error handler. Here’s how we set that up:
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.statusCode || 500).json({
success: false,
message: err.message || 'Server Error',
});
});
This final piece ensures that every thrown error—no matter where it originates—is:
- Logged properly (with stack trace)
- Responded with consistent JSON
- Never crashes the server
Now our logs actually meant something, and we weren’t getting “undefined” errors in the console anymore.
The Results: 90% Fewer Bugs, Faster Debugging
Within just a couple of weeks of rolling out this pattern across all routes, here’s what we saw:
Issue Type | Before Middleware | After Middleware |
---|---|---|
Silent promise rejections | Frequent | Almost never |
Route handlers crashing app | Occasional | Rare |
Logs with incomplete errors | Often | Fixed |
Dev time spent debugging | High | Reduced by 70%+ |
Bugs reported by QA | Constant | Minimal |
We ran an internal audit of the bugs resolved over the past few months and estimated that 90% of them were directly related to unhandled async behavior or poor error formatting. This middleware solved both.
Why This Middleware Works So Well
Let’s dissect why such a tiny middleware is so impactful:
1. It Centralizes Error Handling
You no longer need to remember to wrap every await
in a try/catch
. Your brain gets a break from repetitive boilerplate.
2. It Prevents Crashes
Unhandled promise rejections can crash Node.js in strict environments. This middleware makes your server resilient.
3. It Enhances Maintainability
Instead of debugging 20 separate handler files, you can look at a single error-handling file and make improvements in one place.
4. It Standardizes Error Responses
Clients consuming your API will always get a proper JSON error, instead of HTML dumps or hanging requests.
5. It’s Framework-Agnostic
This pattern works with any Express-like middleware-based Node.js framework—Koa, Fastify, NestJS (with some tweaks), and more.
Bonus: Create Your Own Custom Error Classes
After the success of asyncHandler, we took it a step further. We defined our own AppError
class for structured error throwing:
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
Error.captureStackTrace(this, this.constructor);
}
}
Now instead of throw new Error("User not found")
, we could do:
throw new AppError("User not found", 404);
And our centralized middleware could use that to send more accurate status codes, improving both client and developer experience.
Pro Tips for Using This Middleware Effectively
- Wrap ALL route handlers: One missing wrap can cause elusive bugs.
- Use with custom error classes: For clear and concise status management.
-
Add a logging service: Use
winston
,pino
, or send logs to Loggly/Datadog. -
Use it with validation middleware: Like
express-validator
, and throw structured errors on invalid input. -
Avoid mixing
try/catch
unless necessary: Let the async handler do the heavy lifting.
What If You're Using Other Frameworks?
While Express is the most common use case, this pattern or variation of it can be applied elsewhere:
-
NestJS: Use
Exception Filters
or wrap controllers with interceptors. - Koa: Use try/catch in middleware or use koa-router that supports async error propagation.
- Hapi.js: Errors thrown in handlers are captured by default; still, centralized logging helps.
Real-World Example: Fixing a Critical Production Bug
Let’s wrap this up with a real story.
We had a payment route that interacted with Stripe. Occasionally, users would get charged but the response would fail, leading to panic.
Original code:
app.post('/api/charge', async (req, res) => {
const charge = await stripe.charges.create({
amount: 2000,
currency: 'usd',
source: req.body.tokenId,
});
await saveToDB(charge); // This sometimes failed
res.status(200).json({ success: true });
});
If saveToDB(charge)
failed, no response was sent. Users thought payments were broken, even when they went through.
After fix:
app.post('/api/charge', asyncHandler(async (req, res) => {
const charge = await stripe.charges.create({
amount: 2000,
currency: 'usd',
source: req.body.tokenId,
});
await saveToDB(charge);
res.status(200).json({ success: true });
}));
Now, if anything fails, it’s caught, logged, and the client gets a structured error with success: false
. No more ghost bugs.
Final Thoughts: Small Changes, Big Wins
Backend engineering is full of small, quiet bugs that eat up your time and energy. But sometimes, the best solutions aren’t massive rewrites or frameworks—they’re simple, consistent practices like this middleware.
TL;DR
- Node.js async errors often go unhandled in Express routes.
- One middleware (
asyncHandler
) catches all async errors and forwards them to Express. - It reduces crashes, makes debugging easier, and improves consistency.
- Combine it with custom error classes and centralized error logging for best results.
- This small change fixed over 90% of our app's runtime bugs.
You may also like:
Read more blogs from Here
Share your experiences in the comments, and let's discuss how to tackle them!
Follow me on LinkedIn
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.