Skip to content

Fix: Node.js Crashing with UnhandledPromiseRejection (--unhandled-rejections)

FixDevs ·

Quick Answer

How to fix Node.js UnhandledPromiseRejectionWarning and process crashes — why unhandled promise rejections crash Node.js 15+, how to add global handlers, find the source of the rejection, and fix async error handling.

The Error

Node.js exits with an unhandled promise rejection error:

node:internal/process/promises:289
            triggerUncaughtException(err, true /* fromPromise */);
            ^

[UnhandledPromiseRejection: This error originated either by throwing inside of an
async function without a catch block, or by rejecting a promise which was not
handled with .catch(). The promise rejected with the reason:
Error: connect ECONNREFUSED 127.0.0.1:5432]

Or in older Node.js versions — a warning instead of a crash:

(node:1234) UnhandledPromiseRejectionWarning: Error: connect ECONNREFUSED
(node:1234) UnhandledPromiseRejectionWarning: Unhandled promise rejection.
This error originated either by throwing inside of an async function without
a catch block, or by rejecting a promise which was not handled with .catch().

Why This Happens

In Node.js 15+, an unhandled promise rejection crashes the process with exit code 1. In older versions it was a warning. The rejection is “unhandled” when:

  • An async function throws but is not awaited — calling an async function without await means its rejection has no .catch() handler.
  • A .then() chain has no .catch() — any rejection in the chain propagates until it reaches a .catch(). Without one, it’s unhandled.
  • Promise.all() or Promise.allSettled() has a rejecting promise — if the Promise.all() call itself is not awaited or not followed by .catch(), the rejection is unhandled.
  • Event emitter callbacks that are asyncEventEmitter doesn’t understand promises. If an async listener throws, the rejection is unhandled.
  • Express route handlers not calling next(err) — unhandled async errors in Express 4 don’t automatically reach the error handler.

Fix 1: Add try/catch to Every async Function

// Wrong — async function called without await or .catch()
async function loadData() {
  const data = await db.query('SELECT * FROM users'); // Can throw
  return data;
}

// Called but not awaited — rejection is unhandled
loadData(); // ✗ Fire and forget — any error is swallowed or unhandled

// Correct — always await or handle the rejection
async function main() {
  try {
    const data = await loadData(); // ✓ Awaited — any rejection is caught here
    console.log(data);
  } catch (err) {
    console.error('Failed to load data:', err);
    process.exit(1); // Or handle gracefully
  }
}

main(); // Top-level call — still fire-and-forget but errors are caught inside

Wrap all top-level async code:

// Pattern for Node.js scripts
async function main() {
  // All your async code here — errors caught and handled
  const db = await connectToDatabase();
  await runMigrations(db);
  await startServer(db);
}

main().catch((err) => {
  console.error('Fatal error:', err);
  process.exit(1);
});

Fix 2: Fix Promise Chains Without .catch()

// Wrong — no .catch() on the chain
fetch('https://api.example.com/data')
  .then(res => res.json())
  .then(data => processData(data));
// If fetch fails or processData throws, rejection is unhandled

// Correct — add .catch()
fetch('https://api.example.com/data')
  .then(res => res.json())
  .then(data => processData(data))
  .catch(err => {
    console.error('Request failed:', err);
  });

// Or use async/await for cleaner error handling
async function fetchData() {
  try {
    const res = await fetch('https://api.example.com/data');
    const data = await res.json();
    return processData(data);
  } catch (err) {
    console.error('Request failed:', err);
    throw err; // Re-throw if caller needs to handle it
  }
}

Fix 3: Fix Async Event Listeners

EventEmitter does not understand promises. If an async listener rejects, the error is unhandled:

const EventEmitter = require('events');
const emitter = new EventEmitter();

// Wrong — async listener rejection is unhandled
emitter.on('data', async (payload) => {
  await processPayload(payload); // If this throws, nobody catches it
});

// Correct — wrap in try/catch
emitter.on('data', async (payload) => {
  try {
    await processPayload(payload);
  } catch (err) {
    console.error('Error processing payload:', err);
    emitter.emit('error', err); // Route to error handler
  }
});

// For EventEmitter errors — always add an error listener
emitter.on('error', (err) => {
  console.error('Emitter error:', err);
});

For streams and other Node.js built-in EventEmitters:

const { pipeline } = require('stream/promises');

// Use the promise-based pipeline to handle stream errors
async function processStream(readable, writable) {
  try {
    await pipeline(readable, transform, writable);
  } catch (err) {
    console.error('Stream pipeline failed:', err);
  }
}

Fix 4: Fix Express Async Route Handlers

Express 4 does not automatically catch async errors — you must either call next(err) or use a wrapper:

// Wrong — async error not passed to Express error handler
app.get('/users', async (req, res) => {
  const users = await db.getUsers(); // If this throws, Express doesn't catch it
  res.json(users);
});

// Fix A — wrap manually
app.get('/users', async (req, res, next) => {
  try {
    const users = await db.getUsers();
    res.json(users);
  } catch (err) {
    next(err); // Passes to Express error handling middleware
  }
});

// Fix B — use a wrapper function
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

app.get('/users', asyncHandler(async (req, res) => {
  const users = await db.getUsers();
  res.json(users);
}));

// Fix C — Express 5 (handles async errors automatically)
// npm install express@5
app.get('/users', async (req, res) => {
  const users = await db.getUsers(); // Express 5 catches this automatically
  res.json(users);
});

Express error handling middleware:

// Must be defined after all routes
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({
    error: process.env.NODE_ENV === 'production' ? 'Internal Server Error' : err.message,
  });
});

Fix 5: Add a Global Unhandled Rejection Handler

As a safety net — catch any rejections that slip through:

// Catch unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise);
  console.error('Reason:', reason);

  // In production — log to error tracking service
  // Sentry.captureException(reason);

  // Graceful shutdown
  process.exit(1); // Node.js 15+ exits anyway, but be explicit
});

// Catch synchronous uncaught exceptions
process.on('uncaughtException', (err, origin) => {
  console.error('Uncaught Exception:', err);
  console.error('Origin:', origin);

  // Always exit after uncaughtException — the process state is unreliable
  process.exit(1);
});

// Graceful shutdown on SIGTERM (e.g., from Docker or Kubernetes)
process.on('SIGTERM', async () => {
  console.log('SIGTERM received — shutting down gracefully');
  await server.close();
  await db.disconnect();
  process.exit(0);
});

Warning: Do not use unhandledRejection to swallow errors and continue running. After an unhandled rejection, the process may be in an inconsistent state. Log the error, then either exit or restart via a process manager (PM2, systemd).

Fix 6: Find the Source of the Unhandled Rejection

The error message often doesn’t show which line of your code caused the rejection. Use these techniques to find it:

Enable long stack traces:

# Node.js built-in — shows async stack traces
node --stack-trace-limit=50 server.js

# Or with the --enable-source-maps flag for TypeScript
node --enable-source-maps server.js

Use the --trace-warnings flag:

node --trace-warnings server.js
# Shows the full stack trace for every warning including UnhandledPromiseRejection

Intercept rejections before they become unhandled:

// Monkey-patch Promise to track unhandled rejections
const originalPromise = global.Promise;

global.Promise = class TrackedPromise extends originalPromise {
  constructor(executor) {
    super(executor);
    // Add a no-op .catch to prevent "unhandled" but log when it fires
    this.catch((err) => {
      console.trace('Promise rejected without handler:', err);
    });
  }
};

Or use the --unhandled-rejections flag to control behavior:

# Node.js 15+ options for --unhandled-rejections:
# 'throw' (default in Node 15+) — throws an exception, crashes the process
# 'warn' (Node 14 default) — logs a warning, process continues
# 'none' — silently ignores (never use in production)
# 'warn-with-error-code' — warns and sets exit code

node --unhandled-rejections=warn server.js  # Temporarily use Node 14 behavior

Fix 7: Fix Timer and setInterval Async Handlers

// Wrong — async callback in setInterval is not awaited
setInterval(async () => {
  await syncData(); // If this throws, the rejection is unhandled
}, 60_000);

// Correct — wrap in try/catch
setInterval(async () => {
  try {
    await syncData();
  } catch (err) {
    console.error('Sync failed:', err);
    // Do NOT rethrow — setInterval will continue on the next tick
  }
}, 60_000);

// Better — use a self-scheduling async function for control over timing
async function scheduledSync() {
  while (true) {
    try {
      await syncData();
    } catch (err) {
      console.error('Sync failed:', err);
    }
    await new Promise(resolve => setTimeout(resolve, 60_000));
  }
}

scheduledSync().catch(err => {
  console.error('Scheduler crashed:', err);
  process.exit(1);
});

Still Not Working?

Check third-party libraries. Some older libraries return unhandled promises internally. Check the library’s issue tracker or upgrade to a newer version.

Use Node.js --inspect with Chrome DevTools to pause on unhandled rejections:

node --inspect-brk server.js
# Open chrome://inspect in Chrome
# DevTools → Sources → Event Listener Breakpoints → Promise → Unhandled Rejection

Use why-is-node-running to find what’s keeping the process alive after an error:

npm install -g why-is-node-running
# Add to your entry point:
const why = require('why-is-node-running');
setTimeout(why, 5000); // After 5s, print what's keeping Node alive

For related Node.js and async issues, see Fix: JavaScript Unhandled Promise Rejection and Fix: Python asyncio Runtime Error No Running Event Loop.

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