Or How I Learned to Stop Writing Callback Functions and Love Javascript ES8.

Sometimes modern Javascript projects get out of hand. A major culprit in this can be the messy handling of asynchronous tasks, leading to long, complex, and deeply nested blocks of code. Javascript now provides a new syntax for handling these operations, and it can turn even the most convoluted asynchronous operations into concise and highly readable code.

Background

AJAX (Asynchronous JavaScript And XML)

First a brief bit of history. In the late 1990s, Ajax was the first major breakthrough in asynchronous Javascript. This technique allowed websites to pull and display new data after the HTML had been loaded, a revolutionary idea at a time when most websites would download the entire page again to display a content update. The technique (popularized in name by the bundled helper function in jQuery) dominated web-development for all of the 2000s, and Ajax is the primary technique that websites use to retrieve data today, but with XML largely substituted for JSON.

NodeJS

When NodeJS was first released in 2009, a major focus of the server-side environment was allowing programs to gracefully handle concurrency. Most server-side languages at the time handled I/O operations by blocking the code completion until the operation had finished. Nodejs instead utilized an event-loop architecture, such that developers could assign "callback" functions to be triggered once non-blocking asynchronous operations had completed, in a similar manner to how the Ajax syntax worked.

Promises

A few years later, a new standard called "Promises" emerged in both NodeJS and browser environments, offering a powerful and standardized way to compose asynchronous operations. Promises still used a callback based format, but offered a consistent syntax for chaining and composing asynchronous operations. Promises, which had been pioneered by popular open-source libraries, were finally added as a native feature to Javascript in 2015.

Promises were a major improvement, but they still can often be the cause of somewhat verbose and difficult-to-read blocks of code.

Now there is a solution.

Async/await is a new syntax (borrowed from .NET and C#) that allows us to compose Promises as though they were just normal synchronous functions without callbacks. It's a fantastic addition to the Javascript language, added last year in Javascript ES7, and can be used to simplify pretty much any existing JS application.

Examples

We'll be going through a few code examples.

No libraries are required to run these examples. Async/await is fully supported in the latest versions of Chrome, Firefox, Safari, and Edge, so you can try out the examples in your browser console. Additionally, async/await syntax works in Nodejs version 7.6 and higher, and is supported by the Babel and Typescript transpilers, so it can really be used in any Javascript project today.

Setup

If you want to follow along on your machine, we'll be using this dummy API class. The class simulates network calls by returning promises which will resolve with simple data 200ms after being called.

class Api { constructor () { this.user = { id: 1, name: 'test' } this.friends = [ this.user, this.user, this.user ] this.photo = 'not a real photo' } getUser () { return new Promise((resolve, reject) => { setTimeout(() => resolve(this.user), 200) }) } getFriends (userId) { return new Promise((resolve, reject) => { setTimeout(() => resolve(this.friends.slice()), 200) }) } getPhoto (userId) { return new Promise((resolve, reject) => { setTimeout(() => resolve(this.photo), 200) }) } throwError () { return new Promise((resolve, reject) => { setTimeout(() => reject(new Error('Intentional Error')), 200) }) } }

Each example will be performing the same three operations in sequence: retrieve a user, retrieve their friends, retrieve their picture. At the end, we will log all three results to the console.

Attempt 1 - Nested Promise Callback Functions

Here is an implemention using nested promise callback functions.

function callbackHell () { const api = new Api() let user, friends api.getUser().then(function (returnedUser) { user = returnedUser api.getFriends(user.id).then(function (returnedFriends) { friends = returnedFriends api.getPhoto(user.id).then(function (photo) { console.log('callbackHell', { user, friends, photo }) }) }) }) }

This probably looks familiar to anyone who has worked on a Javascript project. The code block, which has a reasonably simple purpose, is long, deeply nested, and ends in this...

}) }) }) }

In a real codebase, each callback function might be quite long, which can result in huge and deeply indented functions. Dealing with this type of code, working with callbacks within callbacks within callbacks, is what is commonly referred to as "callback hell".

Even worse, there's no error checking, so any of the callbacks could fail silently as an unhandled promise rejection.

Attempt 2 - Promise Chain

Let's see if we can do any better.

function promiseChain () { const api = new Api() let user, friends api.getUser() .then((returnedUser) => { user = returnedUser return api.getFriends(user.id) }) .then((returnedFriends) => { friends = returnedFriends return api.getPhoto(user.id) }) .then((photo) => { console.log('promiseChain', { user, friends, photo }) }) }

One nice feature of promises is that they can be chained by returning another promise inside each callback. This way we can keep all of the callbacks on the same indentation level. We're also using arrow functions to abbreviate the callback function declarations.

This variant is certainly easier to read than the previous, and has a better sense of sequentiality, but is still very verbose and a bit complex looking.

Attempt 3 - Async/Await

What if it were possible to write it without any callback functions? Impossible? How about writing it in 7 lines?

async function asyncAwaitIsYourNewBestFriend () { const api = new Api() const user = await api.getUser() const friends = await api.getFriends(user.id) const photo = await api.getPhoto(user.id) console.log('asyncAwaitIsYourNewBestFriend', { user, friends, photo }) }

Much better. Calling "await" in front of a promise pauses the flow of the function until the promise has resolved, and assigns the result to the variable to the left of the equal sign. This way we can program an asynchronous operation flow as though it were a normal synchronous series of commands.

I hope you're as excited as I am at this point.

Note that "async" is declared at the beginning of the function declaration. This is required and actually turns the entire function into a promise. We'll dig into that later on.

Loops

Async/await makes lots of previously complex operations really easy. For example, what if we wanted to sequentially retrieve the friends lists for each of the user's friends?

Attempt 1 - Recursive Promise Loop

Here's how fetching each friend list sequentially might look with normal promises.

function promiseLoops () { const api = new Api() api.getUser() .then((user) => { return api.getFriends(user.id) }) .then((returnedFriends) => { const getFriendsOfFriends = (friends) => { if (friends.length > 0) { let friend = friends.pop() return api.getFriends(friend.id) .then((moreFriends) => { console.log('promiseLoops', moreFriends) return getFriendsOfFriends(friends) }) } } return getFriendsOfFriends(returnedFriends) }) }

We're creating an inner-function that recursively chains promises for the fetching friends-of-friends until the list is empty. Ugh. It's completely functional, which is nice, but this is still an exceptionally complicated solution for a fairly straightforward task.

Note - Attempting to simplify the promiseLoops() function using Promise.all() will result in a function that behaves in significantly different manner. The intention of this example is to run the operations sequentially (one at a time), whereas Promise.all() is used for running asynchronous operations concurrently (all at once). Promise.all() is still very powerful when combined with async/await, however, as we'll see in the next section.

Attempt 2 - Async/Await For-Loop

This could be so much easier.

async function asyncAwaitLoops () { const api = new Api() const user = await api.getUser() const friends = await api.getFriends(user.id) for (let friend of friends) { let moreFriends = await api.getFriends(friend.id) console.log('asyncAwaitLoops', moreFriends) } }

No need to write any recursive promise closures. Just a for-loop. Async/await is your friend.

Parallel Operations

It's a bit slow to get each additional friend list one-by-one, why not do them in parallel? Can we do that with async/await?

Yeah, of course we can. It solves all of our problems.

async function asyncAwaitLoopsParallel () { const api = new Api() const user = await api.getUser() const friends = await api.getFriends(user.id) const friendPromises = friends.map(friend => api.getFriends(friend.id)) const moreFriends = await Promise.all(friendPromises) console.log('asyncAwaitLoopsParallel', moreFriends) }

To run operations in parallel, form an array of promises to be run, and pass it as the parameter to Promise.all() . This returns a single promise for us to await, which will resolve once all of the operations have completed.

Error Handling

There is, however, one major issue in asynchronous programming that we haven't addressed yet: error handling. The bane of many codebases, asynchronous error handling often involves writing individual error handling callbacks for each operation. Percolating errors to the top of the call stack can be complicated, and normally requires explicitly checking if an error was thrown at the beginning of every callback. This approach is tedious, verbose and error-prone. Furthermore, any exception thrown in a promise will fail silently if not properly caught, leading to "invisible errors" in codebases with incomplete error checking.

Let's go back through the examples and add error handling to each. To test the error handling, we'll be calling an additional function, "api.throwError()", before retrieving the user photo.

Attempt 1 - Promise Error Callbacks

Let's look at a worst-case scenario.

function callbackErrorHell () { const api = new Api() let user, friends api.getUser().then(function (returnedUser) { user = returnedUser api.getFriends(user.id).then(function (returnedFriends) { friends = returnedFriends api.throwError().then(function () { console.log('Error was not thrown') api.getPhoto(user.id).then(function (photo) { console.log('callbackErrorHell', { user, friends, photo }) }, function (err) { console.error(err) }) }, function (err) { console.error(err) }) }, function (err) { console.error(err) }) }, function (err) { console.error(err) }) }

This is just awful. Besides being really long and ugly, the control flow is very unintuitive to follow since it flows from the outside in, instead of from top to bottom like normal, readable code. Awful. Let's move on.

Attempt 2 - Promise Chain "Catch" Method

We can improve things a bit by using a combined Promise "catch" method.

function callbackErrorPromiseChain () { const api = new Api() let user, friends api.getUser() .then((returnedUser) => { user = returnedUser return api.getFriends(user.id) }) .then((returnedFriends) => { friends = returnedFriends return api.throwError() }) .then(() => { console.log('Error was not thrown') return api.getPhoto(user.id) }) .then((photo) => { console.log('callbackErrorPromiseChain', { user, friends, photo }) }) .catch((err) => { console.error(err) }) }

This is certainly better; by leveraging a single catch function at the end of the promise chain, we can provide a single error handler for all of the operations. However, it's still a bit complex, and we are still forced to handle the asynchronous errors using a special callback instead of handling them the same way we would normal Javascript errors.

Attempt 3 - Normal Try/Catch Block

We can do better.

async function aysncAwaitTryCatch () { try { const api = new Api() const user = await api.getUser() const friends = await api.getFriends(user.id) await api.throwError() console.log('Error was not thrown') const photo = await api.getPhoto(user.id) console.log('async/await', { user, friends, photo }) } catch (err) { console.error(err) } }

Here, we've wrapped the entire operation within a normal try/catch block. This way, we can throw and catch errors from synchronous code and asynchronous code in the exact same way. Much simpler.

Composition

I mentioned earlier that any function tagged with "async" actually returns a promise. This allows us to really easily compose asynchronous control flows.

For instance, we can reconfigure the earlier example to return the user data instead of logging it. Then we can retrieve the data by calling the async function as a promise.

async function getUserInfo () { const api = new Api() const user = await api.getUser() const friends = await api.getFriends(user.id) const photo = await api.getPhoto(user.id) return { user, friends, photo } } function promiseUserInfo () { getUserInfo().then(({ user, friends, photo }) => { console.log('promiseUserInfo', { user, friends, photo }) }) }

Even better, we can use async/await syntax in the receiver function too, leading to a completely obvious, even trivial, block of asynchronous programing.

async function awaitUserInfo () { const { user, friends, photo } = await getUserInfo() console.log('awaitUserInfo', { user, friends, photo }) }

What if now we need to retrieve all of the data for the first 10 users?

async function getLotsOfUserData () { const users = [] while (users.length < 10) { users.push(await getUserInfo()) } console.log('getLotsOfUserData', users) }

How about in parallel? And with airtight error handling?

async function getLotsOfUserDataFaster () { try { const userPromises = Array(10).fill(getUserInfo()) const users = await Promise.all(userPromises) console.log('getLotsOfUserDataFaster', users) } catch (err) { console.error(err) } }

Conclusion

With the rise of single-page javascript web apps and the widening adoption of NodeJS, handling concurrency gracefully is more important than ever for Javascript developers. Async/await alleviates many of the bug-inducing control-flow issues that have plagued Javascript codebases for decades and is pretty much guaranteed to make any async code block significantly shorter, simpler, and more self-evident. With near-universal support in mainstream browsers and NodeJS, this is the perfect time to integrate these techniques into your own coding practices and projects.

Join The Discussion on Reddit