Skip to content

Fix: Node.js UnhandledPromiseRejection and uncaughtException — Crashing Server

FixDevs ·

Quick Answer

How to handle Node.js uncaughtException and unhandledRejection events — graceful shutdown, error logging, async error boundaries, and keeping servers alive safely.

The Error

Node.js crashes with an unhandled exception:

/app/server.js:45
  throw new Error('Something went wrong');
        ^
Error: Something went wrong
    at processTicksAndMicrotasks (internal/process/task_queues.js:95:5)

node:internal/process/promises:288
            triggerUncaughtException(err, true /* fromPromise */);
            ^
[DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated.
In future versions of Node.js, promise rejections that are not handled will terminate
the Node.js process with a non-zero exit code.

Or in Node.js 15+, the process exits immediately:

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

Error [ERR_UNHANDLED_REJECTION]: Database connection failed
    at Timeout._onTimeout (/app/db.js:23:11)

Or an intermittent crash with no error message — the process just exits.

Why This Happens

Node.js has two process-level events for unhandled errors:

  • uncaughtException — a synchronous throw that wasn’t caught by any try/catch. In older code, this was common with callbacks.
  • unhandledRejection — a Promise rejection that had no .catch() handler and wasn’t awaited inside a try/catch. This became the dominant source of crashes when async/await was adopted.

The Node.js process exits on unhandledRejection since Node.js 15. In Node.js 14 and earlier, it printed a deprecation warning but continued running — this masked bugs that would have crashed newer versions.

Common causes:

  • Async function called without await — the returned Promise is discarded; any rejection becomes unhandled.
  • Promise chain missing .catch() — a .then() chain with no terminal .catch().
  • setTimeout or setInterval callback throwing — errors in timer callbacks are uncaught exceptions.
  • Event emitter throwing — errors emitted as 'error' events with no 'error' listener.
  • Database connection failures — async connection setup that fails after the app starts.

Fix 1: Handle unhandledRejection at the Process Level

Add a global handler to catch any promise rejection that escapes normal error handling:

// At the top of your entry point (index.js / server.js)
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Promise Rejection:');
  console.error('Reason:', reason);
  console.error('Promise:', promise);

  // Log to error monitoring (Sentry, Datadog, etc.)
  errorMonitoring.captureException(reason);

  // Graceful shutdown — the process is in an uncertain state
  // Don't catch and continue; exit cleanly
  server.close(() => {
    process.exit(1);
  });
});

process.on('uncaughtException', (error) => {
  console.error('Uncaught Exception:', error);
  errorMonitoring.captureException(error);

  // Always exit after uncaughtException
  // The process state is corrupted — don't try to continue
  server.close(() => {
    process.exit(1);
  });
});

Warning: Do NOT use unhandledRejection as a catch-all to swallow errors and keep running. The handler should log the error, notify monitoring, and initiate a graceful shutdown. An unhandled rejection means a bug exists — fix the root cause, don’t suppress it.

Fix 2: Always await Async Functions

The most common source of unhandled rejections — calling an async function without await:

// WRONG — rejection is unhandled (promise is discarded)
async function saveUser(data) {
  await db.insert('users', data);
}

// Called without await — the returned Promise is ignored
router.post('/users', (req, res) => {
  saveUser(req.body);  // ← Promise returned and discarded — if it rejects, crash
  res.json({ status: 'ok' });
});

// CORRECT — await the async function
router.post('/users', async (req, res, next) => {
  try {
    await saveUser(req.body);  // ← Awaited — rejections are caught
    res.json({ status: 'ok' });
  } catch (err) {
    next(err);  // Pass to Express error handler
  }
});

Promise.all without handling rejections:

// WRONG — if any promise rejects, the rejection is unhandled
Promise.all([
  fetchUser(id),
  fetchPermissions(id),
  fetchSettings(id),
]);

// CORRECT — handle the rejection
Promise.all([
  fetchUser(id),
  fetchPermissions(id),
  fetchSettings(id),
])
  .then(([user, permissions, settings]) => {
    // process results
  })
  .catch(err => {
    console.error('Failed to load user data:', err);
    // Handle the error
  });

// Or with async/await
async function loadUserData(id) {
  try {
    const [user, permissions, settings] = await Promise.all([
      fetchUser(id),
      fetchPermissions(id),
      fetchSettings(id),
    ]);
    return { user, permissions, settings };
  } catch (err) {
    throw new AppError('Failed to load user data', { cause: err });
  }
}

Fix 3: Handle Event Emitter Errors

Node.js’s EventEmitter has special handling for the 'error' event. If an 'error' event fires with no listener, Node.js throws an uncaught exception:

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

// WRONG — no 'error' listener
emitter.emit('error', new Error('something failed'));
// → Uncaught 'Error' [ERR_UNHANDLED_ERROR]: Unhandled error. (something failed)
//   Process crashes

// CORRECT — always add an 'error' listener to EventEmitters you use
emitter.on('error', (err) => {
  console.error('EventEmitter error:', err);
});

Streams (which extend EventEmitter):

const fs = require('fs');

// WRONG — no error handler on readable stream
const stream = fs.createReadStream('/nonexistent/file.txt');
stream.on('data', chunk => console.log(chunk));
// If file doesn't exist → uncaught error → crash

// CORRECT — always handle stream errors
const stream = fs.createReadStream('/path/to/file.txt');
stream.on('data', chunk => process.stdout.write(chunk));
stream.on('error', err => {
  console.error('Stream error:', err.message);
  // Handle appropriately
});
stream.on('end', () => console.log('Done'));

HTTP server and socket errors:

const http = require('http');

const server = http.createServer(app);

// Handle server-level errors (port already in use, etc.)
server.on('error', (err) => {
  if (err.code === 'EADDRINUSE') {
    console.error(`Port ${PORT} is already in use`);
    process.exit(1);
  }
  throw err;
});

// Handle errors on individual socket connections
server.on('clientError', (err, socket) => {
  socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});

Fix 4: Implement Graceful Shutdown

A process restart (after a crash) is unavoidable — implement graceful shutdown to minimize disruption:

const express = require('express');
const app = express();

const server = app.listen(3000);
let isShuttingDown = false;

function gracefulShutdown(signal) {
  console.log(`Received ${signal} — starting graceful shutdown`);
  isShuttingDown = true;

  // Stop accepting new connections
  server.close(async () => {
    console.log('HTTP server closed');

    try {
      // Close database connections
      await db.pool.end();
      console.log('Database connections closed');

      // Close other resources (Redis, message queues, etc.)
      await redis.quit();
      console.log('Redis connection closed');

      console.log('Graceful shutdown complete');
      process.exit(0);
    } catch (err) {
      console.error('Error during shutdown:', err);
      process.exit(1);
    }
  });

  // Force exit if graceful shutdown takes too long
  setTimeout(() => {
    console.error('Graceful shutdown timed out — forcing exit');
    process.exit(1);
  }, 30000);  // 30 second timeout
}

// Reject new requests during shutdown
app.use((req, res, next) => {
  if (isShuttingDown) {
    res.setHeader('Connection', 'close');
    return res.status(503).json({ error: 'Server is shutting down' });
  }
  next();
});

// Handle shutdown signals
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));  // Kubernetes, Docker stop
process.on('SIGINT', () => gracefulShutdown('SIGINT'));    // Ctrl+C

// Handle unhandled errors
process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', err);
  gracefulShutdown('uncaughtException');
});

process.on('unhandledRejection', (reason) => {
  console.error('Unhandled Rejection:', reason);
  gracefulShutdown('unhandledRejection');
});

Fix 5: Use a Process Manager to Auto-Restart

Since crashes can happen even with good error handling, run Node.js under a process manager that automatically restarts the process:

PM2:

npm install -g pm2

# Start with auto-restart
pm2 start server.js --name "api-server"
pm2 start server.js --name "api-server" --max-restarts 10 --restart-delay 5000

# Ecosystem file for production
// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'api-server',
    script: 'server.js',
    instances: 'max',        // Use all CPU cores
    exec_mode: 'cluster',
    max_restarts: 10,        // Max restarts in restart_delay period
    restart_delay: 5000,     // Wait 5s before restart
    max_memory_restart: '500M',  // Restart if memory exceeds 500MB
    env: {
      NODE_ENV: 'production',
      PORT: 3000,
    },
    error_file: '/var/log/pm2/api-error.log',
    out_file: '/var/log/pm2/api-out.log',
  }],
};

Docker with restart policy:

# docker-compose.yml
services:
  api:
    image: my-api:latest
    restart: unless-stopped   # Always restart except on explicit stop
    # Or for production:
    # restart: always

# Kubernetes handles restarts automatically via Pod restart policy

Fix 6: Use Domain for Legacy Code (Deprecated but Informational)

Node.js had domain for grouping async operations and handling their errors — it was deprecated and should not be used in new code. The modern equivalent is proper async/await error handling:

// LEGACY — don't use in new code
const domain = require('domain');
const d = domain.create();

d.on('error', (err) => {
  console.error('Domain caught:', err);
});

d.run(() => {
  // Async operations inside the domain have errors caught by d.on('error')
  setTimeout(() => {
    throw new Error('Caught by domain');
  }, 100);
});

// MODERN equivalent — proper try/catch with async/await
async function runOperation() {
  try {
    await asyncOperation();
  } catch (err) {
    console.error('Caught:', err);
  }
}

Fix 7: Structured Error Logging

Make unhandled errors visible and actionable with structured logging:

const { createLogger, format, transports } = require('winston');

const logger = createLogger({
  format: format.combine(
    format.timestamp(),
    format.errors({ stack: true }),
    format.json(),
  ),
  transports: [
    new transports.Console(),
    new transports.File({ filename: 'error.log', level: 'error' }),
  ],
});

process.on('unhandledRejection', (reason, promise) => {
  logger.error('Unhandled Promise Rejection', {
    reason: reason instanceof Error ? {
      message: reason.message,
      stack: reason.stack,
      name: reason.name,
    } : reason,
    timestamp: new Date().toISOString(),
  });

  // Initiate graceful shutdown
  gracefulShutdown('unhandledRejection');
});

process.on('uncaughtException', (error) => {
  logger.error('Uncaught Exception', {
    message: error.message,
    stack: error.stack,
    name: error.name,
  });

  gracefulShutdown('uncaughtException');
});

Error monitoring with Sentry:

const Sentry = require('@sentry/node');

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
});

// Sentry automatically captures unhandledRejection and uncaughtException
// when initialized — no manual .on() needed for basic capture

// For custom context:
process.on('unhandledRejection', (reason) => {
  Sentry.captureException(reason);
  gracefulShutdown('unhandledRejection');
});

Still Not Working?

Find the source of the rejection — add a --trace-warnings flag to see where the unhandled rejection originated:

node --trace-warnings --trace-uncaught server.js
# Shows a full stack trace including where the Promise was created

Async event handlers — event handlers that are async but not awaited cause silent unhandled rejections:

// WRONG — EventEmitter doesn't await async listeners
emitter.on('data', async (event) => {
  await processEvent(event);  // If this throws, rejection is unhandled
});

// CORRECT — catch errors inside the async listener
emitter.on('data', async (event) => {
  try {
    await processEvent(event);
  } catch (err) {
    emitter.emit('error', err);  // Re-emit as error event
  }
});

Promise.allSettled instead of Promise.all for non-critical operations:

// Promise.all rejects on first failure — other promises continue untracked
// Promise.allSettled waits for all, never rejects, reports each result
const results = await Promise.allSettled([
  sendWelcomeEmail(user),
  updateAnalytics(user),
  notifyAdmin(user),
]);

for (const result of results) {
  if (result.status === 'rejected') {
    console.error('Non-critical operation failed:', result.reason);
  }
}

For related Node.js issues, see Fix: Express Async Error Not Being Caught and Fix: Node.js Heap Out of Memory.

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