Mark Thomas Miller's logo

A less scary explanation of async await

February 13, 2022

There are a lot of explanations of async and await out there, but in my opinion, they're too academic for people who are completely new to the concept. This post is my attempt at a simpler explanation that shows what real world usage looks like. Before we start, here's a quick refresher on the meaning of "async":

Sometimes, we need our code to communicate with another server/the file system/etc. We don't know how long these operations are going to take, so we write the code to be asynchronous – meaning that it's "non-blocking" — that is, other code can continue to run while we wait for the response. Nobody enjoys spelling asynchronous so we abbreviate it as async in the code.

— from my other post, Acing the JavaScript interview

For the purposes of this tutorial, let's assume that we have an async function called getData. It uses JavaScript's global fetch method to make a request to another URL and then log some data to the console. Right now, it looks like this:

function getData() {
  const response = fetch("https://jsonplaceholder.typicode.com/posts");
  console.log(response);
}

The thing is, if you run that in your DevTools console right now, it'll just log Promise {<pending>}.

Ugh, what gives?

Well, you know how when you visit a website, it takes a couple seconds to actually load the page? This fetch request is doing a similar thing — it needs to travel off to some server and retrieve the data because of fetch. In place of that, it "promises" that it'll come back with your data. The way that function is written, response is currently just a "promise".

If we want to log the actual response, we need to tell JavaScript to wait for it. To do this, we write the grandiose-sounding word await before fetch:

const response = await fetch("https://jsonplaceholder.typicode.com/posts");
//               ^^^^^

But we're not done yet. We can only use await inside of functions marked as "async". In other words, we need to tell JavaScript that getData is an asynchronous function. To do this, we write the word async before function:

async function getData() {
// ^^

Sidenote: if getData was an arrow function and you wanted to make it asynchronous, you'd write async before the arguments like const getData = async () => ....

All together, your final (non-arrow) function looks like this:

async function getData() {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts");
  console.log(response);
}

Now, JavaScript will "smartly" know not to log response until the posts are retrieved. You'll often hear frontend developers describe this with the phrase, "when the promise resolves".

Those are the basics, but you can stick around if you want to find out more about using the fetch function. You see, if you run getData right now, it'll return something like:

Response {type: 'cors', url: 'https://jsonplaceholder.typicode.com/posts', redirected: false, status: 200, ok: true, …}

...which is cool and all, but you'll likely want to transform the posts into JSON format to make them more readable. To do that, you can call the .json() method on the response, which returns yet another promise, which you can await:

async function getData() {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts");
  const json = await response.json();
  console.log(json);
}

If you run that function, it'll actually log all of the posts to the console!

That's good enough for many cases, but for the final part of this post, I want to talk about error handling. There are two types of errors that you might want to guard against:

  1. Request failures. For instance, this could happen if you lose your internet connection, if there's a CORS error, or if the server doesn't return data in JSON format.

  2. Server failures. For example, the request might succeed, but the server could reject it and return an error code like:

    {
      "error_code": "auth_required",
      "message": "You must be logged in to do that!"
    }
    

To handle #1, you could wrap your code in a try/catch block. If you haven't seen those before, they work like this:

try {
  // 🍇
} catch (error) {
  // if the code in 🍇 throws an error, this will run
}

Adding a try/catch block to our getData function would look like this:

async function getData() {
  try {
    const response = await fetch("https://jsonplaceholder.typicode.com/posts");
    const json = await response.json();
    console.log(json);
  } catch (error) {
    console.error(error);
  }
}

For #2, If you're expecting an error response via JSON, you could watch the response for the existence of an error_code and add an early return:

async function getData() {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts");
  const json = await response.json();

  if (json.error_code) {
    console.error({ code: json.error_code, text: json.message });
    return;
  }

  console.log(json);
}

Here, we're checking for the existence of an error_code in the response. If one is there, we'll log the error to the console and stop executing getData. You're welcome to handle this part however you want — for instance, instead of logging an error to the console, you might want to change your UI to show the error message to the user, or you might want to trigger different behavior depending on which error_code was returned (like redirecting the user to the login page if they aren't authenticated).

Combining both #1 and #2 would look like this:

async function getData() {
  try {
    const response = await fetch("https://jsonplaceholder.typicode.com/posts");
    const json = await response.json();

    if (json.error_code) {
      console.error({ code: json.error_code, text: json.message });
      return;
    }

    console.log(json);
  } catch (error) {
    console.error(error);
  }
}

Now, you have an asynchronous function that guards against a wide class of errors!

I hope you enjoyed this post. If there's anything that could be explained more clearly, please email me at m@mtm.dev to let me know.