⚡ Asynchronous Error Handling
Almost every Express route talks to a database, a file system, or another API — and all of that work is asynchronous. Asynchronous errors don't follow the same path as synchronous ones, so the error middleware you built last lesson can miss them entirely. This lesson closes that gap for callbacks, promises, and async/await.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why Express 4 does not automatically catch errors from async handlers
- Forward callback errors correctly with
next(err)and modernize them withpromisify - Write and apply a reusable catchAsync wrapper to eliminate repetitive try/catch
- Use Express 5's built-in async error forwarding and know when to still reach for the wrapper
- Organize errors with domain-specific classes and add global handlers for uncaught exceptions and unhandled rejections
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Build a catchAsync utility, apply it to async routes, and prove it forwards a rejected database call to your error handler.
In This Lesson
The Challenge of Async Errors
Synchronous errors travel up the call stack and land in Express's error middleware. Asynchronous errors do not — by the time a database call rejects, the function that started it has already returned and its stack frame is gone. The error has nowhere to bubble to.
💡 A mental picture: A synchronous error is a ball dropped down a chute — gravity delivers it to the bottom. An async error is a ball thrown after the person holding the chute has already walked away. Nobody is there to catch it unless you arrange for a catcher in advance.
The consequences are concrete: in Express 4, an unhandled rejection in a route means the request hangs forever (the client eventually times out) and Node logs an UnhandledPromiseRejection warning that can crash the process on newer runtimes.
Why Express Misses Them
Express 4's middleware system predates promises and async/await. Its try/catch wraps only the synchronous call to your handler. Look closely:
function handler(req, res, next) {
throw new Error('sync'); // Caught — Express wraps this call
setTimeout(() => {
throw new Error('async'); // NOT caught — different tick, no stack
}, 100);
Promise.reject(new Error('promise')); // NOT caught — rejection, not a throw
}
An async function always returns a promise. When it rejects, there is no throw for Express 4 to intercept — the framework simply never learns the request failed. That is the gap every solution below fills: getting the rejection back to next().
📖 Key Term: forwarding
"Forwarding" an error means calling next(err). That single call is what hands control to your central error middleware. Every technique in this lesson is really just a different way of making sure next(err) gets called when an async operation fails.
Handling Callback Errors
Plenty of older libraries still use Node's error-first callback style. There is no magic here — you check the error and forward it yourself:
app.get('/users/:id', (req, res, next) => {
db.getUser(req.params.id, (err, user) => {
if (err) return next(err); // Forward DB errors
if (!user) return next(new NotFoundError('User'));
res.json(user);
});
});
Better still, stop writing callbacks. Node's built-in util.promisify converts any error-first callback function into one that returns a promise, so you can use it with async/await:
const { promisify } = require('node:util');
const getUser = promisify(db.getUser); // now returns a promise
app.get('/users/:id', async (req, res, next) => {
try {
const user = await getUser(req.params.id);
if (!user) return next(new NotFoundError('User'));
res.json(user);
} catch (err) {
next(err);
}
});
💡 promisify's contract
util.promisify works with any function whose last argument is a callback taking (error, result). It's the cleanest way to modernize legacy code — but notice we still need try/catch in every route. That repetition is exactly what the next section removes.
The catchAsync Wrapper
Writing try/catch in every async route is noise. The classic fix is a tiny higher-order function that wraps a handler, runs it, and pipes any rejection straight to next:
// utils/catchAsync.js
const catchAsync = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = catchAsync;
Promise.resolve() makes it work whether your handler returns a promise or not, and .catch(next) forwards any rejection. Now routes are clean:
const catchAsync = require('../utils/catchAsync');
app.get('/users/:id', catchAsync(async (req, res, next) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User'); // Rejection forwarded for you
res.json(user);
}));
You can even wrap a whole router so every handler is covered automatically:
// utils/asyncRouter.js
const express = require('express');
const catchAsync = require('./catchAsync');
function asyncRouter() {
const router = express.Router();
for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
const original = router[method].bind(router);
router[method] = (path, ...handlers) =>
original(path, ...handlers.map(h =>
h.length === 4 ? h : catchAsync(h) // leave error middleware alone
));
}
return router;
}
module.exports = asyncRouter;
⚠️ "Cannot set headers after they are sent"
If your handler already called res.json() and then something rejects, forwarding to next can trigger a second response and this error. Guard against it: check res.headersSent in your central handler and, if true, delegate to Express's default handler with next(err) instead of writing again.
Express 5's Built-in Support
Here's the good news: Express 5 (the current major version, released in 2024) fixes this at the framework level. A rejected promise returned from an async handler is automatically forwarded to your error middleware — no wrapper required.
// Express 5 — this rejection reaches your error handler automatically
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(user);
});
| Approach | Express 4 | Express 5 |
|---|---|---|
| Bare async handler | Rejection lost | Auto-forwarded ✅ |
catchAsync wrapper | Recommended | Optional (still fine) |
express-async-errors package | Common patch | Not needed |
💡 So should I stop using catchAsync?
On Express 5 you no longer need it for correctness. Many teams keep it anyway because it's explicit, works identically across versions, and gives you one obvious place to add per-handler behavior later. Know both — you'll meet Express 4 codebases for years to come. (The old express-async-errors package solved this for Express 4 by monkey-patching the router; on Express 5 it's obsolete.)
Domain Error Classes
As an app grows, generic errors blur together. Organizing errors by domain — user, order, payment — keeps intent obvious and lets services throw meaningful types the handler can recognize:
// domains/user/errors.js
const { AppError } = require('../../errors');
class UserNotFoundError extends AppError {
constructor(userId) {
super(`User ${userId} not found`, 404, 'USER_NOT_FOUND');
}
}
class UserValidationError extends AppError {
constructor(details) {
super('User validation failed', 400, 'USER_VALIDATION_FAILED');
this.details = details;
}
}
module.exports = { UserNotFoundError, UserValidationError };
The service layer throws them; the route stays thin; the central handler already knows what to do because they all extend AppError:
// domains/user/service.js
class UserService {
async getUser(id) {
const user = await db.getUser(id);
if (!user) throw new UserNotFoundError(id);
return user;
}
}
// routes/userRoutes.js
router.get('/users/:id', catchAsync(async (req, res) => {
const user = await userService.getUser(req.params.id);
res.json(user);
}));
✅ The payoff of a shared base class
Because UserNotFoundError extends AppError, it already carries statusCode, code, and isOperational. Your central handler needs zero special cases — it formats every domain error identically. Consistency for free.
Global Safety Nets
Even with perfect route handling, something may reject where no catch can reach it — a stray timer, a background job, a bug. Node gives you two process-level events as a last resort. Treat them as a signal to log and restart, not to keep running in an unknown state.
// Synchronous bugs that escaped everything — the app is now unreliable
process.on('uncaughtException', (err) => {
console.error('UNCAUGHT EXCEPTION! Shutting down.');
console.error(err.name, err.message);
process.exit(1); // let your process manager (pm2, Docker, etc.) restart
});
// A rejected promise nobody caught
process.on('unhandledRejection', (reason) => {
console.error('UNHANDLED REJECTION! Shutting down.');
console.error(reason);
// Finish in-flight requests, then exit
server.close(() => process.exit(1));
});
⚠️ These are a net, not a strategy
After an uncaught exception the process is in an undefined state — never "log and continue." Fix the real cause in your routes and services; let these handlers exist only so a crash is logged and cleanly restarted rather than silently hanging.
Hands-on Exercise
🏋️ Prove catchAsync Forwards a Rejection
Objective: Build the catchAsync wrapper, apply it to an async route backed by a fake database that rejects, and confirm the error reaches your central handler instead of hanging.
Instructions:
- Write
catchAsyncexactly as shown earlier. - Create a fake async function
getUser(id)thatthrows whenid === '0'and resolves otherwise. - Add
GET /users/:idwrapped incatchAsyncthat awaitsgetUser. - Add a central error handler that returns JSON with the status and message.
- Request
/users/0and confirm you get a clean JSON 500 (or your chosen code), not a hung request.
💡 Hint
Without the wrapper, an unhandled rejection would leave /users/0 hanging. With catchAsync, the .catch(next) forwards the thrown error into your final (err, req, res, next) handler.
✅ Solution
const express = require('express');
const app = express();
const catchAsync = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
async function getUser(id) {
if (id === '0') throw Object.assign(new Error('DB connection lost'), { statusCode: 503 });
return { id, name: 'Ada' };
}
app.get('/users/:id', catchAsync(async (req, res) => {
const user = await getUser(req.params.id);
res.json(user);
}));
app.use((err, req, res, next) => {
console.error(err.message);
res.status(err.statusCode || 500).json({
success: false,
error: { message: err.message }
});
});
app.listen(3000, () => console.log('http://localhost:3000'));
// GET /users/0 -> 503 { "error": { "message": "DB connection lost" } }
// GET /users/7 -> 200 { "id": "7", "name": "Ada" }
🎯 Quick Quiz
Question 1: In Express 4, why is a rejected promise inside a bare async handler not caught by error middleware?
Question 2: What does catchAsync actually do?
Question 3: How should a process.on('uncaughtException') handler behave?
Summary & Quiz
🎉 Key Takeaways
- Async errors escape Express 4 because a rejection is not a synchronous throw.
- Forward callback errors with
next(err); modernize legacy APIs withutil.promisify. - A tiny catchAsync wrapper removes repetitive try/catch and forwards every rejection.
- Express 5 forwards async rejections automatically — the wrapper becomes optional but still useful.
- Domain error classes that extend
AppErrorkeep intent clear and reuse your central handler. - Add global handlers for uncaught exceptions and unhandled rejections — to log and restart, never to limp on.
📚 Further Reading
- Express — Error Handling (async section)
- Node.js — Errors documentation
- Node.js — unhandledRejection event
🚀 What's Next?
Your app now catches errors from every corner. Next we turn to error response strategies: designing a consistent JSON envelope, choosing the right status codes, and shaping responses that clients — and other developers — can rely on.
🎉 Great work!
No more silent hangs. Every async failure now flows to one predictable place.