Back to all posts

Everything You Need to Know About JavaScript Promises and How They Work

A comprehensive guide to understanding JavaScript Promises

Manash Jyoti Baruah

Manash Jyoti Baruah

December 7, 2024 12 min read

Everything You Need to Know About JavaScript Promises and How They Work

Table of Contents

Navigate through the key topics in this article

Introduction

Modern web development relies heavily on asynchronous activities to enable responsive, interactive applications. Whether it's retrieving data from an API, reading files, or running timers, these processes must run in the background without freezing the interface. JavaScript offers you a reliable way to handle these jobs. This article covers all you need to know about promises, including basic ideas and advanced features, to develop error-free asynchronous programs.

From understanding the core concepts to mastering advanced promise features and best practices, this comprehensive guide will help you become proficient with JavaScript promises and write cleaner, more maintainable asynchronous code.

What is a Promise?

A Promise in JavaScript is equivalent to making a "promise" to do something in the future. When you make a promise, you are saying, "I promise to give you the results later." This outcome could be success or failure.

In other words, a promise is an object that reflects the ultimate success (or failure) of an asynchronous operation and its resultant value. It lets you to correlate handlers with the success or failure of an asynchronous action, making your code easier to read and maintainable.

Here's the basic syntax for creating a Promise:

1const myPromise = new Promise((resolve, reject) => {
2  // Asynchronous operation
3
4  if (/* operation successful */) {
5    resolve(value); // Promise is fulfilled
6  } else {
7    reject(error); // Promise is rejected
8  }
9});

Why Use Promises?

In JavaScript, for instance, time-consuming operations-like retrieving data from a server-were generally accomplished with callbacks. A callback is just a function passed to another function to execute after the task is completed. You might use a callback, for example, to process data when it arrives from a server.

However, when there are complex operations, the use of callbacks get pretty messy. This mess is known as "callback hell," where one can have a callback within another, and this makes the code unreadable and unmanageable.

Callback Hell Example:

1fetchData((data) => {
2  processData(data, (processedData) => {
3    saveData(processedData, (result) => {
4      console.log(result);
5    });
6  });
7});

As shown above, such code becomes increasingly difficult to read and maintain in larger codebases due to its deeply nested structure, often referred to as "callback hell."

Promises were introduced to address this problem, offering a cleaner and more organized way to handle asynchronous tasks by allowing chaining in a more readable manner.

Promise-Based Approach:

1fetchData()
2  .then(processData)
3  .then(saveData)
4  .then(console.log)
5  .catch(console.error);

This approach flattens the structure and makes the code more readable and maintainable.

How Promises Work?

Promises in JavaScript can be in one of three states:

  1. Pending: This is the initial step. The promise is still yet to be fulfilled.

  2. Fulfilled: The promise has completed successfully which means it is resolved and has a value.

  3. Rejected: The promise did not complete successfully, and it carries an error message.

Basic Syntax:

1myPromise.then(
2  (value) => {
3    // fulfillment handler
4    console.log(value);
5  },
6  (reason) => {
7    // rejection handler (optional)
8    console.error(reason);
9  }
10);

In this example, the promise resolves after 1 second with the message "Promise resolved!" The .then() method is used to handle the resolved value.

Handling Promises

Using .then() for Success Handling

1myPromise.then((data) => {
2  console.log("Data received:", data);
3});

Using .catch() for Error Handling

The .catch() method is used to handle what happens when a promise fails. It registers a function (callback) to run when the promise is rejected.

1myPromise.catch((error) => {
2  console.error("Error:", error);
3});

Using .finally() for Cleanup

The .finally() method lets you run some code after the promise is done, whether it was successful or not.

1myPromise.finally(() => {
2  console.log("Cleanup tasks");
3});

Chaining Promises

Chaining allows you to perform tasks sequentially by passing the outcome of the previous one. Then proceed to the next.then(). This allows you to handle several asynchronous tasks sequentially.

Example of Chaining:

1fetch("https://api.example.com/user")
2  .then((response) => response.json())
3  .then((data) => {
4    console.log("Processed data:", data);
5    return processData(data);
6  })
7  .then((finalResult) => {
8    console.log("Final result:", finalResult);
9  })
10  .catch((error) => console.error("Error:", error));

This example uses each.then()to handle each step in the process, allowing for clear data flow. This allows you to see how the result of one stage is transferred to the next.

Error Handling in Promises

Promises simplify error handling by allowing them to pass down the chain to the .catch() method for resolution. This eliminates the need to handle failures at each phase, keeping your code clearer and easier to manage.

Example with Error Propagation:

1fetchData()
2  .then(processData)
3  .then(saveData)
4  .catch((error) => console.error("An error occurred:", error));

If any step in the promise chain fails, the error will be caught by the .catch() block. This makes it easy to handle issues and keep your code running smoothly.

Advanced Promise Features

1. Promise.all() for Parallel Execution

The Promise.all() method allows you to run several promises simultaneously and wait for them all to complete. If all of the promises are fulfilled, you will receive the results of each one. If any promise fails, it detects the mistake.

1Promise.all([fetchData1(), fetchData2(), fetchData3()])
2  .then((results) => {
3    console.log("All data fetched:", results);
4  })
5  .catch((error) => console.error("Error fetching data:", error));

In this example, if any promise fails, the entire Promise.all() fails.

2. Promise.race() for Fastest Promise

The Promise.race() method returns the result of the first promise that finishes, whether it succeeds or fails.

1Promise.race([fetchData1(), fetchData2()]).then((result) =>
2  console.log("First to finish:", result)
3);

In this example, whichever promise (fetchData1 or fetchData2) completes first will have its result logged to the console.

3. Promise.allSettled() for Handling All Outcomes

The Promise.allSettled() method waits for all the promises you give it to be in a successful or failed state and then finish. An array is then returned that has the results of each promise.

1Promise.allSettled([fetchData1(), fetchData2()]).then((results) => {
2  results.forEach((result) =>
3    console.log(result.status, result.value || result.reason)
4  );
5});

In this example, Promise.allSettled() waits for both fetchData1() and fetchData2() to complete. It then logs the status and result (or error) of each promise. This way, you can see what happened with each promise, regardless of whether they succeeded or failed.

4.Promise.any() for Resolving with the First Successful Promise

The Promise.any() method waits for the first promise to be resolved correctly from a list of promises. In case at least one promise is resolved, the value will be returned by the Promise.any() method. If all promises are refused, this method will throw an error.

1const promise1 = new Promise((resolve, reject) =>
2  setTimeout(reject, 100, "Error 1")
3);
4const promise2 = new Promise((resolve) =>
5  setTimeout(resolve, 200, "Success A")
6);
7const promise3 = new Promise((resolve, reject) =>
8  setTimeout(reject, 300, "Error 3")
9);
10
11Promise.any([promise1, promise2, promise3])
12  .then((value) => console.log(value)) // logs: 'Success A'
13  .catch((error) => console.error("All promises were rejected", error));

In this example, Promise.any() waits for the first promise to be resolved successfully. The procedure returns the outcome of the first successful promise, in this case promise2with the value 'Success A'. If all promises are refused, the .catch() block is executed, logging the error message. This strategy is beneficial when you want to receive the result of the first successful promise without having to wait for the rest.

JavaScript Execution Flow with Promises (Important)

1. Promises in JavaScript run in the microtask queue, which gets priority over macrotasks like setTimeout.

Here's an example to illustrate this:

1console.log(2);
2
3setTimeout(() => console.log(4), 0);
4
5Promise.resolve().then(() => console.log(3));
6
7console.log(6);
8
9// Output:
10// 2
11// 6
12// 3
13// 4

In this example:

  • console.log(2) runs first because it's a regular synchronous operation.

  • console.log (6) runs next because it's also synchronous.

  • The promise's .then() runs before the setTimeout callback because promises are microtasks, which have higher priority, hence prints 3.

  • Finally, the setTimeout callback runs, as it's a macrotask and prints 4.

So always remember, the promise's.then()executes before the setTimeout callback due to the microtask queue's priority.

2. Promise Execution Order and the Microtask Queue with Multiple .then() Calls

In JavaScript, code runs in a specific order: first the synchronous code, then microtasks (like promises), and finally, macrotasks (likesetTimeout).

Here's one example to explain this:

1console.log(3);
2
3const promise = new Promise((resolve) => {
4  console.log(6);
5  resolve();
6  console.log(2);
7});
8
9console.log(7);
10
11promise
12  .then(() => {
13    console.log(1);
14  })
15  .then(() => {
16    console.log(9);
17  });
18
19console.log(8);
20
21setTimeout(() => {
22  console.log(13);
23}, 10);
24
25setTimeout(() => {
26  console.log(21);
27}, 0);
28
29// Output:
30// 3
31// 6
32// 2
33// 7
34// 8
35// 1
36// 9
37// 21
38// 13

In this example, synchronous code runs first, logging 3, 6, 2, 7, and 8. Once the synchronous code finishes, microtasks (the .then() callbacks) are processed, logging 1 and 9. Finally, macrotasks (from setTimeout) execute in order of their delays, logging 21 (0ms) and 13 (10ms). This highlights JavaScript's execution order: synchronous code > microtasks > macrotasks.

3. Multiple Resolve and Reject Calls in a Promise: Only the First One Matters

When you create a promise, the first call to resolve or reject is the only one that counts. All the other calls are dismissed.

Here's an example to illustrate this:

1new Promise((resolve, reject) => {
2  resolve(1); // This will be the only one that counts
3  resolve(2); // Ignored
4  reject("error"); // Ignored
5}).then(
6  (value) => {
7    console.log(value); // Logs: 1
8  },
9  (error) => {
10    console.log("error"); // This won't run
11  }
12);

In this example, the promise is resolved with the value 1. The second resolve and the reject calls are ignored because the promise has already been settled with the first resolve.

4. Chaining Promises and Handling Values in Sequential .then() Calls

When you chain promises, each .then() handles a step in the process.

1Promise.resolve(1) // 1
2  .then(() => 2) // 2 (here 1 goes to the .then method but isn't used)
3  .then(3) // skip
4  .then((value) => value * 3) // 2 * 3 = 6
5  .then(Promise.resolve(4)) // creates a Pending promise
6  .then(console.log); // console.log will display 6

In this example, Promise.resolve(1) starts with a value of 1, but the first .then(() => 2) returns 2 instead. The next .then(3) is ignored, and the value 2 is passed on. The .then((value) => value * 3) multiplies the value by 3, resulting in 6. The .then(Promise.resolve(4)) doesn't change the value, and finally, .then(console.log) logs 6. This demonstrates how values are passed through the chain, with non-function values being ignored.

5. Promise Chain with .catch() and .finally() Handling

1Promise.resolve(1)
2  .then((val) => {
3    console.log(val); // resolve with value 1
4    return val + 1; // return 2
5  })
6  .then((val) => {
7    console.log(val); // 2
8    // return undefined
9  })
10  .then((val) => {
11    console.log(val); // undefined
12    return Promise.resolve(3).then((val) => {
13      console.log(val); // 3
14      // return undefined
15    });
16  })
17  .then((val) => {
18    console.log(val); // undefined
19    return Promise.reject(4); // return 4
20  })
21  .catch((val) => {
22    console.log(val); // 4
23    // return undefined
24  })
25  .finally((val) => {
26    console.log(val); // undefined: finally has no arguments
27    return 10; // no effect on promise object
28  })
29  .then((val) => {
30    console.log(val); // undefined: because recent 'catch()' handled the promise object with 'undefined'
31  });

In this example, we're chaining multiple .then(),.catch(), and .finally() methods together to show how different stages of promise resolution are handled. Let's break it down:

  • finally() does not receive an argument: The finally() block executes clean-up code but doesn't take or pass any values. It's used to ensure certain code runs regardless of the promise's outcome.

  • Returning a value in finally() doesn't affect the promise: If you return a value in the finally() block, it doesn't affect the promise chain or the final value. It's executed after the promise resolution/rejection but doesn't modify the result.

  • Throwing an error in finally() causes rejection: If you throw an error or return a rejected promise in finally(), it will cause the promise chain to reject with the error or rejection reason.

1Promise.reject(1).finally(() => {
2  throw new Error(2);
3});

OR

1Promise.reject(1).finally(() => {
2  return Promise.reject(2);
3});
  • The order of .then() and .catch() matters: The .then() and .catch() can be invoked in any order, but they will always return the promise's final state. When a promise is handled by .catch(), any subsequent .then() will receive the final value.

Example:

1Promise.reject(1)
2  .catch((val) => {
3    console.log(val); // 1
4  })
5  .then((val) => {
6    console.log(val); // undefined
7  });

Converting Promise Chains to Async/Await

You can combine promises with async/await for parallel execution using Promise.all().

1async function getAllData() {
2  try {
3    const [data1, data2] = await Promise.all([fetchData1(), fetchData2()]);
4    console.log("Data 1:", data1);
5    console.log("Data 2:", data2);
6  } catch (error) {
7    console.error("Error fetching data:", error);
8  }
9}

Best Practices and Common Mistakes

  • Avoid Deep Nesting: Use chaining or async/await to keep code flat and readable.

  • Always Handle Errors: Make sure every promise chain has a .catch() or a try/catch block.

  • Use Parallel Execution Wisely: Only use Promise.all() when tasks are independent but need to finish together.

Conclusion

JavaScript promises are one of the best ways to deal with your time-consuming operations, such as, retrieving data on a server. They even help you write cleaner easier-to-maintain code, and the practice of what you have learned will equip you to take full advantage of asynchronous coding. Once you get some hands-on experience and begin handling errors elegantly, promises will become such a huge part of JavaScript.