Like any astute JavaScript developer, you’ve been keeping your eye on the onslaught of new language additions that the TC39 Gods have bestowed upon us humble users. The impact of these range from fundamentally game-changing constructs like block-scoped arrow functions and native promises to minor conveniences like the exponentiation operator.
Yet, there have been few proposals that have caused as much simultaneous excitement and confusion as async functions. To those that understand them, they represent the introduction of truly readable asynchronous code to the JavaScript language. To those that don’t – and I counted myself among them not long ago – the previous sentence reads as Klingon and they revert to the comfort of callbacks and/or promises.
The goal of this blog is to present a practical case for async functions. I will set the historical context for the relevance of these new function types, explain their implicit advantages, and give you my take on their place in the ECMAScript landscape. If you just want to learn about async functions, jump to the good stuff. For a more technical look at async/await and it’s inner workings, check out some of the resources down below. For those that want to take in the full prix fixe menu, let’s dive in.
Asynchronicity
Hearkening back to the early days of the web, JavaScript was born out of the growing necessity to make web pages that were more than just static displays of text and images. From its origin, JS has had first-class functions, meaning that functions can themselves be passed to other functions like any other object. Functions in JavaScript, after all, are really just objects under the covers. This concept would become crucial in later advancements of the language.
One of these major advances was the introduction of Asynchronous JavaScript and XML, or AJAX, requests. These enabled browsers to make requests to the server without reloading the page, in turn receiving the data back at a later time and using it to update the web page. With this addition, JavaScript evolved into a language that masterfully handled asynchronous operations. Personally, I think we owe this to two important constructs of the JavaScript language:
- The Event Loop: JavaScript is (somewhat) unique in that it is single-threaded and non-blocking. This means that only one block of code is executed at a time, with asynchronous operations queued, managed, and executed at a later time by the event loop. That is a topic for its own blog post, but in my opinion, Philip Roberts’ event loop talk at JSCOnf EU 2014 is the holy grail of explainers.
-
Callbacks: Although not unique to JavaScript, these ~~are~~ were crucial to working with asynchronous code and where having first-class functions became key in JavaScript.
Let’s take a closer look at callbacks and the evolving manner in which we’ve handled asynchronicity in JavaScript. To do this, we will use the Chuck Norris API to demonstrate how each pattern helps us complete an asynchronous task.
Callbacks
Remember when we said functions were first-class objects in JavaScript? Here is an example of that functionality in the wild:
1 2 3 |
function conditionalCall(bool, arg, funcA, funcB) { return bool ? funcA(arg) : funcB(arg) } |
In this instance, we are passing four arguments to the conditionalCall
function. A boolean value, an arbitrary argument, and two functions. Based on the truthiness of the boolean value, either funcA
or funcB
is called with arg
as the input. We are only able to do this based on the fact that conditionalCall
can accept functions as arguments just like any other data type.
Building on this pattern, callbacks were conceived as an elegant way of handling asynchronous operations. Functions that contain asynchronous behavior can leverage first-class functions by taking a callback as an argument, invoking it upon completion (or error) of their asynchronous operation. Using our Chuck Norris API and callbacks, it would look something like this:
1 2 3 4 5 6 7 8 9 10 11 |
const request = require('request') request('https://api.chucknorris.io/jokes/random', (err, res, body) => { if (err) { console.error(err) } else { console.log(JSON.parse(body).value) } console.log('RESPONSE RECEIVED') }) console.log('REQUEST SENT') |
Here we fire off an AJAX request to chucknorris.io
, passing in the callback as the second argument to the request
function. This callback function is only invoked when a response has been received. If you note the logged output, the synchronous code is executed well before the callback’s function block.
This pattern was immensely useful in providing a way to interact with functions like request
that operated asynchronously. As its usage evolved, however, weaknesses of the pattern came to the forefront. The following is a non-exhaustive list of some of these shortcomings.
- Callback Hell: The callback pattern is nice, but what happens when you have to make subsequent asynchronous calls that rely on the previous async response? You end up with a clunky pyramid of a codebase that is not only hard to parse but just plain ugly. Or in other words, it’s callbacks all the way down.
1 2 3 4 5 6 7 |
firstFunc(1, (err, res1) => { secondFunc(res1.value, (err, res2) => { thirdFunc(res2.value, (err, res3) => { console.log(`Answer: {res3.value}`) }) }) }) |
2. Error Handling: Callback best practices say to denote an error in an async operation with an error variable as the first parameter of the callback. The user should first check this parameter to see if something went wrong, only proceeding as normal if the input is
null
. Although this works, it departs from the normal try...catch
error handling mechanism and generally just makes code unnecessarily more verbose.
In summation, callbacks were instrumental in JavaScript but introduced syntactical madness. Enter the next stage of the async revolution: the Promise
.
Promises
Promises are a topic in their own right and have their own origin story. They took quite awhile to make their way through the ECMAScript proposal stages, resulting in their implementation in third-party libraries like bluebird.js well before they were native to the language. In order to remain focused, this section will simply cover using (and not creating) native ES6 promises to handle asynchronous functions.
You can think of a promise as an object that is always in one of three states: Pending, Resolved, or Rejected. There are two exposed methods on a promise, called then and catch, respectively used to handle responses and errors. Using this knowledge, let’s walk through how this works:
- A promise is invoked, causing all of it’s synchronous code to be run
- Based on the success of it’s contained asynchronous operation, it is either resolved or rejected
– Resolved: The then method is invoked, passing the result in as the argument
– Rejected: The catch method is invoked, passing the error in as the argument
3. These results can be chained to handle subsequent async requests in an orderly manner.
Here is how our Chuck Norris joke-producing code would look with promises, this time using axios to make the HTTP request:
1 2 3 4 5 6 7 |
const axios = require('axios') axios('https://api.chucknorris.io/jokes/random') .then(res => console.log(res.data.value)) .catch(err => console.log(err)) .then(() => console.log('RESPONSE RECEIVED')) console.log('REQUEST SENT') |
This code should demonstrate that we’ve solved a few of our callback issues. First, error handling is done much more elegantly, as we now have an explicit control flow for handling an error case. It is not perfect, however, as we are still unable to use our beloved try...catch
statement. Perhaps even more important, one might imagine how this solves what we’ve affectionately come to know as callback hell. Let’s take our example from before and reimplement it using promises to demonstrate the improvement:
1 2 3 4 |
firstPromise(1) .then(res1 => secondPromise(res1.value)) .then(res2 => thirdPromise(res2.value)) .then(res3 => console.log(`Answer: {res3.value}`)) |
Not only can we use promises to chain sequential code together, promises returned within a resolved promise’s then
method can themselves be resolved by a subsequent then
method. Easy peasy, right?
Sort of. Once you wrap your head around this pattern and use it in practice, you start to create a lot of boilerplate code simply to enable sequential, asynchronous operations.
Yea verily, we finally have a solution to all this madness: Async functions.
Async Functions
Async functions have come at a time when native promises have become widely adopted by developers. They do not seek to replace promises, but instead improve the language-level model for writing asynchronous code. If promises were our savior from logistical nightmares, the async/await
pattern solves our syntactical woes.
One last time, let’s see what our Chuck Norris example looks like with async functions:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const axios = require('axios'); const getJoke = async () => { try { const res = await axios('https://api.chucknorris.io/jokes/random') console.log(res.data.value) } catch (err) { console.log(err) } console.log('RESPONSE RECEIVED') } getJoke() console.log('REQUEST SENT') |
By simply wrapping our code in an async
-style function, we can utilize asynchronous operations in a naturally synchronous manner. Also, we’ve finally been able to reincorporate our normal JavaScript error handling flow!
Once more, let’s return to our complex example of handling sequential async calls:
1 2 3 4 5 6 |
(async () => { const res1 = await firstPromise(1) const res2 = await secondPromise(res1.value) const res3 = await thirdPromise(res2.value) console.log(`Answer: {res3.value}`) })() |
Although we have what looks at first glance to be a simple series of assignments, we actually have three sequential asynchronous operations, the latter two rely on the previous one’s response. This new syntax is extremely useful for many use cases, but it does not come without its potential pitfalls. We’ll explore these in the final section, but first, let’s check out all of our async/await
plunders!
Why should I use the Async/Await Pattern?
Hopefully, the main benefit of async functions is clear, but there are a few more gains to be had from their usage. Let’s walk through the mains ones.
Synchronous-Looking Code
Async functions take the promises that many of us have come to know and love and give us a synchronous-looking manner in which to use them. When used effectively it creates cleaner code which, in turn, leads to more maintainable code. In the rapidly evolving JS landscape, this notion is evermore important.
This is particularly useful when leveraging sequential operations that rely on intermediate results. Let’s use a more relevant (if not contrived) example to demonstrate this point.
1 2 3 4 5 6 |
getUser('/api/users/123') .then(user => { getUserPassport(`/api/passports/${user.passportId}`) .then(passport => runBackgroundCheck(user, passport)) .then(pass => console.log('check passed:', pass)) }) |
In the above code, we leverage promises to asynchronously retrieve a user, subsequently retrieving their passport information, as well. Only then can we run their background check using the previous two results as arguments to runBackgroundCheck
. Due to scoping constraints, this prevents us from simply chaining the function calls and forces us into a similar pattern to callback hell. Sure, we could create temp variables, or do some trickery with Promise.all
to avoid this, but those are really just band-aids on a lesion. What we really want is a way to store all of our results in the same scope, which async functions allow.
1 2 3 4 5 6 |
(async () => { const user = await getUser('/api/users/123') const passport = await getUserPassport(`/api/passports/${user.passportId}`) const pass = await runBackgroundCheck(user, passport) console.log('check passed:', pass) })() |
Much better!
Promises All the Way Down
In addition to async functions leveraging promises in their composure, they return a promise, as well. This allows for us to do a few neat things:
- We can chain off of an async function…
- Which allows us to mix async functions and promises…
- So that we can refactor existing promise-based functions as async functions, without the need to change how that function was utilized.
Let’s reintroduce the background check example to support this claim:
1 2 3 4 5 |
const axios = require('axios'); function runBackgroundCheck(user, passport) { return axios(`https://us.gov/background?ssn=${user.ssn}&pid=${passport.number}`) .then(res => res.data.result) } |
If we were to refactor this promise-based function using an async function, it would look something like this:
1 2 3 4 5 |
const axios = require('axios'); async function runBackgroundCheck(user, passport) { const res = await axios(`https://us.gov/background?ssn=${user.ssn}&pid=${passport.number}`) return res.data.result } |
In my opinion, this makes the return value of the function much more obvious. This example is trivial, of course, but hopefully, this concept makes you think about potential code refactoring gains that this pattern allows.
Proper Error Handling
One of the downsides of promises is that they forced us to use a unique convention to handle errors instead of leveraging the traditional try...catch
syntax. Async functions give us back the ability to utilize that pattern, while still leveraging promises if we wish.
Using the background check example one more time, let’s handle any errors that may arise during execution:
1 2 3 4 5 6 7 8 9 10 |
async () => { try { const user = await getUser('/api/users/123') const passport = await getUserPassport(`/api/passports/${user.passportId}`) const pass = await runBackgroundCheck(user, passport) console.log('check passed:', pass) } catch (err) { // Handle failure accordingly } } |
No matter how those functions (getUser
et al.) are implemented, either with promises or async/await, runtime and thrown errors will be caught by the wrapping try...catch
block. This is useful as we are no longer required to have a special syntax for rejected promises within an async function.
This pattern also improves error messages and debugging by leveraging the sequential nature of the resultant code. This means that error messages are more reflective of where the error occurred and stepping through code with await
statements becomes possible. I won’t go over these improvements in depth, but this post does a nice job explaining why.
Considerations
You might be asking yourself, should I start using this pattern in my JavaScript development today? The truth is, that depends…
Support
Node.js now supports async/await
by default, as of Node v7.6. That means that async/await
is supported in the current branch, but it will not fall under LTS (currently at v6.x) until Node 8 gets LTS in October 2017.
As far as browsers go, async functions are now supported by all main vendors (sans IE). It must be stated that all browsers’ support was only added this year, so you are potentially limiting yourself by including it in your client code just yet. If you insist on using the pattern, I would recommend working something like Babel’s async-to-generator
transform into your transpilation process before you ship the code. Be wary, though, as I have heard the resultant code is quite bulky when compared to the source. And no one likes a fat bundle.
If you think those risks are worth the upgrade, then go for it brave warrior!
Silent but Deadly Errors
Like promises, errors in async functions go silently into the night if they are not caught. When utilizing this pattern you must be careful to use try...catch
blocks where errors are likely to appear. This is always one of the key oversights I had when debugging issues involving promises and I expect it to be a recurring theme as I continue to use async functions.
Sequential Code Trip-Ups
Although async functions give your code the appearance of synchronicity, you want to avoid actual synchronous (i.e. blocking) behavior where possible. Unfortunately, it is easy for async functions to lull you into this behavior by mistake. Take the following example:
1 2 3 4 5 |
async () => { const res1 = await firstPromise() const res2 = await secondPromise() console.log(res1 + res2) } |
At first glance, this seems fine. We are making two asynchronous calls and using the results of both to compute our logged output. However, if we run through the code, you’ll notice that we are blocking the function’s execution until the first promise returns. This is inefficient as there is no reason these calls can’t be made in parallel. To solve this issue, we just need to get creative and reach into our Promise
toolbelt:
1 2 3 4 |
async () => { const [res1, res2]= await Promise.all([firstPromise(), secondPromise()]); console.log(res1 + res2) } |
By using Promise.all
, we are able to regain concurrency while continuing to leverage our new async/await
pattern. Blocking be gone!
TL;DR
This was a long one. In short:
- Async/await improves code readability
- Async/await gives us synchronous-like syntax for asynchronous behavior
- Async/await can be used with and in place of promises
- Async/await enables
try...catch
error handling for asynchronous operations - Async/await is supported by Node.js and all major browser vendors
- Async/await officially arrives in the ES2018 language spec
Additional Resources
- Code: jakepeyser/async-functions
- Blog: Understanding JavaScript’s async await
- Blog: 6 Reasons Why JavaScript’s Async/Await Blows Promises Away
- Blog: You Need ES2017’s Async Functions. Here’s Why …
- Video: JavaScript Patterns for 2017 – Scott Allen
- Docs: Mozilla Developer Network Specification
- Docs: ECMAScript 2018 Specification
- Tool: hunterloftis/awaiting – An aync/await utility
Jake Peyser
Latest posts by Jake Peyser (see all)
- Where Did Async/Await Come from and Why Use It? - June 14, 2017