Fix: Express Async Error Not Being Caught — Unhandled Promise Rejection
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 before async/await existed. Its error handling relies on calling next(err) — but async functions that throw bypass this mechanism:
// Express catches synchronous errors automatically
app.get('/sync', (req, res, next) => {
throw new Error('sync error'); // Express catches this, calls error middleware
});
// Express does NOT catch async errors automatically
app.get('/async', async (req, res, next) => {
throw new Error('async error'); // NOT caught — unhandled rejection
// Error middleware is never called
});Internally, Express wraps route handlers in a try/catch for synchronous errors, but this doesn’t work for Promises. An async function returns a Promise — if that Promise rejects after the synchronous try/catch exits, Express never sees the error.
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.
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 (currently in beta, approaching stable) 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!
});
}));For related Node.js issues, see Fix: Node.js Heap Out of Memory and Fix: Axios Network Error.
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.