308A.3 - Promises and Asynchronous JavaScript


Learning Objectives

By the end of this lesson, learners will be able to:

  • Describe the use of Promises.
  • Create Promises.
  • Use Promises together with the then(), catch(), and finally() methods.
  • Use async/await to create asynchronous functions with synchronous behavior.

Asynchronous JavaScript

We've hinted at the asynchronous capabilities of JavaScript in a few lessons up to this point, but now we'll begin diving into the world of async.

Asynchronous code, in JavaScript, is code which does not block the execution of further logic while it is "waiting" to complete, such as the setTimeout() function on a most basic level. This also includes calls to external APIs, resource fetching from databases, and callback functions.

To remind yourself of how these asynchronous tasks are handled by JavaScript, reference the lesson on the Call Stack and Event Loop. To demonstrate your understanding, write down what you think the following code block will log to the console, and then briefly discuss why as a class:

console.log("One");

setTimeout(() => console.log("Two"), 0);

console.log("Three");

It is very important to understand why this works as it does, even with a "zero-delay" asynchronous function.


Promises

In order to discuss some of the other useful asynchronous tools, we need to first cover promises.

The Promise object represents the eventual completion (or failure) of an asynchronous operation and its resulting value. Since the value of an asynchronous action isn't necessarily known when it is assigned to a variable, a Promise can occupy that space in the meantime. The asynchronous action is literally returning the promise to give a final value at some point in the future.

This allows asynchronous methods to return values as if they were synchronous, which we will discuss shortly.

In the vast majority of cases, you will consume premade promises, but you can also create them. We will discuss the creation of promises later.

A Promise can only be in one of three states:

  • pending: initial state, neither fulfilled nor rejected.
  • fulfilled: meaning that the operation was completed successfully.
  • rejected: meaning that the operation failed.

When the state of a promise changes to either fulfilled or rejected, it is considered to be "settled." Similarly, a "resolved" promise is one that has settled or matched to the eventual state of another promise, and further action upon it will have no effect.


Chaining Promises

One of the key features of promises are their ability to be chained together via .then(), .catch(), and .finally().

  • The then() method takes up to two arguments, the first being a callback function for if the promise is fulfilled, and the second being a callback function for if it is rejected.
  • The catch() method runs when a promise is rejected.
  • The finally() method runs when a promise is settled, allowing you to avoid duplicating logic in both the then() and catch() handlers.

Each of these methods return promises themselves, allowing them to be chained indefinitely. When working with asynchronous actions, this enables clean, readable, efficient code. Take the following very simple example:

// Create a Promise that resolves after one second.
const myPromise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve("Guess this worked!");
    }, 1000);
});

// Add some then() methods to handle additional tasks.
myPromise
    .then((x) => x + ' Again?')
    .then((x) => x + ' Third time!')
    .then((x) => x + ' Promises are cool.')
    .catch((err) => {
        console.error(err);
    })

Note that we have omitted the second argument (the callback function for rejected promises) in each then() statement. Since catch() handles rejected promises for us, we can leave those out and simply handle rejections at the end of our chain. That being said, sometimes you will want to handle errors immediately as they occur. Be sure to choose the logic structure that works best for your needs each time.

It is important to note that you must always return results from your promise chains, otherwise the callbacks won't know the result of a previous promise. When a promise is started but not returned, it is said to be "floating," and there is no way to track its settlement.

Here is a reference image from MDN documentation that shows how promises behave when they are chained like this:

How would that code have looked without promises? Back in the old days, using many asynchronous callbacks in a row would lead to a nested callback structure that might have looked something like this. This and the next few examples are taken from the MDN documentation on using promises.

doSomething(function (result) {
  doSomethingElse(result, function (newResult) {
    doThirdThing(newResult, function (finalResult) {
      console.log(`Got the final result: ${finalResult}`);
    }, failureCallback);
  }, failureCallback);
}, failureCallback);

Imagine if there were ten callbacks instead of only three...


Nesting Promises and catch() Tips

You can also nest promises within one another, but this is a dangerous practice. Most often, nesting is used to limit the scope of catch() statements, since a nested catch only catches failures within its scope and below. This can increase error-handling precision.

Here's an example of this:

doSomethingCritical()
  .then((result) =>
    doSomethingOptional(result)
      .then((optionalResult) => doSomethingExtraNice(optionalResult))
      .catch((e) => {}),
  ) // Ignore if optional stuff fails; proceed.
  .then(() => moreCriticalStuff())
  .catch((e) => console.error(`Critical failure: ${e.message}`));

Note that the optional steps here are nested — with the nesting caused not by the indentation, but by the placement of the outer parentheses around the steps.

The inner error-silencing catch handler only catches failures from doSomethingOptional() and doSomethingExtraNice(), after which the code resumes with moreCriticalStuff(). Importantly, if doSomethingCritical() fails, its error is caught by the final (outer) catch only, and does not get swallowed by the inner catch handler.

You can also chain then() statements after a catch(), which allows you to continue new tasks even after an action within the chain has failed.

Here's another example:

new Promise((resolve, reject) => {
  console.log("Initial");

  resolve();
})
  .then(() => {
    throw new Error("Something failed");

    console.log("Do this");
  })
  .catch(() => {
    console.error("Do that");
  })
  .then(() => {
    console.log("Do this, no matter what happened before");
  });

Since the initial then throws an error, it will never log "Do this." That error will be caught by the catch statement, at which point the next then will execute. The resulting logs will be:

Initial
Do that
Do this, no matter what happened before

Error Handling with Promises

In the example with nested callbacks earlier, we needed to call failureCallback with each nested function, as opposed to only once with catch at the end of a promise chain:

doSomething()
  .then((result) => doSomethingElse(result))
  .then((newResult) => doThirdThing(newResult))
  .then((finalResult) => console.log(`Got the final result: ${finalResult}`))
  .catch(failureCallback);

Whenever an exception is thrown, JavaScript searches the promise chain for catch() handlers or an onRejected callback. This should look very familiar, as it is similar to how synchronous code handles errors with try...catch.

try {
  const result = syncDoSomething();
  const newResult = syncDoSomethingElse(result);
  const finalResult = syncDoThirdThing(newResult);
  console.log(`Got the final result: ${finalResult}`);
} catch (error) {
  failureCallback(error);
}

Promises solve a fundamental flaw with the nested callback structure seen earlier by catching all errors, even thrown exceptions and programming errors. This is essential for functional composition of asynchronous operations.

We'll return to these examples after the section on async/await, which is later in the lesson. async/await allow you to make promises resemble synchronous code even more closely.


Composition Tools

Promise provides four tools for running asynchronous operations concurrently, called "composition tools."

The first, Promise.all, allows us to start several asynchronous operations at the same time, and wait for them all to finish before executing a then:

Promise.all([func1(), func2(), func3()]).then(([result1, result2, result3]) => {
  // use result1, result2 and result3
});

If any of the promises in the provided array rejects, the returned promise is rejected and all other operations are aborted. As an alternative, Promise.allSettled() -- the second compositional tool -- has similar behavior, but waits for all operations to complete before resolving.

You can also create a sequence of promises by using some clever JavaScript:

[func1, func2, func3]
  .reduce((p, f) => p.then(f), Promise.resolve())
  .then((result3) => {
    /* use result3 */
  });

The array of asynchronous functions is reduced to a promise chain, which is equivalent to:

Promise.resolve()
  .then(func1)
  .then(func2)
  .then(func3)
  .then((result3) => {
    /* use result3 */
  });

You should always consider if you need promises to run sequentially or not. Running promises concurrently is more efficient when they do not depend on each other's results, as it avoids unnecessary blocking between the promises.

The opposite of Promise.all is Promise.any, which returns a single Promise that fulfills when any of the input's promises fulfill, with the first fulfillment value. It only rejects if all of the input promises reject, and throws an AggregateError containing an array of rejection reasons.

const promise1 = Promise.reject(0);
const promise2 = new Promise((resolve) => setTimeout(resolve, 100, 'quick'));
const promise3 = new Promise((resolve) => setTimeout(resolve, 500, 'slow'));

const promises = [promise1, promise2, promise3];

Promise.any(promises).then((value) => console.log(value));

The final compositional tool, Promise.race, returns a single Promise that settles with the eventual state of the first input promise that settles.

const promise1 = new Promise((resolve, reject) => {
  setTimeout(resolve, 500, 'one');
});

const promise2 = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, 'two');
});

Promise.race([promise1, promise2]).then((value) => {
  console.log(value);
  // Both resolve, but promise2 is faster
});
// Expected output: "two"

Creating Promises

You can manually create promises using the Promise constructor.

The Promise constructor takes an executor() function that has parameters resolveFunc and rejectFunc, which are callbacks for the resolved and rejected cases of the promise. Most of the time, you will see these named resolve and reject in practice.

Here's a basic example:

const myFirstPromise = new Promise((resolve, reject) => {
  // We call resolve(...) when what we were doing asynchronously was successful, and reject(...) when it failed.
  // In this example, we use setTimeout(...) to simulate async code.
  // In reality, you will probably be using something like XHR or an HTML API.
  setTimeout(() => {
    resolve("Success!"); // Yay! Everything went well!
  }, 250);
});

myFirstPromise.then((successMessage) => {
  // successMessage is whatever we passed in the resolve(...) function above.
  // It doesn't have to be a string, but if it is only a succeed message, it probably will be.
  console.log(`Yay! ${successMessage}`);
});

One of the most common reasons to create promises is to handle errors that are not handled by traditional asynchronous functions. As an example, let's look at the setTimeout() method:

setTimeout(() => saySomething("10 seconds passed"), 10 * 1000);

If the function saySomething() throws an error, nothing catches it. The best practice to handle these kinds of situations, when encountered, is to wrap the low-level callback-accepting functions in a promise, and then never call those functions directly again.

For example, we could create a new function wait() that wraps setTimeout() in a promise, and from then on we would use wait() instead of setTimeout():

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

wait(10 * 1000)
  .then(() => saySomething("10 seconds"))
  .catch(failureCallback);

Since setTimeout() doesn't really fail, we can leave out the reject portion of the Promise constructor. However, if we needed to add a rejection case, we could do so like this:

const wait = (ms) => new Promise((resolve, reject) => {
    try {
        setTimeout(resolve, ms);
    } catch (e) {
        reject(e);
    }
});

wait(10 * 1000)
  .then(() => saySomething("10 seconds"))
  .catch(failureCallback);

Building promises in this way can get quite complex, depending on the desired outcome. For further research, reference the MDN documentation on the Promise constructor if and when you find a use case for it.


Promises and the Event Loop

Remember our setTimeout() example from the beginning of the lesson?

console.log("One");

setTimeout(() => console.log("Two"), 0);

console.log("Three");

Using what you know about the event loop, how would you expect the following code to behave?

Promise.resolve().then(() => console.log(2));
console.log(1);

The initial promise is already resolved, so shouldn't it immediately console.log(2)? No. Like any other asynchronous task, the promise is put into the task queue and is only pushed to the execution stack when the stack is empty. This means the above code logs "1, 2."

There is, however, one very important difference in the way promises are handled within the event loop. Take a moment to analyze the following code, and write down your predictions for what you expect it to output. Don't test the code just yet.

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

wait(0).then(() => console.log("Cat"));

Promise.resolve()
  .then(() => console.log("Dog"))
  .then(() => console.log("Cow"));

console.log("Bird");

Don't test the code just yet.

The task queue within the event loop is actually only one of two queues in this scenario. While traditional callback functions, event callbacks, timeouts, and intervals are added to the task queue that we know and love, promises are added to a microtask queue.

Actions in the task queue are pushed to the stack once per iteration of the event loop. Microtasks, on the other hand, can be run multiple times during a single iteration of the loop. Each time a task exits, the event loop checks to see if the task is returning control to other JavaScript code. If it isn't, it will run all of the microtasks in the microtask queue.

If a microtask adds more microtasks to the queue, those newly-added microtasks are executed before the next task is run. The event loop continues calling microtasks until the microtask queue is emptied, even if more keep getting added.

While it is possible to manually add microtasks through queueMicrotask(), it is beyond the scope of this lesson.

Understanding that promises are microtasks, and therefore have higher priority than other callbacks, let's review the coding challenge once more, included below for convenience:

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

wait(0).then(() => console.log("Cat"));

Promise.resolve()
  .then(() => console.log("Dog"))
  .then(() => console.log("Cow"));

console.log("Bird");

Here's how this breaks down:

  • console.log("Bird") is put onto the call stack, since it is synchronous code.
  • console.log("Cat") is wrapped in a promise, so it goes on the microtask queue.
  • console.log("Dog") and console.log("Cow") are part of a promise, so they go on the microtask queue.

So it should log "Bird, Cat, Dog, Cow," right? Not quite.

  • console.log("Cat") has a call to setTimeout() within its promise.
  • While the promise portion gets put into the microtask queue, it then calls setTimeout() which is put into the task queue.
  • Since the microtask queue continues to empty, the action created by setTimeout() executes last in this case.

The expected output is: "Bird, Dog, Cow, Cat." Test it for yourself!

Understanding how promises interact with the event loop can be crucial for avoiding and solving some of the more difficult bugs that may occur in code when using promises and other types of asynchronous logic.


Further Learning

Promises are powerful tools. As a developer, your mileage with promises may vary. Some applications make extensive use of Promises, while others use them in very shallow implementations or not at all.

One of the major use cases for Promises (and async/await), is with communications to external APIs. The next lesson will cover APIs and data fetching, which will give you further insight on how promises can be used in practice.

For further research on promises, visit the MDN documentation on the Promise object or Using Promises.


Thenables

All Promise-like objects implement the Thenable interface. A thenable implements the .then() method, which is called with a callback for resolution and one for rejection. This means that promises are thenables, but not all thenables are promises.

Creating a thenable is simple; it is any object with a .then() method that accepts optional onFulfilled and/or onRejected callbacks. For example, here is a very simple thenable:

const thenable = {
    then: function(onFulfilled) {
        setTimeout(() => onFulfilled("Hey"), 100);
    }
};

Some thenables have existed long before the implementation of promises in JavaScript, but in order to work with the existing implementation of Promise, JavaScript allows the use of thenables in place of promises.

The Promise.resolve() static method, as an example, will not only resolve promises, but will also trace thenables. If the value is a promise, that promise is returned, but if the value is a thenable, it will call the then() method with the two prepared callbacks. You can use this method to convert an arbitrary thanable into a promise, or to chain thenables with an arbitrary promise.

const thenable = {
    then: function(onFulfilled) {
        setTimeout(() => onFulfilled("Hey"), 100);
    }
};

const p = Promise.resolve(thenable);
console.log(p instanceof Promise); // true

Promise.resolve()
    .then(() => thenable)
    .then(val => console.log(val)); // Hey

You will likely run into thenables during your time in development, and it is important to understand that they are not "fully-fledged" promises. As always, refer to the documentation of whatever tools and libraries you are working with to understand their implementation.


Async and Await

Two of the most important keywords in asynchronous JavaScript are async and await.

async is used to declare an async function, which can contain zero or more await expressions. Async functions always return a promise, even if the return value is not explicitly defined as one. Any returns will always be wrapped in a Promise.resolve(), or Promise.reject() if an exception is thrown or uncaught within the function.

For example, these two functions are identical in behavior:

async function example() {
    return "Hello";
}

function example2() {
    return Promise.resolve("Hello");
}

await expressions suspend execution of the code that follows until their associated promises are settled. This allows asynchronous actions to behave as though they were synchronous, since the code that follows the await expression will not execute until the code before it has completed its task. The resolved value of the promise is treated as a returned value from the await expression.

Code after each await expression can be thought of as existing within a .then() callback function. await allows us to "chain" asynchronous logic without actually chaining it.

For example:

function resolveAfterSeconds(t) {
    const myPromise = new Promise(resolve => {
        setTimeout(() => {
            resolve('Done!');
        }, t * 1000);
    });

    return myPromise;
}

async function testAwait() {
    console.log('Testing...');

    const result = await resolveAfterSeconds(2);

    console.log(result);
}

testAwait();

If you run this code in your browser's console, you'll see that we log "Done!" after a period of two seconds.

Test to see what happens when you remove the await keyword.

By using async and await, we can create synchronous logic with asynchronous actions. This becomes incredibly important when communicating with external servers and APIs that may or may not return responses in a timely manner, causing your code to execute out of the order you intend it to.


Error Handling with async/await

Let's take a moment to revist the Promise error handling examples we gave earlier:

doSomething()
  .then((result) => doSomethingElse(result))
  .then((newResult) => doThirdThing(newResult))
  .then((finalResult) => console.log(`Got the final result: ${finalResult}`))
  .catch(failureCallback);

Whenever an exception is thrown, JavaScript searches the promise chain for catch() handlers or an onRejected callback. This should look very familiar, as it is similar to how synchronous code handles errors with try...catch.

try {
  const result = syncDoSomething();
  const newResult = syncDoSomethingElse(result);
  const finalResult = syncDoThirdThing(newResult);
  console.log(`Got the final result: ${finalResult}`);
} catch (error) {
  failureCallback(error);
}

Now, with async and await, we can see how asynchronous code can truly mimic the synchronous logic that we are used to:

async function foo() {
  try {
    const result = await doSomething();
    const newResult = await doSomethingElse(result);
    const finalResult = await doThirdThing(newResult);
    console.log(`Got the final result: ${finalResult}`);
  } catch (error) {
    failureCallback(error);
  }
}

It builds on promises — for example, doSomething() is the same function as before, so there's minimal refactoring needed to change from promises to async/await.


Working with Promises and async/await

In order to solidify your understanding of promises and the async/await keywords, complete the following lab activity.

ALAB 308A.3.1 - Promises and Async/Await

If you have any questions or get stuck on a particular problem, first reference the available documentation, then consult with one or more of your peers, then speak with your instructors.

Copyright © Per Scholas 2024