Handling Uncaught Exceptions and Promise Rejections in Node.js

In Node.js applications, failing to handle asynchronous errors and unexpected runtime exceptions can lead to silent failures, memory leaks, or abrupt application crashes. This article provides a straightforward guide on how to detect, log, and gracefully handle unhandled promise rejections and uncaught exceptions in Node.js, ensuring your application remains stable and resilient.

Uncaught Exceptions

An uncaught exception occurs when a synchronous error is thrown during the execution of your code but is not caught by any try...catch block.

To intercept these errors globally, you listen to the uncaughtException event on the global process object.

process.on('uncaughtException', (error) => {
  console.error('There was an uncaught error', error);
  // Perform cleanup operations here (e.g., closing database connections)
  process.exit(1); // Mandatory retry/restart
});

Crucial Best Practice: When an uncaught exception occurs, the application is in an undefined state. You must log the error, perform any necessary cleanup, and exit the process using process.exit(1). Use a process manager like PM2, Docker, or Kubernetes to automatically restart the application container.

Unhandled Promise Rejections

An unhandled promise rejection occurs when an asynchronous operation (represented by a Promise) fails, but there is no .catch() block or try...catch block around the await statement to handle the rejection.

To intercept these rejections globally, listen to the unhandledRejection event on the process object.

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // Application specific logging, throwing an error, or cleanup here
});

Unlike uncaught exceptions, you do not always need to crash the process immediately on an unhandled rejection, although modern versions of Node.js (v15+) will terminate the process by default with a non-zero exit code if these are not handled.

To handle both scenarios effectively in a production environment, implement a centralized error-handling script that initializes at the very start of your application entry point (e.g., index.js or server.js).

const logger = require('./logger'); // Your logging library

// Handle synchronous uncaught exceptions
process.on('uncaughtException', (error) => {
  logger.error(`Uncaught Exception: ${error.message}`, { stack: error.stack });
  
  // Graceful shutdown
  gracefulShutdown(1);
});

// Handle asynchronous unhandled rejections
process.on('unhandledRejection', (reason, promise) => {
  logger.error('Unhandled Rejection at:', { promise, reason });
  
  // Recommended: Treat unhandled rejections as uncaught exceptions
  gracefulShutdown(1);
});

function gracefulShutdown(code) {
  // 1. Close active servers (e.g., Express app)
  // 2. Close database connections (e.g., MongoDB, PostgreSQL)
  // 3. Exit process
  setTimeout(() => {
    process.exit(code);
  }, 1000).unref(); // Force exit if cleanup takes too long
}