Unhandled Promise Rejections: How to Catch and Fix Them

Discover how to catch and fix unhandled promise rejections in JavaScript. Learn best practices for preventing crashes caused by unhandled promises

JavaScript has become an essential part of modern web development, powering everything from simple form validation to complex single-page applications. One of the most powerful features of JavaScript is its ability to handle asynchronous operations using promises. Promises make it easier to work with async code by providing a cleaner alternative to callbacks. However, with great power comes great responsibility. Unhandled promise rejections can lead to hard-to-find bugs, unpredictable behavior, and even crashes in your applications.

In this article, we’ll dive deep into the world of unhandled promise rejections, explore why they happen, how to catch them, and most importantly, how to fix them to ensure that your web applications are more robust and error-proof. By the end, you’ll have a clear understanding of how to deal with promise rejections effectively and ensure your code gracefully handles async errors.

Understanding Promises and Their Rejections

Before we explore how to catch and fix unhandled promise rejections, it’s important to understand how promises work and what happens when they are rejected.

A promise in JavaScript represents a value that may not be available yet but will be resolved or rejected in the future. It allows you to handle asynchronous operations (like fetching data from an API or reading a file) without using callbacks. Promises have three states:

  1. Pending: The promise is neither fulfilled nor rejected.
  2. Resolved (fulfilled): The promise has been fulfilled, and a result is available.
  3. Rejected: The promise has failed, and an error or reason for the failure is available.

A promise is considered unhandled when it is rejected, but no error handler (i.e., catch() or .then() with an error callback) is provided to deal with that rejection.

Example of an Unhandled Promise Rejection:

let promise = new Promise((resolve, reject) => {
reject('Error occurred'); // Rejecting the promise
});

// No catch() or error handler here

In the above code, the promise is rejected with the message 'Error occurred', but because there’s no catch() or rejection handler, the promise rejection goes unhandled.

Why Unhandled Promise Rejections Are Dangerous

Unhandled promise rejections can lead to unpredictable outcomes in your application. If a promise is rejected without an error handler, it can cause the following issues:

Silent Failures: Without a proper error handler, your code may fail without notifying you, making it difficult to detect bugs or trace errors.

Memory Leaks: In some cases, unhandled promise rejections can prevent the garbage collector from freeing up memory, causing memory leaks.

Application Crashes: In Node.js, starting from version 15.0.0, unhandled promise rejections cause the process to exit with a non-zero exit code, potentially crashing the server or application.

Because of these risks, it’s crucial to catch and handle promise rejections properly in both client-side and server-side JavaScript code.

How to Catch Promise Rejections

Catching promise rejections involves ensuring that every promise chain has a way to handle failures. There are several ways to do this, depending on how you are using promises in your code.

1. Using .catch() to Handle Rejections

The easiest and most common way to handle a rejected promise is by using the .catch() method. .catch() is a method that allows you to handle any error or rejection that occurs in the promise chain.

Example:

let promise = new Promise((resolve, reject) => {
reject('Error occurred');
});

promise
.then(result => {
console.log('Promise resolved:', result);
})
.catch(error => {
console.error('Caught rejection:', error); // Properly handling the error
});

In this example, .catch() ensures that if the promise is rejected, the error is caught and logged, preventing the rejection from going unhandled.

Another way to handle promise rejections is to provide an error callback directly in the .then() method.

2. Using .then() with an Error Callback

Another way to handle promise rejections is to provide an error callback directly in the .then() method. This method can be useful when you want to handle both success and error cases in the same chain.

Example:

let promise = new Promise((resolve, reject) => {
reject('Error occurred');
});

promise.then(
result => {
console.log('Promise resolved:', result);
},
error => {
console.error('Caught rejection with .then():', error); // Handling error in .then()
}
);

While this approach works, it’s generally better to use .catch() for handling errors, as it provides better separation between success and error handling logic.

3. Using try...catch with async/await

For modern JavaScript code that uses async/await, handling promise rejections is straightforward with a try...catch block. This pattern allows for more readable and synchronous-looking code, while still handling asynchronous errors gracefully.

Example:

async function fetchData() {
try {
let response = await fetch('https://api.example.com/data');
let data = await response.json();
console.log(data);
} catch (error) {
console.error('Error fetching data:', error); // Catching promise rejection
}
}

fetchData();

In this example, the try...catch block ensures that any promise rejections are caught and handled properly, making the code cleaner and easier to debug.

Common Mistakes Leading to Unhandled Promise Rejections

Even when developers intend to handle promise rejections, there are several common mistakes that can lead to unhandled rejections. Let’s take a look at some of these pitfalls and how to avoid them.

1. Forgetting to Return Promises

When working with promises inside functions, one common mistake is forgetting to return the promise. If a promise is not returned, the error handling outside of the function will not be triggered.

Example of the Mistake:

function fetchData() {
fetch('https://api.example.com/data')
.then(response => response.json())
.catch(error => console.error('Caught in fetchData:', error));
}

fetchData(); // Unhandled rejection outside this function will not be caught

In this example, fetchData() does not return the promise, so if the calling code expects to handle a rejection (with .catch() or try...catch), it won’t be able to. To fix this, always return the promise:

function fetchData() {
return fetch('https://api.example.com/data')
.then(response => response.json())
.catch(error => console.error('Caught in fetchData:', error));
}

fetchData()
.then(data => console.log(data))
.catch(error => console.error('Caught outside fetchData:', error)); // Now handled correctly

2. Ignoring Rejected Promises in async/await Functions

In async/await functions, it’s easy to overlook the fact that an awaited promise can still be rejected. If you forget to wrap await statements in a try...catch block, the rejection will go unhandled.

Example of the Mistake:

async function getData() {
let response = await fetch('https://api.example.com/data'); // This can reject
let data = await response.json();
console.log(data);
}

getData(); // If fetch fails, there’s no catch for the rejection

In this case, if the fetch() request fails, the rejection will go unhandled. To fix this, wrap the await calls in a try...catch block:

async function getData() {
try {
let response = await fetch('https://api.example.com/data');
let data = await response.json();
console.log(data);
} catch (error) {
console.error('Error in getData():', error); // Now handled correctly
}
}

getData();

3. Using .catch() Incorrectly in Promise Chains

If you use .catch() in a promise chain but don’t re-throw or return a new promise, any further rejections in the chain will go unhandled.

Example of the Mistake:

fetch('https://api.example.com/data')
.then(response => response.json())
.catch(error => {
console.error('Caught error:', error);
// No return or re-throw here, so next .then() will still run even if there's an error
})
.then(data => {
console.log(data); // This will run even if there was an error
});

In this example, if an error occurs in the first .then(), the second .then() will still execute, potentially leading to more issues. To fix this, re-throw the error in the .catch() block if you need to propagate it further down the chain:

fetch('https://api.example.com/data')
.then(response => response.json())
.catch(error => {
console.error('Caught error:', error);
throw error; // Re-throw the error to stop further execution
})
.then(data => {
console.log(data); // Will not run if an error is thrown
})
.catch(finalError => {
console.error('Final catch:', finalError); // Handles any re-thrown error
});

Tools and Techniques to Detect Unhandled Rejections

Modern browsers and Node.js environments provide tools to help detect unhandled promise rejections, allowing you to catch and fix them during development.

1. Using Node.js’s unhandledRejection Event

In Node.js, you can listen for unhandled promise rejections using the unhandledRejection event. This is useful for logging errors in production environments or during development.

Example:

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

With this event handler, any unhandled promise rejection will be logged, helping you identify where the rejection occurred.

2. Chrome DevTools Warnings

In modern browsers, including Chrome, unhandled promise rejections trigger a warning in the developer console. If you see the message “Unhandled Promise Rejection Warning”, it means that a rejected promise wasn’t properly caught in your code.

Keep an eye on the console during development, and make sure to resolve any warnings by adding proper .catch() handlers or try...catch blocks.

Implementing a Robust Error Handling Strategy

Handling unhandled promise rejections effectively is more than just adding .catch() or try...catch blocks throughout your code. For a scalable and maintainable web application, it’s essential to implement a consistent and robust error handling strategy that ensures every potential rejection is accounted for and handled in a meaningful way.

Here are a few advanced strategies that go beyond basic error handling:

1. Centralizing Error Handling with a Custom Error Handler

Instead of scattering .catch() or try...catch statements throughout your code, you can centralize error handling using a custom error handler. This approach provides a single point of error management, making it easier to log, analyze, and respond to errors consistently.

How to Implement a Custom Error Handler:

First, define an error handling utility function that captures all errors, logs them, and responds appropriately based on the error type.

Example:
function handleError(error) {
// Log the error for debugging purposes
console.error('An error occurred:', error.message || error);

// Optionally, send the error to a remote logging service
// logErrorToService(error);

// Return a user-friendly error message
return 'Something went wrong. Please try again later.';
}

async function fetchData() {
try {
let response = await fetch('https://api.example.com/data');
let data = await response.json();
return data;
} catch (error) {
return handleError(error); // Centralized error handling
}
}

fetchData()
.then(data => console.log(data))
.catch(error => console.error('Unhandled in main flow:', error));

In this example, all errors are sent through the handleError() function, ensuring that they are logged and a meaningful message is returned. This approach improves code maintainability and reduces duplication.

When dealing with promises that involve critical app functionality—such as fetching data from an API

2. Graceful Degradation: Providing Fallbacks for Failed Promises

When dealing with promises that involve critical app functionality—such as fetching data from an API, saving user input, or authenticating users—it’s essential to provide fallback behavior in case of promise rejections. This ensures that the app remains functional, even if parts of it fail.

Example of Using Fallbacks:

Suppose your app fetches user data from an external API. If the API request fails, you can provide a fallback response, such as cached data or default values, to keep the app functional.

async function fetchUserProfile() {
try {
let response = await fetch('https://api.example.com/user-profile');
if (!response.ok) {
throw new Error('Failed to fetch profile data');
}
let data = await response.json();
return data;
} catch (error) {
console.error('Error fetching user profile:', error);
// Provide a fallback profile in case of failure
return { name: 'Guest User', preferences: {} };
}
}

fetchUserProfile()
.then(profile => {
console.log('User profile:', profile);
// Use the profile data in the app
})
.catch(error => {
console.error('Unhandled error:', error);
});

In this example, even if the API request fails, the app continues to function with a fallback profile for the user. This approach improves the user experience by preventing hard crashes and allowing users to interact with the app despite the error.

3. Gracefully Handling Multiple Promises

When working with multiple promises in parallel, such as fetching data from several APIs simultaneously, handling errors in a coordinated way is critical. One common pattern for handling multiple promises is using Promise.all() to execute promises in parallel. However, a single rejected promise will cause Promise.all() to fail entirely.

Solution: Using Promise.allSettled() for Resilience

Unlike Promise.all(), which fails if any promise is rejected, Promise.allSettled() waits for all promises to either resolve or reject and returns an array of results, regardless of the outcome of each promise.

Example:
const fetchUsers = fetch('https://api.example.com/users');
const fetchPosts = fetch('https://api.example.com/posts');
const fetchComments = fetch('https://api.example.com/comments');

Promise.allSettled([fetchUsers, fetchPosts, fetchComments])
.then(results => {
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Promise ${index} fulfilled with`, result.value);
} else {
console.error(`Promise ${index} rejected with`, result.reason);
}
});
});

In this approach, Promise.allSettled() ensures that even if one of the promises fails, the others still proceed. This is particularly useful for apps that rely on multiple data sources where a single failure shouldn’t halt the entire process.

4. Logging and Monitoring for Production Environments

In a production environment, error handling should go beyond console logging. To ensure that you can track errors in real-time and resolve them quickly, integrate logging and monitoring tools into your application.

Popular Tools for Error Monitoring:

Sentry: A popular error tracking tool that captures exceptions, tracks unhandled promise rejections, and provides stack traces for debugging. Sentry also integrates with JavaScript frameworks like React, Angular, and Vue, making it easy to add to your project.

import * as Sentry from "@sentry/browser";

Sentry.init({ dsn: "https://example.sentry.io/project-id" });

async function fetchData() {
try {
let response = await fetch('https://api.example.com/data');
return await response.json();
} catch (error) {
Sentry.captureException(error); // Log the error to Sentry
throw error;
}
}

LogRocket: A tool that captures session recordings, console logs, and network activity, allowing you to replay what users experienced in your app and understand the context of errors.

Elastic Stack (ELK): A powerful suite of logging tools (Elasticsearch, Logstash, and Kibana) for aggregating, searching, and visualizing logs. This is useful for more complex applications with higher logging demands.

By integrating these tools into your error-handling strategy, you can monitor your app’s health in production, capture unhandled promise rejections, and proactively resolve issues before they affect users.

5. Applying Rejection Handling in Node.js

When developing server-side applications using Node.js, unhandled promise rejections can cause the server to crash or behave unpredictably. This is especially dangerous in production, where unhandled errors can bring down entire services. Node.js provides a way to handle unhandled rejections globally, but it’s recommended to prevent them from happening in the first place by using proper error handling techniques.

Handling Global Rejections in Node.js:

In Node.js, you can capture all unhandled promise rejections globally by using the unhandledRejection event. However, this should only be a last resort for catching errors that slip through your error-handling logic.

process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Promise Rejection:', reason);
// Perform clean-up or restart server if necessary
});

For a production-ready server, consider restarting the server or performing cleanup tasks if a critical unhandled rejection is detected. This ensures that your application can recover gracefully from serious errors.

Conclusion

Unhandled promise rejections can cause serious issues in your web applications, from silent failures to crashes. By understanding how promises work, learning how to properly handle rejections, and avoiding common pitfalls, you can make your JavaScript applications more reliable and easier to maintain.

The key takeaways from this article are:

  1. Always catch promise rejections using .catch(), .then() with an error callback, or try...catch with async/await.
  2. Avoid common mistakes like forgetting to return promises or improperly handling rejections in promise chains.
  3. Use tools like Node.js’s unhandledRejection event and browser console warnings to detect unhandled rejections during development.

By following these best practices, you can ensure that your application gracefully handles errors, providing a more robust and predictable user experience, and making your code more maintainable in the long run.

Read Next: