DEV Community

Divya
Divya

Posted on

The journey to async/await

Async/Await is here, or at least has been for a while now. Regardless, a large portion of the web has yet to migrate and many codebases today continue to use callbacks. At the risk of coming being prescriptive, async/await is an incredibly intuitive way of working with asynchronous JavaScript compared to callbacks. Let's dive into what makes async/await so great and how we can make the migration away from callbacks a breeze.

The coming of Async JS

In the beginning, JavaScript was purely synchronous, meaning that code could only execute in sequence. For a time, this meant that code had to be written sequentially from top to bottom. As time went on and JavaScript was used for more complex operations, this paradigm became limiting since any action that occurred would block the main thread and result in a "janky" user experience. The introduction of AJAX/XHR operations in 2005 brought with it asynchronous capabilities to JS. This allowed for making requests to a server without blocking the main thread.

With async JS, also came the introduction of the almighty callback. A callback is a simple function that is passed as an argument to another function and will only execute when the "parent" function it is contained within returns. With a callback, you can always be sure of the order of operations of your code.

An event listener is a common example of an asynchronous operation in JavaScript that uses callbacks.

document.querySelector("button").addEventLister("click", () => {
  // run a function //
});

Another common example of this is the setTimeout function, which allows you to execute a function after a specific amount of time has elapsed.

setTimeout(() => {
  // run some function after 5s
}, 5000);

They call it Callback Hell

Callbacks are an excellent way to declare an action that has to happen after a specific operation has completed. What makes callbacks so notorious however is the complexity that comes with chaining a sequence of requests and/or trying to return data from asynchronous functions.

Let’s say we’re trying to access the Google API. To do so, we’ll have to authenticate the request using oauth and use the generated access token to make a request to the relevant API service. Because the events have to happen in a particular sequence, a callback is necessary. In a fetchData function, we’ll start by creating a google auth instance [a]. We’ll then pass in a specific code generated from Google’s oauth process—more on that here— to a getToken method that is accessible on the auth instance [b]. Once the access token has been created from the getToken method, we’ll grab the token, set it on the oauth instance [c] and finally run the callback [d].

const { google } = require("googleapis");

function fetchData(code, callback) { // a
  const { CLIENT_SECRET, CLIENT_ID, REDIRECT_URIS } = process.env;
  const oAuth2Client = new google.auth.OAuth2(
    `${CLIENT_ID}`,
    `${CLIENT_SECRET}`,
    `${REDIRECT_URIS}`
  );
  oAuth2Client.getToken(code, (err, token) => { // b
    if (err) return console.error("Error retrieving access token", err);
    oAuth2Client.setCredentials(token); // c
    callback(oAuth2Client); // d
  });
}

Though this may seem straight forward at first glance, ensuring that the fetchData function returns the data from the Google API requires finagling the data flow. The current set up that we have above, while valid, returns undefined. This happens because by definition, callbacks never return a value, they simply execute the callback with a given result. Fixing our function with the existing callback flow is therefore not possible. womp womp

One way that we can ensure that our asynchronous function returns a value is by making sure that all functions wrapping it are also asynchronous. This is where promises come in handy.

Promises

Promises are defined as “a proxy for a value that will eventually become available”. Because of their asynchronous nature, they are great for handling values that return from asynchronous functions. A promise generally has three states, pending, resolved, or rejected. When first called, a promise will start in a pending state, and on receiving a return value from the function it wraps, it either resolves or rejects and calls the callback functions then and catch respectively. Here’s an example of what a promise looks like with the above-mentioned example of setTimeout.

new Promise((resolve, reject) => {
  setTimeout(() => {
  // run some function after 5s
  resolve('some value')
  }, 5000);
})

The only change we made is to wrap our setTimeout in a promise and add a line to resolve the function with some value. If we were to call the above function, we will now be able to grab the return value and log it out to the console if we so choose. An added feature of promises is that error handling is more intuitive. Instead of having to log errors to the console without a clear sense of where the error originates, promises allow for bubbling errors up and handling them appropriately.

function timeout() {
  return new Promise((resolve, reject) => {
    setTimeout(() => { ... })
  })
}

timeout()
  .then(res => {
    console.log(res)
  })
  .catch(err => {
    console.error(err)
  })

Adapting promises to our given Google API is easy. All we have to do is wrap our asynchronous function call to getToken in a promise. This way, we can make sure that our overall function to fetchData actually returns a value that we can then use.

function fetchData (code, callback) {
  const { CLIENT_SECRET, CLIENT_ID, REDIRECT_URIS } = process.env;
  oAuth2Client = new google.auth.OAuth2(
      `${CLIENT_ID}`,
      `${CLIENT_SECRET}`,
      `${REDIREC_URIS}`
  );
  return new Promise((resolve, reject) => {
      return oAuth2Client.getToken(code, (err, token) => {
        if (err) reject(err);
        oAuth2Client.setCredentials(token);
        console.log(callback(oAuth2Client));
        resolve(callback(oAuth2Client));
      })
  })
}

Let’s add extra complexity to our example to put promises to the test. Assume that the callback that we make reference to in our fetchData function is also an asynchronous operation that uses promises. Our call to callback will now have callbacks attached to them to capture either a success or an error state. As the number of consecutive asynchronous function calls grows, so does the complexity of this once humble function.


function fetchData (code, callback) {
  const { CLIENT_SECRET, CLIENT_ID, REDIRECT_URIS } = process.env;
  oAuth2Client = new google.auth.OAuth2(
      `${CLIENT_ID}`,
      `${CLIENT_SECRET}`,
      `${REDIREC_URIS}`
  );
  return new Promise((resolve, reject) => {
      return oAuth2Client.getToken(code, (err, token) => {
        if (err) reject(err);
        oAuth2Client.setCredentials(token);
        console.log(callback(oAuth2Client));
        callback(oAuth2Client)
          .then(res => { resolve(res) })
          .catch(err => { reject(err) })
      })
  })
}

Async/Await

As the node documentation on promises explains, async/await is a combination of promises and generators, and are basically a higher level abstraction over promises. Async is used to define a function as asynchronous and await declares the inner function that the wrapping outer async function must wait for.

Because async/await still uses promises, let’s reuse our earlier promisified setTimeout example. However, instead of using the then and catch callbacks to grab return values, let’s use async/await. It’s worth mentioning here that prepending any function with async will always return a promise. So to turn this function into one that uses async/await, we can simply prepend the function with the async keyword.

We’ll go one step further and wrap our function call in a try…catch block so we can appropriately handle errors when they happen. Tada!


async function timeout() {
  return new Promise((resolve, reject) => {
    setTimeout(() => { ... })
  })
}

try {
  const time = timeout()
  console.log(time)
} catch(err) {
  console.error(err)
}

Now that we have somewhat of a grasp on async/await, let’s refactor our Google API code to use it. We’ll start by defining our calling function as async and then within it we’ll prepend our oauth calls to getToken and setCredentials with await. This allows us to promisify our async oauth calls and make sure that the main function will only return when all promises within it have either been resolved or rejected. We’ll also wrap our oauth calls in a try…catch block to make sure that any errors are caught as soon as they occur.

async function fetchData(code, callback) {
    const { CLIENT_SECRET, CLIENT_ID, REDIRECT_URIS } = process.env;
    oAuth2Client = new google.auth.OAuth2(
      `${CLIENT_ID}`,
      `${CLIENT_SECRET}`,
      `${REDIREC_URIS}`
    );
    try {
      let token = await oAuth2Client.getToken(code);
      await oAuth2Client.setCredentials(token.tokens);
      return callback(oAuth2Client)
    } catch (e) {
      return console.error("Error retrieving access token", e);
    }
}

Async/Await is here to stay

As you’ve seen from the above examples, moving to async/await from callbacks doesn’t have to be hard. Though working with async/await requires somewhat of a paradigm shift from callbacks, the benefits that async/await brings in terms of more intuitive error handling, and easier debugging makes the argument for making the switch a compelling one.

Top comments (2)

Collapse
 
shimphillip profile image
Phillip Shim

Nice article, love your examples too.

Collapse
 
shortdiv profile image
Divya

Thanks for your kind feedback! :)