How to Debug Unhandled Promise Rejections in Node.js

Type: Software Reference Confidence: 0.93 Sources: 8 Verified: 2026-02-23 Freshness: quarterly

TL;DR

Constraints

Quick Reference

# Cause Likelihood Signature Fix
1 Missing .catch() on promise chain ~25% of cases UnhandledPromiseRejectionWarning with stack trace of .then() Add .catch() to every promise chain [src1, src2]
2 async function without try/catch ~22% of cases Crash at await line; no handler above Wrap await calls in try/catch blocks [src2, src3]
3 Floating promise (missing await) ~18% of cases Promise fires but rejection has no handler Add await or .catch(); enable no-floating-promises lint rule [src4]
4 Error thrown inside .then() callback ~10% of cases Stack trace points to .then() handler code Chain .catch() after the .then() [src2]
5 Promise.all() without error handling ~8% of cases One rejection crashes; other results lost Use Promise.allSettled() or wrap with try/catch [src6]
6 Express async route without error wrapper ~7% of cases Express hangs, no error response sent Use express-async-errors (Express 4) or upgrade to Express 5 [src7, src8]
7 Event handler with async callback ~4% of cases emitter.on('event', async fn) — rejection escapes Wrap async callback body in try/catch [src3, src5]
8 async in forEach/map without await ~3% of cases Iterations start but rejections aren't caught Use for...of loop with await or Promise.all(arr.map(...)) [src5]
9 Conditional .catch() (missing in some code paths) ~2% of cases Only fails in certain conditions Ensure .catch() on all branches [src3]
10 Rejected promise in module top-level ~1% of cases Crash during import/require initialization Wrap top-level async in IIFE with error handling [src2]

Decision Tree

START
├── Is the error "UnhandledPromiseRejectionWarning" or immediate crash?
│   ├── WARNING (Node 14 or earlier) → Add --unhandled-rejections=strict to catch early [src1]
│   └── CRASH (Node 15+) → Default behavior, read the stack trace ↓
├── Does the stack trace show a specific file and line?
│   ├── YES → Go to that line
│   │   ├── Is there an `await` without `try/catch`? → Add try/catch [src2, src3]
│   │   ├── Is there a `.then()` without `.catch()`? → Add .catch() [src2]
│   │   ├── Is there a function call returning a Promise without await? → Floating promise — add await [src4]
│   │   └── Is it inside a callback (event handler, setTimeout, forEach)? → Wrap in try/catch [src5]
│   └── NO (stack trace is unhelpful) ↓
├── Enable async stack traces: node --async-stack-traces app.js
│   └── Still unclear → Add process.on('unhandledRejection') with full logging [src1]
├── Is it in an Express route?
│   ├── YES, Express 4 → Use express-async-errors or wrap route handler [src7]
│   ├── YES, Express 5 → Async errors are caught automatically — check error middleware [src8]
│   └── NO ↓
├── Is it in a loop processing multiple items?
│   ├── YES → Using forEach? → Switch to for...of with await [src5]
│   │   └── Using Promise.all? → Switch to Promise.allSettled or add try/catch [src6]
│   └── NO ↓
└── Add global handler as safety net: process.on('unhandledRejection') [src1]

Step-by-Step Guide

1. Understand the Node.js version behavior

The way Node.js handles unhandled rejections changed significantly across versions. [src1]

Node.js 10-14: Warning only (DEP0018 deprecation warning), process continues
Node.js 15+:  Throws, process crashes (--unhandled-rejections=throw is default)
# Check your Node.js version
node -v

# Force strict mode in development (crash immediately)
node --unhandled-rejections=strict app.js

# Modes: throw (default 15+), warn (default <15), strict, none
node --unhandled-rejections=throw app.js

Verify: Run your app with --unhandled-rejections=strict — any unhandled rejections will crash immediately with a stack trace.

2. Add a global handler for diagnosis

While you find and fix the root cause, add a global handler to log full details. [src1]

// Add at the top of your entry file (app.js / index.js)
process.on('unhandledRejection', (reason, promise) => {
  console.error('UNHANDLED REJECTION at:', promise);
  console.error('Reason:', reason);
  console.error('Stack:', reason?.stack || 'No stack trace');

  // In production: log to error tracker, then exit
  // process.exit(1);
});

// Also catch uncaught exceptions (synchronous)
process.on('uncaughtException', (error) => {
  console.error('UNCAUGHT EXCEPTION:', error);
  process.exit(1);
});

Verify: Run your app and trigger the failing code path — you should see the full error details logged.

3. Fix missing try/catch in async functions

The most common pattern: async function without error handling. [src2, src3]

// ✅ CORRECT — error caught and handled
async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (error) {
    console.error(`Failed to fetch user ${userId}:`, error.message);
    throw error;
  }
}

Verify: The error is caught and logged, not crashing the process.

4. Find and fix floating promises

A "floating promise" is a promise that's not awaited or .catch()-ed. [src4]

// ❌ WRONG — floating promise
function handleRequest(req, res) {
  saveToDatabase(req.body);  // Returns promise, nobody handles it!
  res.json({ ok: true });
}

// ✅ CORRECT — await the promise
async function handleRequest(req, res) {
  try {
    await saveToDatabase(req.body);
    res.json({ ok: true });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
}

Verify: Enable @typescript-eslint/no-floating-promises ESLint rule to catch these at build time.

5. Fix Promise.all() error handling

Promise.all() rejects as soon as one promise rejects, losing other results. [src6]

// ✅ CORRECT — Promise.allSettled handles partial failures
const results = await Promise.allSettled([
  fetchUser(1), fetchUser(2), fetchUser(3),
]);

const successes = results.filter(r => r.status === 'fulfilled').map(r => r.value);
const failures = results.filter(r => r.status === 'rejected').map(r => r.reason);
if (failures.length) console.error(`${failures.length} fetches failed:`, failures);

Verify: All operations complete; failures are logged, successes are used.

6. Fix Express async routes

Express 4 doesn't natively catch async route handler rejections. Express 5 (released 2025-01-11) fixes this. [src7, src8]

// ✅ FIX 1 — Use express-async-errors (Express 4, simplest)
require('express-async-errors');

// ✅ FIX 2 — Manual wrapper (Express 4)
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await db.getUser(req.params.id);
  res.json(user);
}));

// ✅ FIX 3 — Express 5 (handles async natively, released 2025-01-11)
// No wrapper needed — just ensure error middleware has 4 params

// Error middleware (required — must have 4 params)
app.use((err, req, res, next) => {
  console.error('Error:', err.message);
  res.status(err.status || 500).json({ error: err.message });
});

Verify: Trigger an error in the route — Express responds with the error middleware, no crash.

7. Fix async in loops

Array.forEach doesn't await async callbacks — rejections escape silently. [src5]

// ❌ WRONG — forEach doesn't handle async rejections
items.forEach(async (item) => {
  await processItem(item);  // Rejection has no handler!
});

// ✅ CORRECT — for...of with await (sequential)
for (const item of items) {
  await processItem(item);
}

// ✅ CORRECT — Promise.all with map (parallel)
await Promise.all(items.map(item => processItem(item)));

Verify: All items are processed; errors are caught, not silently dropped.

Code Examples

Global error handler with graceful shutdown

// Input:  App crashes randomly from various unhandled rejections
// Output: Comprehensive global handler with logging and graceful shutdown

let isShuttingDown = false;

process.on('unhandledRejection', (reason, promise) => {
  console.error('=== UNHANDLED PROMISE REJECTION ===');
  console.error('Reason:', reason);
  console.error('Stack:', reason?.stack || 'No stack');
  // Sentry.captureException(reason);

  if (!isShuttingDown) {
    isShuttingDown = true;
    gracefulShutdown('unhandledRejection', 1);
  }
});

process.on('uncaughtException', (error, origin) => {
  console.error(`=== UNCAUGHT EXCEPTION (${origin}) ===`);
  console.error(error);
  if (!isShuttingDown) {
    isShuttingDown = true;
    gracefulShutdown('uncaughtException', 1);
  }
});

async function gracefulShutdown(signal, exitCode = 0) {
  console.log(`${signal} received — shutting down gracefully`);
  const timeout = setTimeout(() => process.exit(1), 10000);
  try {
    if (global.server) await new Promise(r => global.server.close(r));
    if (global.dbPool) await global.dbPool.end();
  } finally {
    clearTimeout(timeout);
    process.exit(exitCode);
  }
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

Safe async utility functions

// Input:  Need reusable patterns for safe async operations
// Output: Utility functions that prevent unhandled rejections

// Go-style error handling: [error, result] tuple
async function safely(promise) {
  try {
    const result = await promise;
    return [null, result];
  } catch (error) {
    return [error, null];
  }
}

// Usage
const [err, user] = await safely(db.getUser(123));
if (err) {
  console.error('Failed:', err.message);
  return res.status(500).json({ error: 'Database error' });
}
res.json(user);

// Batch operations with individual error handling
async function safelyAll(promises, options = {}) {
  const results = await Promise.allSettled(promises);
  return results.map((r, i) => {
    if (r.status === 'rejected') {
      if (options.onError) options.onError(r.reason, i);
      return { ok: false, error: r.reason };
    }
    return { ok: true, value: r.value };
  });
}

const results = await safelyAll(
  userIds.map(id => db.getUser(id)),
  { onError: (err, i) => console.error(`User ${userIds[i]} failed:`, err) }
);
const users = results.filter(r => r.ok).map(r => r.value);

Express app with comprehensive error handling

// Input:  Express app with async routes that crash on errors
// Output: Production-ready error handling setup

const express = require('express');
require('express-async-errors');

const app = express();
app.use(express.json());

// Routes — async errors auto-forwarded to error middleware
app.get('/users/:id', async (req, res) => {
  const user = await db.getUser(req.params.id);
  if (!user) {
    const err = new Error('User not found');
    err.status = 404;
    throw err;
  }
  res.json(user);
});

// 404 handler
app.use((req, res) => {
  res.status(404).json({ error: 'Not found' });
});

// Centralized error middleware (must have 4 params)
app.use((err, req, res, next) => {
  const status = err.status || 500;
  const message = status === 500 ? 'Internal server error' : err.message;
  console.error(`[${req.method} ${req.path}] ${status}:`, err.message);
  if (status === 500) console.error(err.stack);
  res.status(status).json({
    error: message,
    ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
  });
});

app.listen(3000);

Anti-Patterns

Wrong: Async function without try/catch

// ❌ BAD — rejection propagates as unhandled [src2, src3]
app.get('/data', async (req, res) => {
  const data = await fetchExternalAPI();  // If API is down → crash
  res.json(data);
});

Correct: Wrap in try/catch

// ✅ GOOD — error caught and handled [src2, src3]
app.get('/data', async (req, res, next) => {
  try {
    const data = await fetchExternalAPI();
    res.json(data);
  } catch (error) {
    next(error);
  }
});

Wrong: forEach with async callbacks

// ❌ BAD — forEach doesn't handle async rejections [src5]
async function processAll(items) {
  items.forEach(async (item) => {
    await sendEmail(item.email);  // If one fails → unhandled
  });
  console.log('Done');  // Logs immediately, before emails send!
}

Correct: for...of or Promise.all

// ✅ GOOD — proper sequential processing [src5]
async function processAll(items) {
  for (const item of items) {
    try {
      await sendEmail(item.email);
    } catch (err) {
      console.error(`Failed to email ${item.email}:`, err.message);
    }
  }
  console.log('Done');
}

Wrong: Fire-and-forget without .catch()

// ❌ BAD — floating promise, rejection unhandled [src4]
function logUserAction(userId, action) {
  db.insertLog({ userId, action, timestamp: new Date() });
  // Returns promise but nobody handles it!
}

Correct: Always handle the promise

// ✅ GOOD — explicit .catch() for fire-and-forget [src4]
function logUserAction(userId, action) {
  db.insertLog({ userId, action, timestamp: new Date() })
    .catch(err => console.error('Log insert failed:', err.message));
}

Common Pitfalls

Diagnostic Commands

# Run with strict mode — crash immediately on unhandled rejection
node --unhandled-rejections=strict app.js

# Run with async stack traces (better debugging)
node --async-stack-traces app.js

# Both flags together
node --unhandled-rejections=strict --async-stack-traces app.js

# Check which Node.js mode is default for your version
node -e "console.log(process.version, process.execArgv)"

# Find floating promises in TypeScript projects
npx eslint . --rule '{"@typescript-eslint/no-floating-promises": "error"}'

# Find forEach with async
grep -rn "forEach(async" --include="*.js" --include="*.ts" src/

# Find all .catch() patterns
grep -rn "\.catch(" --include="*.js" --include="*.ts" src/

# Check Express version (5+ handles async natively)
node -e "console.log(require('express/package.json').version)"

# Test global handler
node -e "
process.on('unhandledRejection', r => console.log('CAUGHT:', r));
Promise.reject(new Error('test'));
"

Version History & Compatibility

Version Behavior Key Changes
Node.js 25 (Current) Crash (default) Removes multipleResolves event; portable compile cache; Web Storage default [src1]
Node.js 24 Crash (default) V8 13.6; Error.isError() for cross-realm checks; using/await using for resource cleanup [src1, src2]
Node.js 23 Crash (default) 15-20% faster bootstrap; require(esm) unflagged [src1]
Node.js 22 LTS Crash (default) error.cause in promise rejections; improved diagnostics [src1, src2]
Node.js 20 LTS Crash (default) Promise.withResolvers() for cleaner promise patterns [src1]
Node.js 18 LTS (EOL) Crash (default) Built-in fetch() (experimental); async errors catchable [src1]
Node.js 16 (EOL) Crash (default) --unhandled-rejections=throw became default [src1]
Node.js 15 (EOL) Crash (default) First version to crash by default on unhandled rejections [src1]
Node.js 14 (EOL) Warning only UnhandledPromiseRejectionWarning; deprecation DEP0018 [src1]
Node.js 12 (EOL) Warning only --unhandled-rejections flag introduced [src1]

When to Use / When Not to Use

Use When Don't Use When Use Instead
Process crashes with "unhandled rejection" Error is a caught exception (sync throw) Standard try/catch debugging
UnhandledPromiseRejectionWarning in logs Error is ECONNREFUSED (connection issue) Check service connectivity first
Express route hangs without responding Error is in a callback-based API (not Promise) Check callback error parameter
Upgrading Node.js version causes new crashes Error is logged but handled correctly Verify handler is working as expected
Jest/Mocha shows warning but test passes Error is memory-related (heap OOM) Node.js memory leak debugging

Important Caveats

Related Units