Suggest an editImprove this articleRefine the answer for “How to handle async/await in Express.js route handlers?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Async/await in Express.js route handlers** - Express doesn't catch Promise rejections from async functions automatically, so errors skip your error middleware entirely. ```js const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); app.get('/users', asyncHandler(async (req, res) => { const users = await User.findAll(); // rejection forwarded to error handler res.json(users); })); ``` **Key point:** wrap every async handler and middleware with `asyncHandler`, or use `express-async-errors`, or upgrade to Express 5 which handles this natively.Shown above the full answer for quick recall.Answer (EN)Image**Async/await in Express.js route handlers** - Express doesn't catch Promise rejections from async handlers automatically, so unhandled errors skip your error middleware entirely. ## Theory ### TL;DR - Express was built before async/await existed. Its error handling is synchronous. - When an async handler rejects, the rejection is queued as a microtask. Express's try/catch has already exited by then. - Unhandled rejections don't reach your error middleware - they crash the process or trigger Node.js warnings. - Fix: wrap async handlers with a utility that calls `.catch(next)`. - Decision rule: one `asyncHandler` wrapper covers every async route and middleware. ### Quick example ```js // ❌ BROKEN - rejection never reaches error handler app.get('/users', async (req, res) => { const users = await User.findAll(); // throws? App crashes. res.json(users); }); // ✅ CORRECT - rejection forwarded to error middleware const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); app.get('/users', asyncHandler(async (req, res) => { const users = await User.findAll(); res.json(users); })); // Error middleware now receives all async errors app.use((err, req, res, next) => { res.status(500).json({ error: err.message }); }); ``` The wrapper catches any rejection from the async function and calls `next(err)`. Express then routes it to the error middleware. Nothing else changes. ### Why Express can't catch async errors Express's middleware system is synchronous at its core. When you `await` inside a handler, Node.js returns a Promise. If that Promise rejects, the rejection enters the microtask queue. Express's synchronous try/catch has already exited by that point. It never sees the rejection. This isn't a bug. Express shipped in 2010, seven years before async/await. The architecture was correct for the time. ### The asyncHandler wrapper The pattern looks small but does real work: ```js const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; ``` `Promise.resolve()` wraps the handler's return value, even if it's not a Promise. This covers mixed sync/async code. `.catch(next)` attaches a rejection handler to the chain, so any rejection calls `next(err)`. Express then skips all regular middleware and goes directly to your 4-argument error handler. You can also use `try/catch` directly when you need to transform an error before forwarding it: ```js app.get('/users', async (req, res, next) => { try { const users = await User.findAll(); res.json(users); } catch (err) { next(new DatabaseError('Query failed', err)); // custom error shape } }); ``` Use the wrapper for the common case. Use `try/catch + next()` when you need to reshape the error. ### When to use each approach - **asyncHandler wrapper:** every async route handler and async middleware - **try/catch + next(err):** when you need to log, transform, or enrich the error before forwarding - **Neither:** synchronous handlers don't need special handling - **Avoid:** `.catch()` that swallows errors silently - it hides bugs ### Alternative: express-async-errors If you prefer not to wrap every handler manually, `express-async-errors` monkey-patches Express internally: ```js // npm install express-async-errors require('express-async-errors'); // import once at the top of app.js // Now works without a wrapper app.get('/users', async (req, res) => { const users = await User.findAll(); res.json(users); }); ``` Convenient for existing codebases. The tradeoff: it's a monkey-patch, which some teams avoid in production. Both approaches work. ### Express 5 (native async support) Express 5 handles async rejections natively. No wrapper, no patch: ```bash npm install express@next ``` ```js // Express 5 - async errors forwarded automatically app.get('/users', async (req, res) => { const users = await User.findAll(); res.json(users); }); ``` Express 5 is still in RC as of 2024, but stable enough for new projects. Existing projects on Express 4 should stay with the wrapper approach. ### Common mistakes **Mistake 1: Not wrapping async middleware** ```js // ❌ WRONG - middleware errors skip the error handler app.use(async (req, res, next) => { req.user = await User.findById(req.headers.authorization); next(); }); // ✅ CORRECT - middleware needs the wrapper too app.use(asyncHandler(async (req, res, next) => { req.user = await User.findById(req.headers.authorization); next(); })); ``` Developers often wrap route handlers but forget middleware. Same problem, same fix. **Mistake 2: Fire-and-forget Promises** ```js // ❌ WRONG - email rejection escapes asyncHandler app.get('/notify', asyncHandler(async (req, res) => { const user = await User.findById(req.params.id); sendEmail(user.email); // launched without await - rejection escapes res.json(user); })); // ✅ CORRECT - handle fire-and-forget explicitly app.get('/notify', asyncHandler(async (req, res) => { const user = await User.findById(req.params.id); sendEmail(user.email).catch(err => { logger.error('Email failed', err); // log, don't crash }); res.json(user); })); ``` The wrapper only catches Promises that are awaited or returned. A Promise you launch and ignore is outside its scope. **Mistake 3: Assuming a newer Node.js version fixes this** Even on Node 20 or 22, Express 4 doesn't catch async rejections. Node changed how it handles unhandled rejections (crash vs warning), but Express's middleware system didn't change. The wrapper is still needed. **Mistake 4: Calling next() after sending a response** ```js // ❌ WRONG - response already sent, next() has no effect app.get('/users', asyncHandler(async (req, res, next) => { const users = await User.findAll(); res.json(users); next(); // pointless here })); // ✅ CORRECT app.get('/users', asyncHandler(async (req, res) => { const users = await User.findAll(); res.json(users); })); ``` Once `res.json()` runs, the response is done. Don't call `next()` after it. ### Real-world usage - **Express.js:** every async handler and middleware needs the wrapper or try/catch - **Fastify:** similar issue, requires explicit error handling for async handlers - **Koa:** built around Promises from the start, catches async errors automatically - **NestJS:** uses exception filters (`@UseFilters()`) to handle async errors across controllers - **Next.js API routes:** catches Promise rejections automatically - **AWS Lambda:** async handlers must return Promises; unhandled rejections fail the invocation ### Follow-up questions **Q:** Why does `Promise.resolve(fn(req, res, next))` work differently than `fn(req, res, next).catch(next)`? **A:** `Promise.resolve()` ensures the result is always a Promise, even if `fn` returns a plain value or nothing. Without it, calling `.catch()` on a non-Promise return value would throw. Both work for async functions, but `Promise.resolve()` makes the wrapper safer for mixed sync/async code. **Q:** Can you use async/await in Express error middleware? **A:** Yes, but wrap it. The error middleware signature is `(err, req, res, next)` - four arguments. Express recognizes it by arity. Wrap it the same way: `app.use(asyncHandler((err, req, res, next) => { ... }))`. **Q:** If both middleware and a route handler have the wrapper, and middleware throws, which wrapper handles it? **A:** The middleware's wrapper catches it and calls `next(err)`. Express skips all remaining non-error middleware and route handlers and goes straight to error middleware. Only one wrapper handles any given error. They don't chain. **Q:** How do you test that errors are properly forwarded to error middleware? **A:** Mock the database to reject, then assert the error handler was called. With Jest: `jest.spyOn(User, 'findAll').mockRejectedValue(new Error('DB down'))`, then verify your error middleware received that error. Supertest works well for integration-level tests. **Q (senior):** What happens with `Promise.all()` if one Promise rejects inside an asyncHandler? **A:** `Promise.all()` rejects with the first error. That rejection bubbles up through the `await`, the async function rejects, and the wrapper's `.catch(next)` catches it. The other Promises keep running in the background but their results are ignored. If you need to cancel them, you need `AbortController`. The error handler receives exactly one error. ## Examples ### Basic: asyncHandler in a production controller ```js // utils/asyncHandler.js const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); module.exports = asyncHandler; // routes/users.js const asyncHandler = require('../utils/asyncHandler'); app.post('/users', asyncHandler(async (req, res) => { const user = await User.create(req.body); // DB error caught automatically if (!user.email) throw new Error('Email required'); // also caught res.status(201).json(user); })); // One error handler for all routes app.use((err, req, res, next) => { console.error(err.message); res.status(500).json({ error: err.message }); }); ``` Database errors and validation errors both reach the error middleware. You write error logic once, not in every handler. ### Intermediate: Parallel requests with timeout ```js const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); function withTimeout(promise, ms = 5000) { const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms) ); return Promise.race([promise, timeout]); } app.get('/dashboard', asyncHandler(async (req, res) => { // All three run in parallel, not one after another const [users, posts, stats] = await Promise.all([ withTimeout(User.findAll(), 3000), withTimeout(Post.findRecent(), 3000), withTimeout(Stats.summary(), 3000), ]); res.json({ users, posts, stats }); })); ``` Sequential `await` calls run one at a time. `Promise.all()` runs them together. On a dashboard with three data sources, that difference is noticeable. ### Advanced: Fire-and-forget with proper isolation ```js app.get('/orders/:id/confirm', asyncHandler(async (req, res) => { const order = await Order.findById(req.params.id); await order.confirm(); // this must succeed // Email is best-effort - failure shouldn't fail the whole request sendConfirmationEmail(order).catch(err => { logger.error('Confirmation email failed', { orderId: order.id, err }); // Don't rethrow - order is confirmed regardless of email }); res.json({ confirmed: true, orderId: order.id }); })); ``` The wrapper catches what the async function awaits or returns. A Promise you start without awaiting is outside its scope. Handle those explicitly. This is a pattern I've seen cause production incidents when teams assume the wrapper catches everything - it doesn't. It catches what the async function sees. Nothing more.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.