The Problem with Async in Express 4
Express 4 route handlers are synchronous by default - if an async function throws a rejected promise, Express does not catch it.
// ❌ This error is silently lost
app.get('/users', async (req, res) => {
const users = await User.find(); // If this rejects...
res.json(users); // Never executes
});
// The client hangs until timeout because no response is sent
// The server logs: UnhandledPromiseRejectionWarning
Client Express
│ │
│── GET /api/users ──────→│ async handler starts
│ │ ─► await User.find() rejects
│ │ Promise rejected (unhandled)
│ │ No response sent!
│ │
│←── (connection hangs) ──│ Client waits... times out... error
Solution 1: Try/Catch in Every Handler
The explicit approach:
app.get('/users', async (req, res, next) => {
try {
const users = await User.find();
res.json(users);
} catch (err) {
next(err); // Pass to Express error handler
}
});
app.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'Not found' });
}
res.json(user);
} catch (err) {
next(err);
}
});
Pros: Explicit, no magic, no dependencies. Cons: Boilerplate in every route.
Solution 2: Async Handler Wrapper
Extract the try/catch into a reusable wrapper:
// middleware/asyncHandler.js
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
module.exports = asyncHandler;
Usage
const asyncHandler = require('../middleware/asyncHandler');
// Clean and DRY
app.get('/users', asyncHandler(async (req, res) => {
const users = await User.find();
res.json(users);
}));
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
}));
app.post('/users', asyncHandler(async (req, res) => {
const user = await User.create(req.body);
res.status(201).json(user);
}));
With Router-Level Application
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
// Apply to all routes in this router
router.get('/', asyncHandler(async (req, res) => { /* ... */ }));
router.post('/', asyncHandler(async (req, res) => { /* ... */ }));
router.put('/:id', asyncHandler(async (req, res) => { /* ... */ }));
router.delete('/:id', asyncHandler(async (req, res) => { /* ... */ }));
module.exports = router;
Solution 3: express-async-errors
Patch Express globally to catch async errors automatically:
npm install express-async-errors
require('express-async-errors'); // Must be at the very top!
const express = require('express');
const app = express();
// Now async errors are automatically caught!
app.get('/users', async (req, res) => {
const users = await User.find();
res.json(users);
});
app.get('/error', async (req, res) => {
throw new Error('This is caught automatically!');
});
// Error handler still catches everything
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
Pros: Zero boilerplate. Cons: Global side-effect, can hide issues.
Solution 4: Wrapping the Router
Apply asyncHandler to every route in a router automatically:
function asyncRouter(router) {
const wrappedRouter = express.Router();
router.stack.forEach((layer) => {
if (layer.route) {
const route = layer.route;
Object.keys(route.methods).forEach((method) => {
const handlers = route.stack.map((s) => s.handle);
route[method] = undefined; // Clear original
handlers.forEach((handler) => {
route[method](asyncHandler(handler));
});
});
}
});
return wrappedRouter;
}
// Usage
const router = express.Router();
router.get('/', async (req, res) => { /* ... */ });
router.post('/', async (req, res) => { /* ... */ });
app.use('/api', asyncRouter(router));
Handling Specific DB Errors
Mongoose
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new NotFoundError('User');
}
res.json({ success: true, data: user });
}));
Prisma
app.get('/users/:id', asyncHandler(async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.params.id }
});
if (!user) {
throw new NotFoundError('User');
}
res.json({ success: true, data: user });
} catch (err) {
if (err.code === 'P2025') {
throw new NotFoundError('User');
}
throw err;
}
}));
Combining with Validation
const { body, validationResult } = require('express-validator');
app.post('/users',
[
body('name').trim().isLength({ min: 2 }),
body('email').isEmail(),
body('age').isInt({ min: 13 })
],
asyncHandler(async (req, res) => {
// Check validation
const errors = validationResult(req);
if (!errors.isEmpty()) {
throw new AppError('Validation failed', 422,
errors.array().map(e => ({ field: e.path, message: e.msg }))
);
}
const user = await User.create(req.body);
res.status(201).json({ success: true, data: user });
})
);
Complete Setup
// index.js
require('express-async-errors');
const express = require('express');
const { AppError } = require('./errors/AppError');
const app = express();
app.use(express.json());
// Routes
app.use('/api/users', require('./routes/users'));
// 404
app.all('*', (req, res) => {
throw new AppError(`Route ${req.originalUrl} not found`, 404);
});
// Error handler
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const response = {
success: false,
error: err.isOperational ? err.message : 'Internal server error'
};
if (process.env.NODE_ENV === 'development') {
response.stack = err.stack;
}
console.error(`[${new Date().toISOString()}] ${err.message}`);
res.status(statusCode).json(response);
});
// async-error free routes
app.get('/users', async (req, res) => {
const users = await User.find();
res.json({ success: true, data: users });
});
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new AppError('User not found', 404);
res.json({ success: true, data: user });
});
Testing Async Error Handling
// Using Supertest
const request = require('supertest');
describe('GET /users/:id', () => {
it('should return 404 for non-existent user', async () => {
const res = await request(app)
.get('/api/users/nonexistent-id');
expect(res.status).toBe(404);
expect(res.body.success).toBe(false);
expect(res.body.error).toMatch(/not found/i);
});
it('should return 500 on DB failure', async () => {
// Mock DB to fail
jest.spyOn(User, 'findById').mockRejectedValue(new Error('DB down'));
const res = await request(app)
.get('/api/users/507f1f77bcf86cd799439011');
expect(res.status).toBe(500);
expect(res.body.success).toBe(false);
});
});
Key Takeaways
- Express 4 does not catch async errors - handle them explicitly
- Use asyncHandler wrapper to DRY up try/catch in every route
- Use
express-async-errorsfor zero-boilerplate global async error catching - Always call
next(err)or throw in wrapped code - never let a rejection go unhandled - Combine with centralized error handler for consistent responses
- Test async error paths to ensure proper error propagation
- For production, prefer the asyncHandler pattern over global patches