Skip to main content

How to handle async/await in Express.js route handlers?

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.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?