Back to blog

How to Fix Unhandled Promise Rejection Errors in Node.js

Asynchronous programming is central to modern JavaScript. Promises and async/await syntax have made managing asynchronous tasks clean. However, they introduce a major operational risk: Unhandled Promise Rejections.

In early Node.js versions, failing to catch a rejected promise merely printed a warning to the console. In modern Node.js runtimes (v15+), an unhandled rejection is treated as a critical crash event, causing your server process to terminate immediately with a non-zero exit code.

In this guide, we will look at why unhandled rejections happen, learn how to catch them locally, and configure global event listeners to prevent server crashes.

Why Unhandled Rejections Occur

A Promise represents the eventual completion (or failure) of an asynchronous operation. A Promise can exist in three states: pending, fulfilled, or rejected.

If an asynchronous operation fails (e.g., a database connection drops, or an API returns a 500 error), the Promise enters the rejected state. If your code does not register a handler to catch this rejection, the runtime flags it as unhandled.

Consider this insecure asynchronous function:

async function getUserData(userId: string) {
  // If the database fails, this promise rejects
  const data = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  return data;
}

// Calling the function without a catch block
getUserData('invalid-id');

If the database query fails, the call throws an exception. Because there is no error handler wrapping getUserData, the Node.js runtime intercepts the unhandled rejection and exits the process.

Solution 1: Catching Locally with Try-Catch

When using async/await, the best practice is wrapping all asynchronous operations in try-catch blocks. This allows you to handle the error close to where it occurred, log the context, and return a fallback value.

async function fetchUserProfile(userId: string) {
  try {
    const data = await getUserData(userId);
    return { success: true, user: data };
  } catch (error) {
    console.error(`[ERROR] Failed to fetch profile for user ${userId}:`, error);
    return { success: false, error: 'User profile unavailable' };
  }
}

Solution 2: Catching with .catch()

If you are using promise chain syntax instead of async/await, always append a .catch() block to the end of the promise chain:

getUserData('invalid-id')
  .then((data) => {
    console.log('User data loaded:', data);
  })
  .catch((error) => {
    console.error('Promise rejected:', error);
  });

Solution 3: Registering Global Rejection Listeners

Even with strict coding standards, developers can occasionally forget to catch a promise. To prevent your production servers from crashing during unexpected database drops, you must configure global safety nets.

Global Listener in Node.js

Place this listener at the very entry point of your Node.js application (e.g., server.ts or index.js):

// Catch all unhandled promise rejections globally
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  
  // Perform emergency cleanup or alert monitoring services here
  // Depending on the severity, you may still want to gracefully exit
});

Global Listener in the Browser

Browsers also support a global event handler to intercept unhandled promise errors:

window.addEventListener('unhandledrejection', (event) => {
  console.error(`Unhandled rejection: ${event.reason}`);
  
  // Prevent the default console error print (optional)
  event.preventDefault();
});

Conclusion

Unhandled promise rejections are a common cause of Node.js production service downtime. By adopting defensive coding habits (using try-catch blocks with async/await and appending .catch() to promise chains), and setting up global listeners as a backup defense, you can ensure your backend applications remain stable under unexpected network or database failures.