Eric Windmill
I originally published this article on CSS Tricks.
ES2017 was finalized in June, and with it came wide support for my new favorite JavaScript feature: async functions! If you’ve ever struggled with reasoning about asynchronous JavaScript, this is for you. If you haven’t, then, well, you’re probably a super-genius.
Async functions more or less let you write sequenced JavaScript code, without wrapping all your logic in callbacks, generators, or promises. Consider this:
function logger() {
let data = fetch('http://sampleapi.com/posts')
console.log(data)
}
logger()
This code doesn’t do what you expect. If you’ve built anything in JS, you probably know why.
But this code does do what you’d expect.
async function logger() {
let data = await fetch('http://sampleapi.com/posts')
console.log(data)
}
logger()
That intuitive (and pretty) code works, and its only two additional words!
Before we dive into async and await, it’s important that you understand promises. And to appreciate promises, we need go back one more step to just plain ol’ callbacks.
Promises were introduced in ES6, and made great improvements to writing asynchronous code in JavaScript. No more “callback hell”, as it is sometimes affectionately referred to.
A callback is a function that can be passed into a function and called within that function as a response to any event. It’s fundamental to JS.
function readFile('file.txt', (data) => {
// This is inside the callback function
console.log(data)
}
That function is simply logging the data from a file, which isn’t possible until the file is finished being read. It seems simple, but what if you wanted to read and log five different files in sequence?
Before promises, in order to execute sequential tasks, you would need to nest callbacks, like so:
// This is officially callback hell
function combineFiles(file1, file2, file3, printFileCallBack) {
let newFileText = ''
readFile(string1, (text) => {
newFileText += text
readFile(string2, (text) => {
newFileText += text
readFile(string3, (text) => {
newFileText += text
printFileCallBack(newFileText)
}
}
}
}
It’s hard to reason about and difficult to follow. This doesn’t even include error handling for the entirely possible scenario that one of the files doesn’t exist.
This is where a promise
can help. A Promise is a way to reason about data that doesn’t yet exist, but you know it will. Kyle Simpson, author of You Don’t Know JS series, is well known for giving async JavaScript talks. His explanation of promises from this talk is spot on: It’s like ordering food a fast-food restaurant.
readFile(file1)
.then((file1-data) => { /* do something */ })
.then((previous-promise-data) => { /* do the next thing */ })
.catch( /* handle errors */ )
That’s the promise
syntax. Its main benefit is that it allows an intuitive way to chain together sequential events. This basic example is alright, but you can see that we’re still using callbacks. Promises are just thin wrappers on callbacks that make it a bit more intuitive.
A couple years ago, async functions made their way into the JavaScript ecosystem. As of last month, its an official feature of the language and widely supported.
The async
and await
keywords are a thin wrapper built on promises and generators. Essentially, it allows us to “pause” our function anywhere we want, using the await keyword.
async function logger() {
// pause until fetch returns
let data = await fetch('http://sampleapi.com/posts')
console.log(data)
}
This code runs and does what you’d want. It logs the data from the API call. If your brain didn’t just explode, I don’t know how to please you.
The benefit to this is that it’s intuitive. You write code the way your brain thinks about it, telling the script to pause where it needs to.
The other advantages are that you can use try
and catch
in a way that we couldn’t with promises:
async function logger () {
try {
let user_id = await fetch('/api/users/username')
let posts = await fetch('/api/`${user_id}`')
let object = JSON.parse(user.posts.toString())
console.log(posts)
} catch (error) {
console.error('Error:', error)
}
}
This is a contrived example, but it proves a point: catch will catch the error that occurs in any step during the process. There are at least 3 places that the try block could fail, making this by far the cleanest way to handle errors in async code.
We can also use async functions with loops and conditionals without much of a headache:
async function count() {
let counter = 1
for (let i = 0; i < 100; i++) {
counter += 1
console.log(counter)
await sleep(1000)
}
}
This is a silly example, but that will run how you’d expect and it’s easy to read. If you run this in the console, you’ll see that the code will pause on the sleep call, and the next loop iteration won’t start for one second.
Now that you’re convinced of the beauty of async and await, lets dive into the details:
You can still use Promise helpers such as Promise.all(). Here’s our earlier example:
async function logPosts () {
try {
let user_id = await fetch('/api/users/username')
let post_ids = await fetch('/api/posts/<code>${user_id}')
let promises = post_ids.map(post_id => {
return fetch('/api/posts/${post_id}')
}
let posts = await Promise.all(promises)
console.log(posts)
} catch (error) {
console.error('Error:', error)
}
}
Therefore, you can’t use await in the global scope.
// throws an error
function logger (callBack) {
console.log(await callBack)
}
// works!
async function logger () {
console.log(await callBack)
}
The async and await keywords are available in almost every browser as of June 2017. Even better, to ensure your code works everywhere, use Babel to preprocess your JavaScript into and older syntax that older browsers do support.
If you’re interested in more of what ES2017 has to offer, you can see a full list of ES2017 features here.
Sign up for my mailing list to receive new articles, mainly about Dart and Flutter, and other programming technologies.