Get your team started on a custom learning journey today!
Our Boulder, CO-based learning experts are ready to help!
Get your team started on a custom learning journey today!
Our Boulder, CO-based learning experts are ready to help!
Follow us on LinkedIn for our latest data and tips!
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.
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:
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.
Remember when we said functions were first-class objects in JavaScript? Here is an example of that functionality in the wild:
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:
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.
firstFunc(1, (err, res1) => { secondFunc(res1.value, (err, res2) => { thirdFunc(res2.value, (err, res3) => { console.log(`Answer: ${res3.value}`) }) }) })
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 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:
– 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:
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:
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?
Yea verily, we finally have a solution to all this madness: 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:
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:
(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!
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.
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.
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.
(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!
Let’s reintroduce the background check example to support this claim:
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:
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.
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:
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.
You might be asking yourself, should I start using this pattern in my JavaScript development today? The truth is, that depends…
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!
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.
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:
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:
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!
This was a long one. In short:
try...catch
error handling for asynchronous operationsCustomized Technical Learning Solutions to Help Attract and Retain Talented Developers
Let DI help you design solutions to onboard, upskill or reskill your software development organization. Fully customized. 100% guaranteed.
DevelopIntelligence leads technical and software development learning programs for Fortune 500 companies. We provide learning solutions for hundreds of thousands of engineers for over 250 global brands.
“I appreciated the instructor’s technique of writing live code examples rather than using fixed slide decks to present the material.”
VMwareDevelopIntelligence has been in the technical/software development learning and training industry for nearly 20 years. We’ve provided learning solutions to more than 48,000 engineers, across 220 organizations worldwide.
Thank you for everyone who joined us this past year to hear about our proven methods of attracting and retaining tech talent.
© 2013 - 2022 DevelopIntelligence LLC - Privacy Policy
Let's review your current tech training programs and we'll help you baseline your success against some of our big industry partners. In this 30-minute meeting, we'll share our data/insights on what's working and what's not.
Training Journal sat down with our CEO for his thoughts on what’s working, and what’s not working.