Hyun Don's Blog

About Now Notes Resume Portfolio

Asynchronous JavaScript: Promises

Pre-ES6

Traditionally, JavaScript developers had to rely on callbacks to carry out asynchronous operations. Asynchronous callbacks are passed as arguments to functions that execute asynchronous code. The callback function is invoked after the asynchronous code is executed and specified conditions are met.

Asynchronous callbacks are practical when handling simple asynchronous operations. However, callbacks have to be nested within one another to implement a “chain” of callbacks, making your code a lot harder to read (callback hell/pyramid of doom). Errors can occur at any stage of the asynchronous operation, which means that each callback of the “chain” needs its own error handler.

Promises offer an alternative to callbacks when handling asynchronous operations in JavaScript. Promises are now an essential part of asynchronous programming in web development because they make it much easier to register multiple callbacks on an asynchronous operation. Moreover, Promises also simplify error handling in asynchronous operations while offering a standardized way to handle asynchronous errors.

Promises

A Promise represents the eventual outcome of a single asynchronous operation. A Promise is an object which holds information regarding the asynchronous operation. It also contains methods that we can use to register callbacks that will be invoked when the asynchronous operation terminates.

states

During its lifetime, a Promise can be in one of three states: fulfilled, rejected, or pending. These states are mutually exclusive, meaning that a Promise can be in a single state at a time.

A settled Promise is a Promise that has either been fulfilled or rejected. A Promise that has settled cannot change its state and will always remain in that state.

resolved/rejected

You may also come across the term resolved when working with Promises. It is often used interchangeably with fulfilled,” but a resolved Promise does not necessarily mean a fulfilled Promise.

A Promise is resolved if 1) it has settled, or 2) it is “tracking” the state of another Promise. A Promise (p) that is currently tracking the state of another Promise (q) which has not settled is resolved but still pending. When Promise q settles, then Promise p will also settle with the same value.

If a Promise is rejected directly, or an unhandled exception occurs, then the Promise is rejected. A Promise is also rejected if it resolves to a Promise that eventually rejects or has already been rejected.

Note that the “tracking” is recursive. A Promise that resolves to another Promise which resolves to yet another Promise that eventually rejects is also rejected.

Creating Promises

Promise constructor

The Promise constructor creates and initializes a new Promise object. It must be called as a constructor function, and invoking it as a regular function will throw an exception.

The Promise constructor accepts a single executor function as argument. The executor function is used to initiate and handle the operation associated with the newly created Promise. Passing in anything other than a function object to the Promise constructor will result in a TypeError.

The executor function expects to receive a resolve function and a reject function as arguments. These functions are able to resolve or reject the associated Promise. The resolve and reject functions may be used within the executor function to alert the user of the eventual outcome of the asynchronous operation.

Both resolve and reject functions accept a single argument. The value passed to the resolve function represents the eventual outcome of the Promise. The value passed to the reject function represents the rejection value of the Promise.

When the Promise constructor is properly called, it will provide the required arguments to the executor function. Then, it will return a newly created Promise object whose fate lies in the hands of the executor function. Note that the executor function will be invoked synchronously which means that any synchronous code within the executor will block the execution of any subsequent code!

let start, end;

function getResolvedPromise() {
  return new Promise((resolve, reject) => {
    console.log("Initiating Promise...");
    start = new Date();
    let counter = 0;

    for (let i = 0; i < 99_999_999; i++) {
      // blocking
      counter += Math.log(i);
    }

    resolve("Promise resolved!");
    reject("This is ignored");
    resolve("This is also ignored");
    end = new Date();
  });
}

const promise = getResolvedPromise();
const elapsedSeconds = Math.floor((end - start) / 1000);
console.log("Elapsed seconds: ", elapsedSeconds);
console.log("fin");

// Initiating Promise...
// Elapsed seconds:  7
// fin

Promise.resolve, Promise.reject

Promise.resolve and Promise.reject are static methods of the Promise constructor that can create new Promise objects.

Promise.resolve takes a single, optional argument and returns a Promise resolved with the given argument. If the argument is a Promise object, Promise.resolve returns the argument itself. If the argument is not a Promise, but a thenable (a promise-like object that has a then method), Promise.resolve returns a newly created Promise object that adopts the future outcome of the thenable. If the argument is not a Promise nor a thenable, Promise.resolve returns a Promise object fulfilled with the argument value. If no argument is given, Promise.resolve returns a Promise fulfilled with undefined.

const p1 = Promise.resolve(Promise.resolve("Promise resolved!"));
const p2 = Promise.resolve({
  then: (resolve) => resolve("Thenable resolved!"),
});
const p3 = Promise.resolve("Non-thenable value resolved!");

console.log(p1 instanceof Promise); // true
console.log(p2 instanceof Promise); // true
console.log(p2 instanceof Promise); // true

p1.then(console.log);
p2.then(console.log);
p3.then(console.log);

// Promise resolved!
// Non-thenable value resolved!
// Thenable resolved!

Promise.reject creates a new Promise that is rejected with the given argument. You can pass anything to Promise.reject but passing an Error object is recommended as it can help with debugging and error catching.

const p1 = Promise.reject("Rejected!");
const p2 = Promise.reject(Promise.resolve("Also rejected!"));
const p3 = Promise.reject(new Error("Error!"));

p1.catch(console.log); // Rejected!
p2.catch(console.log); // Promise { 'Also rejected!' }
p3.catch(console.log); // Error: Error! at <anonymous>:3:27

Chaining promises with then()

One of the benefits of using Promises over callbacks is that Promises let you assemble a series of asynchronous operations in a linear fashion. If you were to create a chain of asynchronous operations using async callbacks, you would have to nest the callbacks one after another. In contrast, Promises provides the then() method, which you can use to register callbacks on Promises linearly without nesting callbacks inside the previous one.

then()

The then() method is a property of the Promise object that can register callbacks on a Promise. then() accepts two optional arguments: a callback that will be invoked if the Promise fulfills, and another that will be invoked if the Promise rejects.

The first argument of then() is invoked when the Promise is fulfilled (onFulfilled). The second argument is invoked when the Promise is rejected (onRejected). Both callbacks receive the eventual outcome of the Promise as argument. onFulfilled receives the fulfillment value of the Promise, whereas onRejected receives the rejection reason of the Promise.

const resolvedPromise = Promise.resolve("Resolved!");
const rejectedPromise = Promise.reject("Rejected!");

resolvedPromise.then((value) => console.log("Callback invoked. ", value));
// Callback invoked.  Resolved!
rejectedPromise.then(
  (value) => console.log("Not invoked"),
  (error) => console.log("Callback invoked. ", error),
);
// Callback invoked.  Rejected!

Each invocation of the then() method returns a new Promise. The new Promise object will also have access to then(), which means that another set of callbacks can be registered on the new Promise object. Each then() method invocation will return a new Promise object, creating a linear chain of Promises. The callbacks passed to each then() invocation will be invoked sequentially, in the order in which they were registered.

The Promise object (p1) created by then() resolves with whatever is returned from a callback passed to then(). As we’ve seen previously, a Promise that is resolved is not necessarily the same thing as a Promise that has fulfilled. If a callback passed to then() returns a value that is not a Promise, then p1 fulfills with that value immediately. However, if the callback returns a Promise (p2), then p1 resolves to p2 and begins to track p2’s state. p1 settles to the eventual outcome of p2.

When a callback passed to then() returns a Promise, this Promise is dynamically inserted into the Promise chain. Once the Promise settles, the next callback will receive the fulfillment value or reject reason for this Promise.

const p1 = Promise.resolve("p1");
p1.then((value) => {
  // then() creates promise p2
  // p2 fulfills with 'p1'
  return value;
})
  .then((value) => {
    // then() creates promise p3
    // onFulfilled returns promise p4
    // p3 resolves to p4
    return Promise.resolve("p4");
  }) // p4 is dynamically inserted here
  .then((value) => {
    // this callback receives the fulfillment value of p4
    console.log(value);
    // p4
  });

A then() invocation is synchronous. However, it is imperative to recognize that all callbacks passed to then() are always invoked asynchronously. Even if you register a callback on a Promise that has already settled, the callback will be invoked after all the code that is currently in the event loop is executed. This behavior ensures that your code works in a predictable fashion.

Handling errors with catch()

Another benefit of using Promises is that Promises provide a better way to handle errors thrown by asynchronous operations.

Asynchronous errors are handled differently from errors thrown by synchronous operations. When a synchronous function throws an error, it propagates through the call stack until it finds a catch block. This behavior does not work for asynchronous operations because the caller function would not be in the call stack when the asynchronous operation is executed.

Asynchronous tasks are invoked after the call stack is empty and the code in the current loop of the event loop is fully executed. This means that when an asynchronous function is invoked, its caller will already have been popped off the call stack. As such, if an error occurs during an asynchronous operation, it will not have access to its caller nor the catch block to handle it.

Traditionally, callback error handlers were used to take care of errors thrown by asynchronous operations. However, this was not an ideal solution as every nested callback function required its own error handler function, which exacerbated the mess created by the nested functions.

catch()

Thanks to the introduction of Promises in ES6, we now have a much simpler and more efficient way of handling asynchronous errors. In a chain of Promises, errors propagate through the chain, instead of the call stack. We can use the catch() method available on Promise objects to “catch” the error and deal with it.

The catch() method is another way of expressing then(null, onReject).

const rejectedPromise = Promise.reject("rejected");
rejectedPromise.then(null, (reason) => console.log(reason)); // a
rejectedPromise.catch((reason) => {
  console.log(reason);
}); // b
// a and b are the same code

Although you could certainly pass an error handler function to then(), it is usually more practical to use catch(). catch() can handle

const resolvedPromise = Promise.resolve("resolved");
const rejectedPromise = Promise.reject("rejected");

const onFulfill = () => {
  throw "Error in onFulfill";
};
const onReject = () => console.log("Error handled");

resjectedPromise.then(onFulfill, onReject);
// Error handled
resolvedPromise.then(onFulfill, onReject);
// Uncaught (in promise) Error in onFulfill

resolvedPromise.then(onFulfill).catch(onReject);
// Error handled
rejectedPromise.then(onFulfill).catch(onReject);
// Error handled

Passing onFulfill and onReject as arguments to the then() invoked on rejectedPromise will handle the error without problems. However, onReject can’t handle the error thrown by onFulfill.

This can be avoided by using catch(). By adding catch(onReject) behind resolvedPromise.then(onFulfill), we can handle the error thrown by onFulfill. Moreover, adding catch(onReject) behind rejectedPromise.then(onFulfill) will handle the rejection reason of rejectedPromise.

If then() does not have the appropriate callback to handle a settled Promise, the Promise created by then() will settle with the state of the Promise on which then() was invoked. As such, the Promise returned by rejectedPromise.then(onFulfill) will reject with the string “rejected” (the rejection reason of rejectedPromise). Then, catch(onReject) will receive that rejected Promise and handle the error.

It’s worth remembering that catch() is just a shorthand for then(null, callback). This means that catch() also returns a new Promise object, and it is possible to register then() on that Promise object. You can use catch() to stop the error from propagating down the chain. This means that you can strategically place catch() within the Promise chain to recover from errors and allow the chain to move on even in the case of errors.

const rejectedPromise = Promise.reject("rejected");
const onReject = () => "it's ok!";

rejectedPromise.catch(onReject).then((value) => console.log(value));
// it's ok!

Handling multiple asynchronous operations with Promise.all

Using a Promise chain to carry out asynchronous operations is great but sometimes you need the results of multiple asynchronous operations at once. Promise.all() provides a way to run multiple asynchronous operations in parallel. Promise.all() takes an array of Promises as input and returns a newly created Promise. The new Promise will fulfill with an array of fulfillment values or reject with the rejection reason of the first rejected Promise. Note that the Promise object created by Promise.all() will reject as soon as an input array element rejects, regardless of the state of other input Promises.

Technically, the Promise.all() input array may contain non-Promise values. In such a case, the value becomes the fulfillment value of a resolved Promise object.

The Promise object returned by Promise.all() will resolve asynchronously, as long as the given input is not an empty array. If an empty array is passed to Promise.all(), the newly created Promise will resolve to an empty array synchronously.

const p1 = Promise.all([1, 2, 3]);
const p2 = Promise.all([Promise.resolve("resolved")]);
const p3 = Promise.all([]);

console.log(p1);
// Promise { <pending> }
console.log(p2);
// Promise { <pending> }
console.log(p3);
// Promise { [] }

setTimeout(console.log, 0, p1);
setTimeout(console.log, 0, p2);

// after call stack is empty,
// Promise { [ 1, 2, 3 ] }
// Promise { [ 'resolved' ] }

Common mistakes

There are some common mistakes to watch out for when using Promises.

Not returning from a callback

then() returns a Promise that resolves with the callback function’s return value. The callbacks waiting at the next stage of the chain can access that value to carry out the subsequent operations. If you do not return anything from the callback, the Promise will fulfill with undefined as its fulfillment value. This could cause issues because the Promise object returned by then() could be fulfilled while an asynchronous operation is still running in the background. The callback at the next stage of the chain would begin since the Promise is fulfilled, but it would not know whether the asynchronous operation of the previous stage has been fully executed.

Of course, you might not need anything at the next stage of the chain. However, it is practical to return some value from a callback passed to then() to ensure that your code remains predictable.

Unnecessary nesting

Promises provide a way to escape from the callback hell/pyramid of doom. That being said, it’s easy to mistakenly create a nested chain of Promises that ends up being just as hard to read as the callback/hell.

p1.then(
  () => p2.then(
    () => p3.then(
      () => promise4.then(...)
    )
  )
)

Promise chains that look like this completely miss the point of using Promises in the first place. By returning properly from callbacks, you can create a linear Promise chain that is much easier to read.


References