Episode 3 — NodeJS MongoDB Backend Architecture / 3.17 — Error Handling in Express
3.17.c — Async Handler Utility
The
asyncHandlerwrapper eliminates the need to write try-catch in every async route handler by automatically catching rejected promises and forwarding them to Express's error-handling middleware.
<< Previous: 3.17.b — Centralized Error Handling | Next: Exercise Questions >>
1. The Problem: Repetitive try-catch Blocks
In Express 4.x, every async route handler needs a try-catch block to properly forward errors. In a real application, this creates massive boilerplate:
// Without asyncHandler — every route needs try-catch
app.get('/api/users', async (req, res, next) => {
try {
const users = await User.find();
res.json({ success: true, data: users });
} catch (err) {
next(err);
}
});
app.get('/api/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) throw new ApiError(404, 'User not found');
res.json({ success: true, data: user });
} catch (err) {
next(err);
}
});
app.post('/api/users', async (req, res, next) => {
try {
const user = await User.create(req.body);
res.status(201).json({ success: true, data: user });
} catch (err) {
next(err);
}
});
app.put('/api/users/:id', async (req, res, next) => {
try {
const user = await User.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true,
});
if (!user) throw new ApiError(404, 'User not found');
res.json({ success: true, data: user });
} catch (err) {
next(err);
}
});
app.delete('/api/users/:id', async (req, res, next) => {
try {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) throw new ApiError(404, 'User not found');
res.status(204).json({ success: true, data: null });
} catch (err) {
next(err);
}
});
Problems with This Approach
| Problem | Impact |
|---|---|
Every route has identical try { ... } catch (err) { next(err); } | Cluttered, noisy code |
Easy to forget try-catch in one handler | Silent failure: request hangs, no response sent |
Easy to forget next(err) in the catch block | Error is caught but never forwarded |
| Core route logic is buried inside try-catch nesting | Harder to read and maintain |
| Cannot easily audit which routes have proper error handling | Risky for large codebases |
2. The asyncHandler Utility Function
The solution is a higher-order function that wraps any async function and catches any rejected promise, forwarding it to next(err) automatically.
// utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = asyncHandler;
How It Works, Step by Step
1. asyncHandler receives your async route handler function (fn)
2. It returns a NEW function with the (req, res, next) signature
3. When Express calls this new function:
a. It calls fn(req, res, next), which returns a Promise
b. Promise.resolve() ensures it is always a Promise (even if fn is sync)
c. .catch(next) attaches an error handler:
- If the Promise resolves → normal response, nothing extra happens
- If the Promise rejects → .catch(next) calls next(err) automatically
// What asyncHandler does internally (expanded for clarity):
const asyncHandler = (fn) => {
return (req, res, next) => {
const result = fn(req, res, next); // Call the handler
const promise = Promise.resolve(result); // Ensure it is a Promise
promise.catch((err) => next(err)); // Forward rejections to Express
};
};
Alternative Name: catchAsync
Some codebases use catchAsync instead of asyncHandler. They are identical:
// Same function, different name
const catchAsync = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
3. Using asyncHandler in Route Handlers
Wrap each async handler with asyncHandler() and remove all try-catch blocks:
const asyncHandler = require('../utils/asyncHandler');
const ApiError = require('../utils/ApiError');
const User = require('../models/User');
// BEFORE: cluttered with try-catch
app.get('/api/users', async (req, res, next) => {
try {
const users = await User.find();
res.json({ success: true, data: users });
} catch (err) {
next(err);
}
});
// AFTER: clean, focused on business logic
app.get('/api/users', asyncHandler(async (req, res) => {
const users = await User.find();
res.json({ success: true, data: users });
}));
Full CRUD Example with asyncHandler
const express = require('express');
const router = express.Router();
const asyncHandler = require('../utils/asyncHandler');
const ApiError = require('../utils/ApiError');
const User = require('../models/User');
// GET all users
router.get('/', asyncHandler(async (req, res) => {
const users = await User.find().select('-password');
res.json({ success: true, count: users.length, data: users });
}));
// GET single user
router.get('/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id).select('-password');
if (!user) {
throw new ApiError(404, 'User not found');
}
res.json({ success: true, data: user });
}));
// CREATE user
router.post('/', asyncHandler(async (req, res) => {
const user = await User.create(req.body);
res.status(201).json({ success: true, data: user });
}));
// UPDATE user
router.put('/:id', asyncHandler(async (req, res) => {
const user = await User.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true,
});
if (!user) {
throw new ApiError(404, 'User not found');
}
res.json({ success: true, data: user });
}));
// DELETE user
router.delete('/:id', asyncHandler(async (req, res) => {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) {
throw new ApiError(404, 'User not found');
}
res.status(204).json({ success: true, data: null });
}));
module.exports = router;
Notice: no try-catch anywhere, no next parameter needed in the handler (unless you explicitly need it), and throw new ApiError(...) works naturally because asyncHandler catches it.
4. express-async-errors Package
If you prefer not to write even the asyncHandler wrapper, the express-async-errors package monkey-patches Express to automatically handle async errors.
npm install express-async-errors
// app.js — just require it once at the top
require('express-async-errors');
const express = require('express');
const app = express();
// Now async errors are automatically forwarded to error middleware!
app.get('/api/users', async (req, res) => {
const users = await User.find(); // If this rejects, Express handles it
res.json(users);
});
// No asyncHandler wrapper needed
// No try-catch needed
// Errors are automatically forwarded to error-handling middleware
asyncHandler vs express-async-errors
| Feature | asyncHandler (manual) | express-async-errors (package) |
|---|---|---|
| Installation | No dependency, copy-paste utility | npm install express-async-errors |
| Usage | Wrap each route: asyncHandler(fn) | Require once: require('express-async-errors') |
| Explicitness | Clear which routes are wrapped | Implicit, may confuse new developers |
| Control | You choose which routes to wrap | All async routes are patched |
| Bundle size | 0 bytes (your own code) | Tiny (~1KB) |
| Express 5 compatibility | Works everywhere | Unnecessary in Express 5 |
| Team preference | Explicit is better than implicit | Less boilerplate wins |
Recommendation: Use asyncHandler for explicitness and zero dependencies. Use express-async-errors if your team prefers minimal boilerplate and understands the implicit behavior.
5. Combining asyncHandler + ApiError + Centralized Error Handler
The three pieces work together as a complete error handling architecture:
ARCHITECTURE FLOW
Route Handler (wrapped in asyncHandler)
│
├── throw new ApiError(404, 'Not found') ──┐
│ │
├── await db.query() rejects ├── asyncHandler catches
│ │ and calls next(err)
├── Mongoose CastError │
│ │
└── Any thrown/rejected error ─┘
│
▼
Centralized Error Handler
│
┌─────────┼──────────┐
│ │ │
CastError ApiError ValidationError
→ 400 → 404 → 400
│ │ │
└─────────┼──────────┘
│
▼
JSON Error Response
Complete Application Setup
// ----- utils/asyncHandler.js -----
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = asyncHandler;
// ----- utils/ApiError.js -----
class ApiError extends Error {
constructor(statusCode, message, isOperational = true, stack = '') {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
if (stack) {
this.stack = stack;
} else {
Error.captureStackTrace(this, this.constructor);
}
}
}
module.exports = ApiError;
// ----- middleware/errorHandler.js -----
const ApiError = require('../utils/ApiError');
const errorHandler = (err, req, res, next) => {
let error = { ...err, message: err.message, stack: err.stack };
// Mongoose bad ObjectId
if (err.name === 'CastError') {
error = new ApiError(400, `Invalid ${err.path}: ${err.value}`);
}
// Mongoose validation error
if (err.name === 'ValidationError') {
const messages = Object.values(err.errors).map((e) => e.message);
error = new ApiError(400, `Validation failed: ${messages.join('. ')}`);
}
// MongoDB duplicate key
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
error = new ApiError(409, `Duplicate value for '${field}'. Use another value.`);
}
// JWT errors
if (err.name === 'JsonWebTokenError') {
error = new ApiError(401, 'Invalid token. Please log in again.');
}
if (err.name === 'TokenExpiredError') {
error = new ApiError(401, 'Token expired. Please log in again.');
}
error.statusCode = error.statusCode || 500;
// Log server errors
if (error.statusCode >= 500) {
console.error('ERROR:', err.stack);
}
// Development: full details
if (process.env.NODE_ENV === 'development') {
return res.status(error.statusCode).json({
success: false,
message: error.message,
stack: err.stack,
error: err,
});
}
// Production: clean response
if (error.isOperational) {
return res.status(error.statusCode).json({
success: false,
message: error.message,
});
}
return res.status(500).json({
success: false,
message: 'Something went wrong',
});
};
module.exports = errorHandler;
// ----- routes/userRoutes.js -----
const express = require('express');
const router = express.Router();
const asyncHandler = require('../utils/asyncHandler');
const ApiError = require('../utils/ApiError');
const User = require('../models/User');
router.get('/', asyncHandler(async (req, res) => {
const users = await User.find().select('-password');
res.json({ success: true, count: users.length, data: users });
}));
router.get('/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id).select('-password');
if (!user) throw new ApiError(404, 'User not found');
res.json({ success: true, data: user });
}));
router.post('/', asyncHandler(async (req, res) => {
const user = await User.create(req.body);
res.status(201).json({ success: true, data: user });
}));
router.put('/:id', asyncHandler(async (req, res) => {
const user = await User.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true,
});
if (!user) throw new ApiError(404, 'User not found');
res.json({ success: true, data: user });
}));
router.delete('/:id', asyncHandler(async (req, res) => {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) throw new ApiError(404, 'User not found');
res.status(204).json({ success: true, data: null });
}));
module.exports = router;
// ----- app.js -----
const express = require('express');
const ApiError = require('./utils/ApiError');
const errorHandler = require('./middleware/errorHandler');
const userRoutes = require('./routes/userRoutes');
const app = express();
app.use(express.json({ limit: '10kb' }));
// Routes
app.use('/api/users', userRoutes);
// 404 for undefined routes
app.all('*', (req, res, next) => {
next(new ApiError(404, `Cannot find ${req.originalUrl} on this server`));
});
// Centralized error handler
app.use(errorHandler);
module.exports = app;
6. Testing Error Handling
Verify that errors propagate correctly through the entire chain:
// __tests__/errorHandling.test.js
const request = require('supertest');
const app = require('../app');
describe('Error Handling', () => {
// Test 404 for unknown routes
it('should return 404 for unknown routes', async () => {
const res = await request(app).get('/api/nonexistent');
expect(res.status).toBe(404);
expect(res.body.success).toBe(false);
expect(res.body.message).toContain('Cannot find');
});
// Test invalid MongoDB ID format (CastError)
it('should return 400 for invalid ID format', async () => {
const res = await request(app).get('/api/users/not-a-valid-id');
expect(res.status).toBe(400);
expect(res.body.message).toContain('Invalid');
});
// Test resource not found (ApiError 404)
it('should return 404 when user does not exist', async () => {
const res = await request(app).get('/api/users/507f1f77bcf86cd799439011');
expect(res.status).toBe(404);
expect(res.body.message).toBe('User not found');
});
// Test validation error
it('should return 400 for validation errors', async () => {
const res = await request(app)
.post('/api/users')
.send({}); // Missing required fields
expect(res.status).toBe(400);
expect(res.body.message).toContain('Validation failed');
});
// Test malformed JSON
it('should return 400 for malformed JSON', async () => {
const res = await request(app)
.post('/api/users')
.set('Content-Type', 'application/json')
.send('{invalid}');
expect(res.status).toBe(400);
});
// Test that error responses have consistent shape
it('should return consistent error response shape', async () => {
const res = await request(app).get('/api/nonexistent');
expect(res.body).toHaveProperty('success', false);
expect(res.body).toHaveProperty('message');
});
});
7. asyncHandler for Middleware Too
asyncHandler is not limited to route handlers. You can wrap any async middleware:
// Async authentication middleware
const protect = asyncHandler(async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
throw new ApiError(401, 'Not authorized. No token provided.');
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// If token is invalid, jwt.verify throws JsonWebTokenError → caught by asyncHandler
const user = await User.findById(decoded.id);
if (!user) {
throw new ApiError(401, 'User belonging to this token no longer exists.');
}
req.user = user;
next(); // Everything OK, proceed to route handler
});
// Usage
router.get('/profile', protect, asyncHandler(async (req, res) => {
res.json({ success: true, data: req.user });
}));
8. Production Checklist for Error Handling
Use this checklist to verify your error handling is production-ready:
ERROR HANDLING PRODUCTION CHECKLIST
[ ] ApiError class created with statusCode, message, isOperational
[ ] asyncHandler utility wraps ALL async route handlers
[ ] asyncHandler utility wraps ALL async middleware (auth, etc.)
[ ] Centralized errorHandler registered as LAST middleware
[ ] errorHandler converts Mongoose CastError → 400
[ ] errorHandler converts Mongoose ValidationError → 400
[ ] errorHandler converts MongoDB duplicate key (11000) → 409
[ ] errorHandler converts JWT JsonWebTokenError → 401
[ ] errorHandler converts JWT TokenExpiredError → 401
[ ] 404 catch-all route registered before errorHandler
[ ] Development mode shows full stack traces
[ ] Production mode hides stack traces and internal details
[ ] Programmer errors (isOperational=false) show generic message in production
[ ] Server errors (5xx) are logged with stack, URL, method, timestamp
[ ] process.on('unhandledRejection') — graceful shutdown
[ ] process.on('uncaughtException') — log and exit immediately
[ ] No stack traces or internal paths leaked in production responses
[ ] Error responses have consistent JSON shape: { success, message }
[ ] All error scenarios tested with integration tests
9. Common Mistakes and Fixes
| Mistake | What Happens | Fix |
|---|---|---|
Forgetting asyncHandler on one route | That route's async errors are unhandled; request hangs | Wrap every async handler |
Using asyncHandler but still writing try-catch | Unnecessary boilerplate | Remove try-catch; asyncHandler does it for you |
throw inside callback (not async/await) | asyncHandler cannot catch callback errors | Convert to async/await or use next(err) inside callback |
| Error handler with 3 parameters | Express treats it as regular middleware, never receives errors | Always use (err, req, res, next) — all four |
| Error handler registered before routes | Errors thrown in routes never reach the handler | Register error handler AFTER all routes |
Sending response AND calling next(err) | Cannot set headers after they are sent crash | Use return before next(err) or res.json() |
Not setting NODE_ENV in production | Development error details shown to users | Set NODE_ENV=production in your deployment |
Key Takeaways
asyncHandlerwraps async functions and catches rejected promises, callingnext(err)automatically- The implementation is just one line:
(fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next) - It eliminates all try-catch boilerplate from async route handlers
express-async-errorsis an alternative that monkey-patches Express globally — no wrapping needed- Combine asyncHandler + ApiError + centralized errorHandler for a complete error handling architecture
asyncHandlerworks for middleware too, not just route handlers- Thrown
ApiErrorinstances inside asyncHandler-wrapped functions automatically reach the centralized error handler - Always test your error handling with integration tests to verify errors propagate correctly
Explain-It Challenge
Scenario: You are reviewing a pull request from a teammate. Their Express application has 40 async route handlers. None of them use asyncHandler or try-catch. The app has a centralized error handler at the bottom of app.js. Your teammate says "the error handler will catch everything."
Explain to your teammate why this is incorrect. Describe exactly what will happen when one of those async handlers throws an error. Then propose two different solutions (one using asyncHandler, one using express-async-errors) and explain the trade-offs. Finally, describe how you would write a test to prove that errors are being handled correctly.
<< Previous: 3.17.b — Centralized Error Handling | Next: Exercise Questions >>