Converting JS Promise chain to async/await

One of the more nuanced areas of JavaScript is the concept of Promises, which are exactly what they claim to be: A Promise to represent a value sometime in the future.

JavaScript code does not wait for a task to finish as it works through its thread of execution. As a result, we can say that JavaScript is a synchronous language – it never goes back up when executing code.

As Turbo Man always says: “Always keep your promises if you want to keep your friends”

A quick refresher on Promises

A few months back I wrote this post about Promises and went into detail about what’s happening behind the scenes when a Promise is first created. Essentially, when created, a promise will immediately return a plain JavaScript object with a few blank/empty properties. Those properties being value, onFulfillment[], onRejection[].

value is immediately undefined.

onFulfillment is an empty array – we use the .then() method to push any functions we want to execute when the asynchronous task has completed successfully.

onRejection is an empty array – We use the .catch() method to push any functions we want to execute when the asynchronous task has failed and caused an error.

Thanks to JavaScripts microtask queue in conjunction with the call stack, the functions that are pushed to either onFulfillment or onRejection will not run until the call stack is empty.

A Traditional Promise Chain Example

Let’s take a look at a traditional Promise chain in which we will use node-fetch to hit an API and simply return its result to the console:

import fetch from "node-fetch";

fetch("https://cdn.jsdelivr.net/gh/akabab/superhero-api@0.3.0/api/id/1.json")
  .then((response) => {
    return response.json();
  })
  .then((data) => {
    console.log(data);
  })
  .catch((error) => {
      console.log(error)
  })

It’s somewhat verbose, isn’t it? But taking into account what was discussed above about what each .then() call is doing, and what each .catch() call is doing, along with the interaction between the microtask queue and the call stack, it’s really not too difficult to unpack what’s going on.

We invoke fetch() with the API URL as its argument, we then define two arrow functions to push into the microtask queue: The first function (The argument of the first .then() call) parses the response as JSON, the second function (the argument for the second .then() call) receives that parsed JSON and outputs it to the console. Finally, in the event of an error, we created a function that will simply take the error and display it to the console (This function is the argument given to the .catch() method).

Not bad – Roughly 12 lines of code, but there is an even easier way: Enter async/await

Async/Await

In 2017 (ES8) async/await was introduced into the language spec and one of the main goals behind this feature is to make writing, maintaining, and understanding asynchronous code a little easier. Let’s replicate the above example – we’ll hit an API, parse the response, and finally display it to the console.

const heroResponse = await fetch("https://cdn.jsdelivr.net/gh/akabab/superhero-api@0.3.0/api/id/1.json");
const heroData = await heroResponse.json();
console.log(heroData);

Thanks to async/await, In just three lines of code we did what used to take us about a dozen. The await keyword is taking what would have previously been assigned to the promise’s undefined value and instead assigns it to whatever is to the left of the equals sign (assignment) – It’s simply a further abstraction to the traditional .then() promise chain.

What about error handling?

This is a good question. Usually when implementing the new async/await API, you’ll also want to pair it with a try/catch block. A try/catch block literally means “First, try running this code. If for some reason you encounter an error, catch it, and then run this code with whatever error you caught as an argument”.

Let’s add it to the above example:

  try{ 
    const heroResponse = await fetch("https://cdn.jsdelivr.net/gh/akabab/superhero-api@0.3.0/api/id/1.json");
    const heroData = await heroResponse.json();
    console.log(heroData);
  } catch (error) { 
      console.log(error)l
  }

What about the keyword ‘async’?

Another good question. In the above two async/await examples the async keyword was not even used once. So why is it called async/await then? If the code we’re writing is in the global execution context, then it is implicitly async code and we can use await without it.

However, if we were to take the above code and create a new function so we can use it over and over again, then we would have to use the async keyword. Here’s how we would bring it all together:

const getHeroInfo = async () => {
  try {
    const heroResponse = await fetch("https://cdn.jsdelivr.net/gh/akabab/superhero-api@0.3.0/api/id/1.json");
    const heroData = await heroResponse.json();
    console.log(heroData);
  } catch (error) {
    console.log(error);
  }
};

Notice at the top of the arrow function’s definition (Literally in between the equals signs and the set of open/close parenthesis where we’d define our parameters) the keyword async is being used. This tells JavaScript that the code within this function is asynchronous and to treat it accordingly.

Even in 2023, about 5 years after async/await was introduced to the language, you will still see a mix of both .then() chaining and async/await syntax in existing code bases. For the most part, it boils down to personal preference, as well as what the rest of your team is doing/comfortable with.