JavaScript Promises Demystified - An Introduction

Posted on: by Rajeev Edmonds

Asynchronous programming is one of the prominent features of JavaScript. We can do it in three different ways. The first one is the good old callback mechanism which gets ugly at times through a phenomenon called—callback hell.

Another way to write async code is with the help of promises. And, last but not least is the newest way which uses async/await syntax. The last one is just some—syntactic sugar—on top of promises. In this primer, we're going to focus on promises.

A word cloud of words and synonyms of promise

Let's start with defining what a promise is. As the name implies, a promise is something that is fulfilled in the future.

A JavaScript promise is an object acting as a container for a value that'll be generated in the future through an asynchronous operation. Sounds confusing?

A promise tracks an asynchronous operation, and upon its completion, returns its (result) value—for your consumption—in the future.

Generally, these values are not known in advance.

Promise Fundamentals

Let's see the structure and anatomy of a typical JavaScript promise. Here's how it looks in its simplified form.

A graphic of JavaScript promise constructor

So, we have a promise constructor which accepts an executor function. This function is executed —instantly—the moment you create a new Promise instance.

The executor function accepts two arguments. Both of them are functions. The first one is resolve() and the second one, reject().

We call resolve(...) upon successful completion of an operation and generally pass in the result of that successful operation.

Similarly, when an operation fails, we call the reject(...) function and generally pass the data associated with the failure.

Let's see an example in action.

const promise = new Promise(function (resolve, reject) { // Executor function
    const randomNumber = Math.floor(Math.random() * 100);

    if (randomNumber % 2 === 0)
    resolve(randomNumber);
    else 
    reject(new Error(`Whoops! We got an odd number: ${randomNumber}`));
  });

The example shown above does nothing useful.

This promise is resolved whenever we randomly get an even number. In case an odd number is generated, we reject it.

Promise States

Now, that we know how we can create a new promise, let's see the states it may be in at any given point of time, during its lifetime.

Here's a graphic depicting various states of a promise.

A graphic depicting various states of a JavaScript promise

Internally, every promise has two properties that you cannot access directly. These properties are state and result.

Initially, the property state is set to pending and the property result is undefined. At this point, the promise is also considered to be in an Unsettled state.

Whenever an operation is successful, and the resolve(...) method is called, the state of promise changes. In this condition, the property state is set to fulfilled, and the property result is assigned the value of the successful operation.

Similarly, whenever there's an error or the operation returns an unfavorable result, a call to reject(...) method is made. This sets the property state to rejected and the property result is assigned the error data passed via the rejection call.

In both these conditions (fulfilled or rejected), the promise is considered to be in a Settled state.

In an executor function, a call to resolve(...) and reject(...) should be made only once. Any further calls to both these functions are ignored.

And, make sure you're passing only one argument to the resolve(...) and reject(...) function call. If additional arguments are passed, they're simply discarded or ignored.

Promise Consumption via Handlers

Now that we know how to create a promise, the next obvious question is how do we use (consume) it? It's done by attaching different types of handlers to it.

In other words, we process data associated with the promise object by registering handler methods on that object. Following are these instance handler methods.

  • A then(...) function.
  • A catch(...) function.
  • A finally() function.

Let's understand each one of these—one by one.

Connection between executor function, promise object, and halder functions

How then() Method Works?

A then(...) method accepts two arguments. The first argument is the fulfillment handler, and the second argument is the rejection handler. Both of them are functions.

Here's how it works!

// "promise" is the Promise object
    promise.then(
    (result) => {
      /* process/handle result here... */
    },
    (error) => {
      /* process/handle error here... */
    }
  );

Here, the first argument executes whenever a promise is resolved successfully and a call to resolve(...) is made in that promise. The data passed to the resolve(...) method is available via the result variable in the then(...) handler.

You can name this (result) variable, whatever you like.

A call to reject(...) in the promise triggers the execution of the second argument in the then(...) handler.

Any data passed to the reject(...) function in the promise is available via the error variable in the then(...) handler. You're free to choose an appropriate name for the error variable.

Here's an example where we both create a promise and attach a then(...) handler to it.

const promise = new Promise(function (resolve, reject) {
    // your code here....
    if( someCondition )
      resolve('Success!'); // triggers execution of first arg in then()
    else
      reject(new Error('Failure!')); // triggers execution of second arg in then()
  });

  promise.then(
    (result) => { 
      /* process/handle result here... */
    },
    (error) => { 
      /* process/handle error here... */
    }
  );

Some important points to remember about the then(...) method.

Ideally, you must pass at least one of the handler functions to the then(...) method. If none are passed, the result (state) of the promise is passed down to the next handler.

Generally, a then(...) method is used as a fulfillment handler. In this case, the second argument is omitted altogether. Here's an example.

const promise = new Promise(function (resolve, reject) {
    // your code here....
    resolve('Success!'); // triggers execution of first arg in then()
  });

  promise.then(
    (result) => { 
      /* process/handle result here... */
    }
  );

In case, you're using the then(...) method purely as a rejection handler, use undefined as the first argument. Here's an example.

const promise = new Promise(function (resolve, reject) {
    // your code here....
    reject(new Error('Failure!')); // triggers execution of first arg in then()
  });

  promise.then(
    undefined,
    (error) => { 
      /* process/handle error here... */
    }
  );

In real-life production code, you would rarely see such an approach. To deal with errors, we use a different type of handler, which we're going to discuss next.

How catch() Method Works?

The rejection calls in a promise can be handled by the second argument of the then(...) method. But that's not the recommended way.

There's a catch(...) method built specifically for handling errors. Here's how it's used.

const promise = new Promise(function (resolve, reject) {
    // your code here....
    reject(new Error('Failure!')); 
  });

  promise.catch(
    (error) => { 
      /* process/handle error here... */
    }
  );

There's an interesting correlation between then() and catch() methods.

Internally, an invocation of catch(func) method call—triggers—a call to then(undefined, func) method to get the job done!

In other words, a call to catch(func) is analogous to a call to then(undefined, func) call.

While rejecting promises, it's always advisable to throw an Error object or a derivative of an Error object instead of simply returning a string, number, or an ordinary object.

The former approach ensures you do not lose valuable information about an error (e.g. stack trace) while processing an exception.

How finally() Method Works?

And, last but not least is the finally() method. As the name suggests, this method is executed regardless of if the promise is resolved or rejected. It's also called a settlement handler.

In other words, if a promise enters the settled state, the finally() method (if registered) executes without any fail. Here's an example.

const promise = new Promise(function (resolve, reject) {
    // your code here....
  });

  promise.finally(
    () => { 
      /* promise settled (fulfilled or rejected)... */
    }
  );

In real-world projects, finally() is generally used to perform cleanups or similar jobs after the primary operation has finished its execution.

The finally() handler receives no arguments. If chained further (more on this later), the promise state is passed further down to the next handler.

Use finally() when you don't care about the result and want to execute a mandatory routine after the promise has been settled.

Promise Chaining Fundamentals

Now that we know how a promise is created and how we consume it through various types of handler methods, it's time to move a step further and learn about promise chaining.

All the promise handler methods are chainable. This enables you to write a much clear and easily understandable asynchronous code.

JavaScrit Promise chaining example

All the promise handlers, viz. then(), catch(), and finally() return a promise. This allows them to be chained.

Promise chaining can be loosely compared with a pipeline in which you process a multi-stage operation where each preceding step generates an output for the next step.

Though it is not true in its entirety as a control flow may skip several registered handlers before reaching the one registered way down in the chain.

Sounds confusing?

Let's directly dig into the code to understand it clearly.

new Promise((resolve, reject) => {
  resolve(100);
})
  .then((result) => console.log(result)) // => 100
  .catch((error) => console.log(error))
  .finally(() => console.log('Promise settled!')); // => Promise settled!

Here we are settling a promise by resolving it with a value of 100 which is received by the then() handler.

A catch() handler is present, but it is not invoked at all because neither the parent promise nor the following then() handler rejected a promise or threw an error.

As mentioned before, the finally() method invocation is sealed as soon as the promise enters the settled state.

Here's the same example, but this time we reject the promise.

new Promise((resolve, reject) => {
  reject('Error!');
})
  .then((result) => console.log(result))
  .catch((error) => console.log(error)) // => Error!
  .finally(() => console.log('Promise settled!')); // => Promise settled!

Because the promise is rejected, it's the catch() handler that gets invoked. The important thing to note is that finally() got invoked this time too.

Let's see what happens when finally() is at the top of the chain.

new Promise((resolve, reject) => {
  resolve(100);
})
  .finally(() => console.log('Promise settled!')) // => Promise settled!
  .then((result) => console.log(result)) // => 100
  .catch((error) => console.log(error));

The finally() handler passes on the state of the promise (received from the top) to the next handler down the chain. That's why, despite standing in second place in the promise chain, the then() handler can receive the value from the parent promise at the very top.

Rejecting a promise or throwing an error in finally() will override the promise state received up from the chain. The new value will be whatever is thrown or returned.

Here's an example.

new Promise((resolve, reject) => {
  resolve(100); // This state and value is overridden in finally()
})
  .finally(() => new Promise((undefined, reject) => reject('Whoops!'))) // A new promise with a new state
  .then((result) => console.log(result))
  .catch((error) => console.log(`Error: ${error}`)); // => Error: Whoops!

You can also continue chaining even after the catch() handler. It's like continuing with an operation processing after handling a specific error condition.

new Promise((resolve, reject) => {
  reject('overflow');
})
  .then((result) => console.log(result))
  .catch((error) => {
    console.log(`Error: ${error} handled!`); // => Error: overflow handled!
    return new Promise((resolve) => resolve(50));
  })
  .then((value) => console.log(value)); // => 50

Now, take a look at the following code snippet. Sometimes, newbies confuse it with chaining.

const promise = new Promise((resolve, reject) => {
  resolve(100);
});

promise.then((result) => console.log(result)); // => 100
promise.then((value) => console.log(value)); // => 100
promise.then((data) => console.log(data)); // => 100

Here, you're registering multiple then() handlers with the same promise. You're not chaining them after one another. These handlers are at the same level without any tail of their chains.

If you're returning plain values (non-promise values, e.g. primitives or objects) in a handler, they're wrapped in a new promise before pushing them down to the next handler in the chain.

new Promise((resolve, reject) => {
  resolve(100);
})
  .then((value) => value + 100) // returns a new promise with the result: 200
  .then((result) => result + 200) // returns a new promise with the result: 400
  .then((data) => console.log(data)); // => 400

It's always recommended to keep one catch() handler at the end of every promise chain.

This error handler not only takes care of explicit promise rejections and error throws but also catches errors introduced inadvertently by the developer.

new Promise((resolve, reject) => {
  resolve(100);
})
  .then((value) => value + someVar) // using a non-existent variable
  .catch((error) => console.log(error)); // => Reference Error: someVar is not defined

One of the common problems beginners often face in promise chaining is nesting promises inside each other instead of creating a linear chain.

This problem disappears as you code regularly and write more asynchronous code.

Multiple Promise Execution Management

So far, we were dealing with a single promise. What if we want to run and manage multiple promises in some way or the other?

That's what we're going to learn in this section.

But before that, let's take a quick look at two static methods which return a settled promise—instantly. These functions are as follows.

  • A Promise.resolve(...) method.
  • A Promise.reject(...) method.

Calling either of them immediately returns a new promise with the value passed to these functions. Here's an example.

Promise.resolve(100)
  .then((result) => {
    console.log(result); // => 100
    return Promise.reject(-1);
  })
  .catch((error) => console.log(error)); // => -1

To run multiple promises in parallel, we have a rich API comprising four static methods. They're as follows.

  • Promise.all()
  • Promise.allSettled()
  • Promise.race()
  • Promise.any()

Let's understand each one of them—one by one.

Promise.all()

This API method receives an iterable (generally an array) of promises and returns a promise with the results of all the promises in an array.

If you're passing a non-promise value in the iterable, it is pushed as it is in the resulting array.

How JavaScript Promise.all() API works

What if one of the promises is rejected?

In this case, the execution of the API method is immediately terminated and a promise is returned with the value of the rejected promise that failed.

How Promise.all() behaves on promise rejection

All other promises are ignored in this case. You only get the rejected promise value.

Let's see the code for both scenarios in action.

// All the promises are resolved
Promise.all([Promise.resolve(1), Promise.resolve(100), Promise.resolve(200)])
  .then((result) => console.log(result)) // => [ 1, 100, 200 ]
  .catch((error) => console.log(error));

// One of the promises is rejected 
Promise.all([Promise.resolve(1), Promise.reject(-1), Promise.resolve(200)])
  .then((result) => console.log(result))
  .catch((error) => console.log(error)); // => -1

If an empty iterable is passed to the Promise.all() method, an already resolved promise is instantly returned with an empty array as the result.

Promise.allSettled()

This is one of my favorite API methods. Once again you pass an iterable (generally an array) of promises to Promise.allSettled() which returns a promise with the result as an array of objects.

It waits till all of the promises are resolved (fulfilled or rejected) and sends the result of all of them in the following format.

A status property with the values fulfilled or rejected, and a value property with the result data in case of fulfillment, or a reason property with the error data in case of rejection.

The following graphic makes it a bit clear.

How JavaScript Promise.allSettled() API works

And, let's see the code in action as well.

Promise.allSettled([
  Promise.resolve(1),
  Promise.reject(-1),
  Promise.resolve(200)
])
  .then((result) => console.log(result)) // =>
  /*
  [
    { status: 'fulilled', value: 1 },
    { status: 'rejected', reason: -1 },
    { status: 'fulfilled', value: 200 }
  ]
  */

And, once again, if you pass an empty iterator to the Promise.allSettled() method, the result is an immediately-returned resolved promise with an empty array as the result.

This API call is best suited for situations when you're more interested in processing the results of each promise regardless of how they've settled.

Promise.race()

The Promise.race() method receives an iterable (generally an array) of promises and returns a promise with the value of the first promise that gets settled—among all—in the iterable.

This API call only monitors the first settled promise and doesn't care if it is fulfilled or rejected. All other promises are ignored.

How JavaScript Promise.race() API works

Let's see this same example in the form of code.

Promise.race([
  new Promise((resolve, reject) => setTimeout(() => reject(1), 2000)),
  new Promise((resolve, reject) => setTimeout(() => reject(200), 1000)),
  new Promise((resolve, reject) => setTimeout(() => resolve(300), 3000))
])
  .then((result) => console.log(result))
  .catch((error) => console.log(error)); // => 200

As you can see, the second promise is resolved first and therefore its outcome becomes the result or error of the promise returned by the Promise.race() method.

Caution: Passing an empty iterable to Promise.race() returns a promise that'll remain pending—perpetually.

If you're passing an already settled promise or a non-promise value (e.g. a primitive), the first occurrence of such an entry (in the iterable) is returned by the Promise.race() method.

Here's an example.

Promise.race([
  new Promise((resolve, reject) => setTimeout(() => reject(1), 2000)),
  Promise.resolve('I am the winner!'), // Already settled promise
  new Promise((resolve, reject) => setTimeout(() => reject(200), 1000)),
  3.14, // a primitive value
  new Promise((resolve, reject) => setTimeout(() => resolve(300), 3000))
])
  .then((result) => console.log(result)) // => I am the winner!
  .catch((error) => console.log(error));

Here, we have both a non-promise value as well as an already resolved promise. Because the latter one comes first, it's picked up by the API method.

Promise.any()

This API method is quite opposite of what the Promise.all() method does. The Promise.any() method receives an iterable (generally an array) of promises and resolves with the result (value) of the first promise that gets resolved.

Here's a graphical representation of the same.

How JavaScript Promise.any() API call works

Here's the code snippet for the same.

Promise.any([
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 2000)),
  new Promise((resolve, reject) => setTimeout(() => reject(200), 1000)),
  new Promise((resolve, reject) => setTimeout(() => resolve(300), 1000))
])
  .then((result) => console.log(result)) // => 300
  .catch((error) => console.log(error.errors));

What if all of the promises are rejected in the iterable passed to Promise.any() method?

In such a case, the API call returns a rejected promise with a special AggregateError object which is a derivative of an Error object.

AggregateError object in Promise.any() method

All the error data associated with the rejected promises is transferred to the errors property (array) of the AggregateError object.

Promise.any([
  new Promise((resolve, reject) => setTimeout(() => reject(1), 2000)),
  new Promise((resolve, reject) => setTimeout(() => reject(200), 1000)),
  new Promise((resolve, reject) => setTimeout(() => reject(300), 1000))
])
  .then((result) => console.log(result))
  .catch((error) => console.log(error.errors)); // => [ 1, 200, 300 ]

If an empty iterable is passed, Promise.any() returns a rejected promise with an empty array.

Promise.any([])
  .then((result) => console.log('Result: ', result))
  .catch((error) => console.log(error.errors)); // => []

If you're interested in the result of the very first promise that gets resolved, use this API method. A very common use case for this API method is when you're attempting to fetch a mandatory common resource from multiple locations.

Summary

Promises are used to write asynchronous code in JavaScript. They're easy-to-use and enable you to write async code in a linear fashion.

A rich API consisting of 6 static methods and 3 instance methods gives you enough power to write complex asynchronous code, with ease.

Previous Post: Git Objects - How They Work Internally?