Fix: Express Async Error Not Being Caught — Unhandled Promise Rejection
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Express async route handlers not passing errors to the error middleware — wrapping async routes, using express-async-errors, global error handlers, and Node.js unhandledRejection events.
The Error
An Express route uses async/await but errors crash the server instead of returning a proper error response:
UnhandledPromiseRejectionWarning: Error: ENOENT: no such file or directory
at processTicksAndMicrotasks (internal/process/task_queues.js:93:5)
# Server keeps running but the request hangs or crashesOr the error middleware is never called despite next(err) being in the code:
app.get('/users/:id', async (req, res, next) => {
const user = await User.findById(req.params.id); // Throws if not found
res.json(user);
// If findById throws, next() is never called — error middleware is bypassed
});Or in Node.js 15+:
node:internal/process/promises:246
triggerUncaughtException(err, true /* fromPromise */);
^
Error [ERR_UNHANDLED_REJECTION]: ...Why This Happens
Express was designed in 2010, years before async/await existed in JavaScript. Its error-handling pipeline relies on the callback pattern: synchronous errors thrown inside a route handler are caught by an internal try/catch, and errors passed via next(err) route to the error middleware. But async functions return Promises, and Express 4 does not attach a .catch() to the returned Promise. When the Promise rejects, Express never sees it.
Here’s the exact failure sequence. Express wraps each route handler in a try/catch. Your async handler starts executing, hits the first await, and returns a pending Promise. The synchronous try/catch exits successfully because no synchronous error was thrown. Later, the awaited operation rejects. The rejection propagates up through the Promise chain, but Express already finished its try/catch. Nobody catches the rejection, and Node.js emits an unhandledRejection event. In Node.js 15+, that crashes the process. In earlier versions, it logs a deprecation warning and the request hangs forever because no response is sent.
This is not a bug in Express — it’s a design limitation that predates modern JavaScript. The Express team addressed it in Express 5, which attaches .catch() to returned Promises automatically. But Express 4 remains the most widely deployed version, and understanding the underlying cause prevents hours of debugging in any callback-based framework.
Additional causes:
nextcalled without the error — callingnext()(no argument) in a catch block continues normal processing instead of error handling. Must benext(err).- No global error middleware — Express requires a 4-argument error handler
(err, req, res, next)to catch errors. Without it, errors are logged but no response is sent. - Error middleware registered before routes — Express matches middleware in order. Error handlers must be registered after all routes.
res.json()called after an async error — if code continues after an async operation throws (missingreturnorawait), Express may try to send two responses.
How Other Frameworks Handle Async Errors
Express 4’s async gap is well-known, and every modern Node.js framework has solved it differently. Comparing approaches shows what Express is missing and what to expect if you migrate.
Express 5 (release candidate as of 2025) adds native async support. If a route handler returns a Promise that rejects, Express 5 calls next(err) automatically. The upgrade path is straightforward — replace express@4 with express@5, and async errors flow to your error middleware without any wrapper. The API is backward-compatible for most use cases, but some deprecated methods (app.del(), req.param()) are removed.
Fastify was built with async support from the start. Route handlers can be async functions that throw or return values directly. Fastify calls reply.send(returnValue) automatically, and thrown errors route to the error handler. Fastify also provides JSON schema validation on request/response, automatic serialization, and a plugin system with encapsulated contexts. Its error-handling model eliminates the need for any wrapper function.
Koa (by the original Express creators) uses async middleware natively. Every middleware is an async function, and Koa’s core catches rejected Promises at the top of the middleware chain. Calling ctx.throw(404, 'Not found') sends an error response. Koa’s app.on('error', handler) catches unhandled errors globally. The trade-off is that Koa is minimal — it has no built-in router, body parser, or static file serving.
Hono is a lightweight framework designed for edge runtimes (Cloudflare Workers, Deno, Bun, Node.js). All handlers are async by default. Hono catches rejected Promises and routes them to app.onError(). Because Hono targets edge environments where unhandled rejections crash the isolate immediately, async safety is non-negotiable.
The express-async-errors package solves the problem for Express 4 without changing your code. It monkey-patches Express’s Layer.handle to attach .catch(next) to any returned Promise. Import it once at the top of your app, and all async handlers forward errors to the error middleware automatically. This is the lowest-effort fix for existing Express 4 codebases.
Fix 1: Wrap Async Handlers to Catch Errors
The classic fix — wrap every async route handler in a function that catches rejected promises and passes them to next:
// Helper wrapper
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// BEFORE — async errors not caught
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user);
});
// AFTER — errors caught and passed to error middleware
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user);
}));TypeScript version:
import { Request, Response, NextFunction, RequestHandler } from 'express';
type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<void>;
const asyncHandler = (fn: AsyncHandler): RequestHandler =>
(req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Usage
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user);
}));Fix 2: Use express-async-errors (Automatic Patching)
The express-async-errors package patches Express to automatically handle async errors without wrapping every handler:
npm install express-async-errors// Import ONCE at the top of your app entry point — before express routes
require('express-async-errors');
// or: import 'express-async-errors';
const express = require('express');
const app = express();
// Now async routes automatically forward errors to next()
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id); // Throws — caught automatically
res.json(user);
});
// Error middleware still needed to handle the errors
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});This is the simplest solution — one import fixes all async routes in the application.
Fix 3: Use Express 5 (Async Support Built In)
Express 5 natively handles async route handlers:
npm install express@next// Express 5 — async errors automatically call next(err)
const express = require('express');
const app = express();
// No wrapper needed in Express 5
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id); // Throws — next(err) called automatically
res.json(user);
});
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});Note: Express 5 is currently in release candidate stage. Check the release status before using in production. The API is backward-compatible with Express 4 for most use cases.
Fix 4: Write a Global Error Handler
All the above fixes require a proper error handler middleware. Without it, errors are logged but no response is sent to the client (the request hangs):
// WRONG — no error handler, errors cause the request to hang
const app = express();
app.get('/data', async (req, res) => {
const data = await fetchData();
res.json(data);
});
app.listen(3000);
// If fetchData() throws, no response is sent — client times out// CORRECT — error handler after all routes
const app = express();
app.get('/data', asyncHandler(async (req, res) => {
const data = await fetchData();
res.json(data);
}));
// 404 handler — for routes that don't exist
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// Error handler — MUST have 4 arguments (err, req, res, next)
// Register LAST, after all routes and other middleware
app.use((err, req, res, next) => {
console.error(err.stack);
// Determine status code
const status = err.status || err.statusCode || 500;
// Don't expose internal errors in production
const message = process.env.NODE_ENV === 'production' && status === 500
? 'Internal server error'
: err.message;
res.status(status).json({ error: message });
});Structured error handler with custom error classes:
class AppError extends Error {
constructor(message, statusCode = 500) {
super(message);
this.statusCode = statusCode;
this.name = 'AppError';
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
this.name = 'NotFoundError';
}
}
class ValidationError extends AppError {
constructor(message) {
super(message, 400);
this.name = 'ValidationError';
}
}
// In routes — throw meaningful errors
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(user);
}));
// Error handler — handle each error type
app.use((err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({ error: err.message });
}
// ORM-specific errors
if (err.name === 'CastError') {
return res.status(400).json({ error: 'Invalid ID format' });
}
if (err.code === 11000) {
return res.status(409).json({ error: 'Duplicate entry' });
}
// Unhandled error — log and return 500
console.error('[Unhandled Error]', err);
res.status(500).json({ error: 'Internal server error' });
});Fix 5: Handle Unhandled Rejections at the Process Level
For any promise rejection that escapes route handlers (from queued callbacks, event handlers, or third-party code):
// Catch unhandled promise rejections — prevents server crash in Node.js < 15
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Promise Rejection:', reason);
// Optional: graceful shutdown
// server.close(() => process.exit(1));
});
// Catch uncaught synchronous exceptions
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
// Graceful shutdown — the process is in an undefined state
server.close(() => process.exit(1));
});Graceful shutdown on fatal errors:
const server = app.listen(3000);
process.on('unhandledRejection', (err) => {
console.error('Unhandled rejection:', err);
server.close(() => {
console.log('Server shut down due to unhandled rejection');
process.exit(1);
});
});Note: An
unhandledRejectionusually indicates a bug — something threw that you didn’t expect. In Node.js 15+, unhandled rejections crash the process by default. Fix the root cause rather than relying on the process-level handler for normal error flow.
Fix 6: Handle Errors in Middleware Chains
If you use multiple middleware functions, errors must be passed through the chain:
// Middleware that validates the request
const validateUser = asyncHandler(async (req, res, next) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User');
req.user = user; // Attach to request for next middleware
next(); // Pass to route handler
});
// Route handler
const getUserProfile = asyncHandler(async (req, res) => {
const profile = await Profile.findByUserId(req.user.id);
res.json({ user: req.user, profile });
});
// Route with multiple middleware
app.get('/users/:id/profile', validateUser, getUserProfile);Avoid next() after sending a response:
// WRONG — calling next() after res.json() causes "headers already sent" error
app.get('/data', asyncHandler(async (req, res, next) => {
const data = await fetchData();
res.json(data);
next(); // Error: can't call next() after response is sent
}));
// CORRECT — return after sending response
app.get('/data', asyncHandler(async (req, res) => {
const data = await fetchData();
return res.json(data); // return prevents further execution
}));Fix 7: TypeScript with Express Error Handling
TypeScript makes error handling patterns more robust with typed errors:
import express, { Request, Response, NextFunction } from 'express';
// Typed error class
class HttpError extends Error {
constructor(
public readonly statusCode: number,
message: string,
) {
super(message);
this.name = 'HttpError';
}
}
// Typed async wrapper
const asyncRoute = (
fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
) => (req: Request, res: Response, next: NextFunction): void => {
fn(req, res, next).catch(next);
};
// Typed error handler
const errorHandler = (
err: Error,
req: Request,
res: Response,
_next: NextFunction, // Must accept 4 args to be recognized as error handler
): void => {
if (err instanceof HttpError) {
res.status(err.statusCode).json({ error: err.message });
return;
}
console.error(err);
res.status(500).json({ error: 'Internal server error' });
};
const app = express();
app.get('/users/:id', asyncRoute(async (req, res) => {
const user = await User.findById(Number(req.params.id));
if (!user) throw new HttpError(404, 'User not found');
res.json(user);
}));
// Register error handler last
app.use(errorHandler);With NestJS (which handles async errors automatically):
// NestJS handles async errors natively — no wrapping needed
@Controller('users')
export class UsersController {
@Get(':id')
async getUser(@Param('id') id: string) {
const user = await this.usersService.findOne(+id);
if (!user) throw new NotFoundException('User not found');
return user;
}
// Exceptions are caught by NestJS's built-in exception filter
}Still Not Working?
Confirm the error handler has exactly 4 arguments. Express identifies error handlers by the number of arguments. An error handler with 3 arguments (req, res, next) is treated as a regular middleware:
// WRONG — Express doesn't recognize this as error handler (3 args)
app.use((req, res, next) => {
res.status(500).json({ error: 'Error' });
});
// CORRECT — exactly 4 args, first is err
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});Check the middleware registration order. Error handlers must be registered after all routes:
// Route registrations
app.use('/api/users', usersRouter);
app.use('/api/posts', postsRouter);
// 404 handler (after routes)
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
// Error handler LAST
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});Verify next is not shadowed. In nested functions, a different next variable may shadow the route’s next:
app.get('/data', asyncHandler(async (req, res, next) => {
someArray.forEach((item, index, array, next) => {
// ^^^^ shadows route's next!
});
}));Check for double response sends. If an async operation throws after you’ve already called res.json(), the error handler tries to send a second response. Guard against this with res.headersSent:
app.use((err, req, res, next) => {
if (res.headersSent) {
return next(err); // Delegate to Express's default error handler
}
res.status(500).json({ error: err.message });
});Verify that express-async-errors is imported before routes. The import must appear before any app.get(), app.post(), or app.use() calls that register route handlers. If it’s imported after routes are registered, those routes are not patched.
Test with a minimal reproduction. If errors work in a fresh Express app but not in your project, a middleware higher in the chain may be swallowing errors. Add console.log('error handler hit', err.message) at the top of your error handler to confirm it’s being reached. If it’s not, check for try/catch blocks in preceding middleware that catch errors without calling next(err).
For related Node.js issues, see Fix: Express Middleware Not Working, Fix: Node.js Unhandled Rejection Crash, Fix: Express req.body Undefined, and Fix: Fastify Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: Node.js UnhandledPromiseRejection and uncaughtException — Crashing Server
How to handle Node.js uncaughtException and unhandledRejection events — graceful shutdown, error logging, async error boundaries, and keeping servers alive safely.
Fix: pdf-lib Not Working — PDF Not Generating, Fonts Not Embedding, or Pages Blank
How to fix pdf-lib issues — creating PDFs from scratch, modifying existing PDFs, embedding fonts and images, form filling, merging documents, and browser and Node.js usage.
Fix: Pusher Not Working — Events Not Received, Channel Auth Failing, or Connection Dropping
How to fix Pusher real-time issues — client and server setup, channel types, presence channels, authentication endpoints, event binding, connection management, and React integration.