Skip to content

Fix: Express Async Error Not Being Caught — Unhandled Promise Rejection

FixDevs ·

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 crashes

Or 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:

  • next called without the error — calling next() (no argument) in a catch block continues normal processing instead of error handling. Must be next(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 (missing return or await), 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 unhandledRejection usually 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles