I Promise you won't have to await long to understand async in Javascript
Zack Sheppard
Posted on August 17, 2021
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());
}
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`
}
);
}
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...
})
})
})
})
}
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...
}
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. Promise
s 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);
}
});
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
});
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 */ );
}
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));
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");
}
}
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:
async
and.then
will flatten returnedPromise
s automatically. Bothasync
and.then
are smart enough to know that if you return aPromise
for some value, your end user does not want aPromise
for aPromise
for some value. You can return either your value directly or aPromise
for it and it will get flattened down correctly.Promise.all
for joining, not multipleawait
s. If you have severalPromise
s 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();
}
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()
]);
}
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.
Promise.allSettled
if failure is acceptable. The downside ofPromise.all
or serialawait
s is that if one of yourPromise
s reject, then the whole chain is rejected. This is wherePromise.allSettled
comes in. It works the same asPromise.all
except that it will wait until all the arguments have resolved or rejected and then pass you back an array of thePromise
s themselves. This is useful if you're trying to do some work but it's ok if it fails.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 asasync
too! This is really really useful if you're trying to create a callback handler where you'll want to useawait
, such as for anonSubmit
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>
.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.
Posted on August 17, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.