Introduction

Express.js does not automatically catch errors thrown in async route handlers. When an async function rejects its promise and the rejection is not caught with try/catch, Node.js emits an unhandledRejection event. Since Node.js 15, unhandled promise rejections crash the process by default. This means a single database query failure or external API error in an Express route can bring down the entire server, dropping all active connections. The fix requires either wrapping every async handler in a try/catch or using a library that patches Express to catch async errors automatically.

Symptoms

Server crashes on route error:

(node:12345) UnhandledPromiseRejectionWarning: Error: connect ECONNREFUSED 127.0.0.1:5432 at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1146:16) (Use node --trace-unhandled ...` to show where the warning was thrown)

node:internal/process/promises:288 triggerUncaughtException(err, true /* fromPromise */); ^ Error: connect ECONNREFUSED 127.0.0.1:5432 at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1146:16)

Node.js v18.17.0 Process exited with code 1 ```

Or the route hangs forever (no response sent):

bash
$ curl http://localhost:3000/api/users
# Connection hangs - no response, no error

Common Causes

  • Async route handler throws without try/catch: Express does not wrap async handlers
  • Await on a rejecting promise: await db.query() fails and the error is not caught
  • Error thrown after res.writeHead(): Partial response already sent
  • Middleware does not call next(err): Synchronous errors not passed to error middleware
  • Unhandled rejection in event emitter: Event listeners that reject promises
  • Missing error handling middleware: No app.use((err, req, res, next) => ...) at the end of the middleware chain

Step-by-Step Fix

Step 1: Use express-async-errors to auto-catch

bash
npm install express-async-errors

```javascript const express = require('express'); require('express-async-errors'); // MUST be imported before routes

const app = express();

// Now async errors are automatically caught and passed to error middleware app.get('/api/users/:id', async (req, res) => { const user = await db.query('SELECT * FROM users WHERE id = $1', [req.params.id]); // If this throws, Express catches it and calls error middleware res.json(user.rows[0]); });

// Error handling middleware - must be last app.use((err, req, res, next) => { console.error('Unhandled error:', err.message); res.status(500).json({ error: 'Internal server error' }); }); ```

Step 2: Wrap async handlers manually

```javascript const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };

// Usage app.get('/api/users/:id', asyncHandler(async (req, res) => { const user = await db.query('SELECT * FROM users WHERE id = $1', [req.params.id]); if (!user.rows[0]) { const error = new Error('User not found'); error.statusCode = 404; throw error; } res.json(user.rows[0]); })); ```

Step 3: Add global unhandled rejection handler

```javascript // At the very top of your application entry point process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection at:', promise, 'reason:', reason); // In production, do NOT exit - log and continue // In development, you may want to crash for visibility if (process.env.NODE_ENV === 'production') { // Send to error tracking service sentry.captureException(reason); } });

process.on('uncaughtException', (error) => { console.error('Uncaught Exception:', error.message); // Graceful shutdown process.exit(1); }); ```

Prevention

  • Always use express-async-errors or an async handler wrapper
  • Place error handling middleware as the LAST middleware in the chain
  • Add process.on('unhandledRejection') handler as a safety net
  • Use next(error) pattern for synchronous errors in middleware
  • Test error paths by deliberately throwing in route handlers
  • Configure APM tools to track unhandled rejection rates
  • Never silently swallow errors in catch blocks -- always log or rethrow