Skip to content

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

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

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.

The deeper reason is that Promise is not exception-aware in the synchronous sense. A throw inside an async function becomes Promise.reject(error). If nobody attaches a .catch or awaits the promise before the next microtask, the V8 engine has no idea anyone wanted to know about the error. The runtime fires the unhandledRejection event one microtask later, by which time the calling code has long since moved on. This is why the stack trace usually does not include the line that “forgot” to handle the error — the call stack at the point of detection is empty.

The third subtlety is the difference between a rejection that is detected late and one that never gets handled. If you write const p = doSomething(); and later, after a setTimeout(..., 100), you write p.catch(handle), Node may still report an unhandled rejection for those 100 ms before the handler attaches. This is the source of the “phantom” rejections that appear in well-tested code. They are not bugs in V8 — they are real handler-attachment races. Starting in Node 16, the unhandledRejection and rejectionHandled events fire in a pair, and you can listen for rejectionHandled to clear false alarms.

Version History That Changes the Failure Mode

The default behavior of unhandled rejections has changed five times in Node.js history. The exact version you run determines whether the process keeps going, logs a warning, or hard-crashes.

Node.js 6 (April 2016). The unhandledRejection event first appeared. By default, nothing happened. Your code crashed silently or appeared to hang.

Node.js 10 (April 2018). The --unhandled-rejections flag was added with values none and warn. The default remained “warn and continue.”

Node.js 12 (April 2019). Added --unhandled-rejections=strict, which crashed the process. Opt-in, not default.

Node.js 14 (April 2020). Deprecation warning added: “Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.” This is the warning many production applications were ignoring.

Node.js 15 (October 2020) — the breaking change. The default flipped to --unhandled-rejections=throw. Code that ran for years on Node 14 started exit-coding-1 on Node 15 the same day. This is the single most-cited reason developers post about this error today. The change propagated forward to Node 16, 18, 20, and 22.

Node.js 16 LTS (April 2021). Inherited the Node 15 default. Added the paired rejectionHandled event, which lets you cancel a false “unhandled” detection if you attach a .catch slightly late.

Node.js 18 LTS (April 2022). Native fetch() arrived. Note that fetch itself returns a promise, and forgetting to .catch it on Node 18 follows the same crash semantics as any other promise. Many “Node 18 broke our app” reports trace to new fetch calls without error handling.

Node.js 20 LTS (April 2023). Default behavior unchanged. The --unhandled-rejections=warn escape hatch still works but is officially discouraged. Node 20 also added stable Worker thread support, where unhandled rejections inside a worker terminate the worker but not the parent — unless the parent itself does not handle the worker’s error event, in which case the parent crashes too.

Node.js 22 LTS (April 2024). Added improved diagnostics — --report-uncaught-exception now also fires for unhandled rejections, generating a JSON crash dump alongside the stderr trace.

The escape hatch. Across Node 15+, you can still set node --unhandled-rejections=warn server.js to get Node 14 behavior. This is a useful unblock during an upgrade but is genuinely a temporary measure. Worker threads and built-in modules increasingly assume rejections crash, and the warn-only mode introduces latent inconsistencies between main and worker code.

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

Check Worker thread error handling. On Node 16+, unhandled rejections inside a worker_threads.Worker only terminate the worker — not the parent. But if the parent does not subscribe to the worker’s 'error' event, the error bubbles to the parent’s unhandledRejection handler and crashes the whole process. Add worker.on('error', err => { ... }) on every worker you spawn, even if you do not plan to handle the error in a meaningful way. The act of subscribing prevents accidental parent-crash escalation.

Listen for rejectionHandled to filter race-condition false positives. If you attach a .catch shortly after a promise rejects, Node may fire unhandledRejection before your handler attaches and rejectionHandled after. Tracking both lets your monitoring distinguish real bugs from microtask races:

const pending = new Map();
process.on('unhandledRejection', (reason, promise) => {
  pending.set(promise, { reason, time: Date.now() });
});
process.on('rejectionHandled', (promise) => {
  pending.delete(promise);
});
setInterval(() => {
  for (const [promise, info] of pending) {
    if (Date.now() - info.time > 5000) {
      // Genuinely unhandled for 5+ seconds — log to Sentry, then drop
      pending.delete(promise);
    }
  }
}, 5000);

Use --unhandled-rejections=warn only as a temporary unblock. If you have just upgraded from Node 14 to Node 18/20 and discovered a swarm of rejections, set node --unhandled-rejections=warn in your start command, fix the rejections one by one, and remove the flag. Leaving the flag in place permanently is technical debt that will eventually catch you when a Worker thread or native module assumes the default crash behavior.

For related Node.js and async issues, see Fix: JavaScript Unhandled Promise Rejection, Fix: Python asyncio Runtime Error No Running Event Loop, Fix: Node Uncaught Exception, and Fix: Express Async 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