DEV Community

Cover image for I Promise you won't have to await long to understand async in Javascript
Zack Sheppard
Zack Sheppard

Posted on • Originally published at blog.zack.computer

I Promise you won't have to await long to understand async in Javascript

As you're poking around with modern Javascript, it won't take you long to encounter one of the main asynchronous keywords: Promise, await, or async. So, how do these they work, and why would you want to use them? (And then at the end, some pro-tips for getting the most out of them.)

As with all things in asynchronous programming, we'll answer those questions eventually but the order in which we'll do so is not defined.

async function writeBlogPost() {
  await Promise.all([
    writeHowAsyncWorks(),
    writeWhyAsync().then(() => writeAsyncIsNotMultithreading())
  ])
    .then(() => writeProTips())
    .finally(() => writeConclusion());
}
Enter fullscreen mode Exit fullscreen mode

Why Async?

Since the beginning, Javascript has lived on the internet. This necessarily means that it has had to deal with tasks that could take an indeterminate amount of time (usually calls from your device out to a server somewhere). The way that Javascript dealt with this traditionally has been with "callbacks":

function getImageAndDoSomething() {
  // This is a simplified example, of course, since arrow functions
  // didn't exist back in the day...
  loadDataFromSite(
    // Function argument 1: a URL
    "http://placekitten.com/200/300",
    // Function argument 2: a callback
    (image, error) => {
      // Do something with `image`
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

Callbacks are references to functions that get called when the work is done. Our loadDataFromSite function above will call our callback with image defined if and when it has successfully loaded the data from the target URL. If it fails, it will call our callback with image set to null and, hopefully, error defined.

This works fine when you're dealing with simple "get it and do one thing" loops. However, this can quickly enter callback hell if you need to do multiple chained calls to a server:

function apiCallbackHell() {
  loadData((data, error) => {
    data && transformData(data, (transformed, error) => {
      transformed && collateData(transformed, (collated, error) => {
        collated && discombobulateData(collated, (discombobulated, error) => {
          // And so on...
        })
      })
    })
  })
}
Enter fullscreen mode Exit fullscreen mode

This is a mess! Callback hell like this was the motivation behind the Promise API, which in turn spawned the async/await API. In a moment we'll break down what this is doing, but for now let's just enjoy how clean our function looks with async/await:

async function notApiCallbackHell() {
  const data = await loadData();
  const transformed = await transformData(data);
  const collated = await collateData(transformed);
  const discombobulated = await discombobulateData(collated);
  // And so on...
}
Enter fullscreen mode Exit fullscreen mode

Side Quest: Async is not Multithreaded Javascript

Before we break that down, though, let's clarify one common misconception: async code is not the same as multi-threaded code. At its core, Javascript remains a single-threaded environment.

Under the hood of the language is something called the "event loop", which is the engine responsible for reading in a single instruction and performing it. That loop remains a single threaded process - it can only ever read in one instruction at a time and then move on.

Callbacks and Promises make it look like this loop is doing multiple things at once, but it isn't. Let's imagine the instructions in our code as a pile of cards and the event loop is a dealer, pulling them off the top one-at-a-time and stacking them into a neat deck. If we have no callbacks or Promises then the pile our dealer can pull from is clear: it's just what we have in the program, reading through the lines of code top to bottom.

Adding async code to the mix gives our dealer another pile to pull from - the code in our callback or Promise can be read independently from the instructions in the global scope of our program. However, there is still only one dealer (one thread) and they can still only read through one instruction at a time. It's just that now they share their efforts between the different piles. This means that if you put some very difficult work into a Promise, you'll be creating a very big new pile for your dealer to pull from. This will slow down the execution of your other code, so interactive UI on your screen might get verrrrrry slow as a result.

The solution to this is to move your intense work to another thread - in our metaphor this would be the same as hiring a second dealer to sort through the intense pile of instructions separately from our main dealer. How to do that is beyond the scope of this post, but if you're curious check out Node's Worker Threads or the browser's Web Workers.

What are the pieces here?

So, we've heard of the main three tools in the async/await landscape, but what do they actually do and how do they work?

Promise

The backbone of the async/await toolkit is the Promise type. Promises are objects. They wrap code that does something. Their original purpose was to make it easier to attach callbacks and error handlers to that code. There are several ways to create a promise, but the most basic one is:

new Promise((resolve, reject) => {
  // Do something
  if (itSucceeded) {
    resolve(successResult);
  } else {
    reject(failureReason);
  }
});
Enter fullscreen mode Exit fullscreen mode

Here you can see the core feature of a Promise - it is just a wrapper around callbacks! Inside of the execution block for our new Promise we simply have two callbacks - one we should call if the promise successfully did its work (the resolve callback) and one we should call if it failed (the reject callback).

We then get two functions on the Promise that are the most important:

const somePromise = getPromise();

somePromise
  .then((result) => {
    // Do something with a success
  })
  .catch((rejection) => {
    // Do something with a rejection
  });
Enter fullscreen mode Exit fullscreen mode

then and catch are extremely useful if you've been handed a Promise from some other code. These are how you can attach your own callbacks to the Promise to listen for when it resolves (in which case your then callback will be called with the resolved value) or to handle a failure (in which case your catch callback will be called with the rejection reason, if any).

(Side note there is also a finally which, as you might guess, runs after all the then and catch handlers are finished.)

Then and catch are also useful because they themselves return a Promise now containing the return value of your handler.

So, you can use .then to chain together multiple steps, partly escaping callback hell:

function promisePurgatory() {
  loadData(data)
    .then(data => transformData(data))
    .then(transformed => collateData(transformed))
    .then(collated => discombobulateData(collated))
    .then( /* and so on */ );
}
Enter fullscreen mode Exit fullscreen mode

Async/Await

You might have noticed, though, that Promise doesn't completely get us out of needing a huge stack of callbacks. Sure they are now all on the same level, so we no longer need to tab into infinity. But, the community behind Javascript were sure they could do better. Enter async and its partner await. These two simplify Promise programming tremendously.

First of all is async - this is a keyword you use to annotate a function to say that it returns a Promise. You don't have to do anything further, if you mark a function as async, it will now be treated the same as if you'd made it the execution block inside a promise.

async function doSomeWork() {
  // Do some complicated work and then
  return 42;
}

async function alwaysThrows() {
  // Oh no this function always throws
  throw "It was called alwaysThrows, what did you expect?"
}

const automaticPromise = doSomeWork();
// Without having to call `new Promise` we have one.
// This will log 42:
automaticPromise.then((result) => console.log(result));

const automaticReject = alwaysThrows();
// Even though the function throws, because it's async the throw
// is wrapped up in a Promise reject and our code doesn't crash:
automaticReject.catch((reason) => console.error(reason));
Enter fullscreen mode Exit fullscreen mode

This is by itself pretty useful - no longer do you have to remember how to instantiate a Promise or worry about handling both the reject case and also any throw errors. But where it really shines is when you add in await.

await can only exist inside of an async function, but it gives you a way to pause your function until some other Promise finishes. You will then be handed the resolved value of that Promise or, if it rejected, the rejection will be thrown. This lets you handle Promise results directly without having to build callbacks for them. This is the final tool we need to truly escape callback hell:

// From above, now with error handling
async function notApiCallbackHell() {
  try {
    const data = await loadData();
    const transformed = await transformData(data);
    const collated = await collateData(transformed);
    const discombobulated = await discombobulateData(collated);
    // And so on...
  } catch {
    // Remember - if the Promise rejects, await will just throw.
    console.error("One of our ladders out of hell failed");
  }
}
Enter fullscreen mode Exit fullscreen mode

A couple Pro(mise) Tips

Now that you understand the basics of Promise, async, and await a little better, here's a few Pro Tips to keep in mind while using them:

  1. async and .then will flatten returned Promises automatically. Both async and .then are smart enough to know that if you return a Promise for some value, your end user does not want a Promise for a Promise for some value. You can return either your value directly or a Promise for it and it will get flattened down correctly.

  2. Promise.all for joining, not multiple awaits. If you have several Promises that don't depend on each other and you want to wait for all of them, your first instinct might be to do:

async function waitForAll() {
  // Don't do this
  const one = await doPromiseOne();
  const two = await doPromiseTwo();
  const three = await doPromiseThree();
}
Enter fullscreen mode Exit fullscreen mode

This is going to cause you problems, though, because you're going to wait for promise one to finish before you start promise two, and so on. Instead, you should use the built-in function Promise.all:

async function waitForAll() {
  const [one, two, three] = await Promise.all([
    doPromiseOne(), doPromiseTwo(), doPromiseThree()
  ]);
}
Enter fullscreen mode Exit fullscreen mode

This way your code will create all three promises up front and run through them simultaneously. You're still going to await all three finishing, but it will take much less time because you can spend downtime on promiseOne working on promiseTwo or Three.

  1. Promise.allSettled if failure is acceptable. The downside of Promise.all or serial awaits is that if one of your Promises reject, then the whole chain is rejected. This is where Promise.allSettled comes in. It works the same as Promise.all except that it will wait until all the arguments have resolved or rejected and then pass you back an array of the Promises themselves. This is useful if you're trying to do some work but it's ok if it fails.

  2. Arrow functions can be async too. Last but most certainly not least, it's important to keep in mind that arrow functions can be marked as async too! This is really really useful if you're trying to create a callback handler where you'll want to use await, such as for an onSubmit for a form:

// Imagining we're in react...
return <Form onSubmit={
  async (values) => {
    const serverResponse = await submitValuesToServer(values);
    window.location.href = "/submitted/success";
  }
}>{/* Form contents */}</Form>
Enter fullscreen mode Exit fullscreen mode

.finally(...)

Let me know in the comments down below what questions you now have about Promise, async, and await. Even though I use these three in every Node and React app I write, there are still tons of nuances to learn about them.

If you enjoyed this, please leave me a like, and maybe check out my last "back to basics" article on the ins and outs of this in JS.

Latest comments (6)

Collapse
 
rodrigomoyano11 profile image
Rodrigo Moyano

👏👏👏

Collapse
 
chandrajobs profile image
Venkatraman

Well written Zack

Collapse
 
bchewy profile image
Brian Chew

Thanks for this :)

Collapse
 
krhoyt profile image
Kevin Hoyt

For the title alone … 👏

Collapse
 
artydev profile image
artydev • Edited

Nice thank you
You can give an eye here : Rubico
Regards

Collapse
 
afozbek profile image
Abdullah Furkan Özbek

Thanks for the content, like the title btw.