Async and Await

> Guillermo Rauch (@rauchg). Thursday, June 2nd 2016 (22h ago).

JavaScript, in particular Node.js, has been frequently associated with callback hell[1]. If you've written code that deals with a lot of async I/O, you're probably familiar with this pattern:

export default function getLikes () {
  getUsers((err, users) => {
    if (err) return fn(err);
    filterUsersWithFriends((err, usersWithFriends) => {
      if (err) return fn(err);
      getUsersLikes(usersWithFriends, (err, likes) => {
        if (err) return fn (err);
        fn(null, likes);
      });
    });
  });
}

It turns out, this code can be much easier and safer to write.

I'll show you how Promise combined with async / await enables this, but also some of the lessons we've learned from using these new features in production.

Let's start with the pitfalls of the example above.

Callback problems

Error handling is repetitive

In a great majority of cases, you want to just pass the error along.

In the example above, however, you repeat yourself many times. It's also easy to miss a return and only discover it (with non-obvious debugging) when the error actually occurs.

Error handling in unspecified

When errors occur, most popular libraries will invoke the callback with an Error parameter, or in the success case use null instead.

Unfortunately this is not always the case. You might get false instead of null. Some libraries omit it altogether. If several errors occur, you might even get multiple callbacks! Which leads us to…

Scheduling is unspecified

Does the callback fire immediately? or on a different microtask? or on a different tick? Sometimes? Always?

Who knows! Reading your own code certainly won't tell you. Reading the library's documentation might tell you, if you're lucky.

It's possible that the callback will fire more than once without you expecting it. Once again, this will almost certainly result in code that's extremely hard to debug.

In certain cases, the code might continue to run but not doing quite what it should. In others, you might get a stack trace that doesn't exactly make the root cause obvious.

The solution to these problems is the standarization on Promise.

How Promise works

Promises present a clear contract and API to you. While we might disagree on whether the details and API of this contract are the best ones, they're strictly defined.

Thus, the lack of specification we mentioned above is not a concern when you're dealing with code that uses Promise.

This is what the equivalent to setTimeout would look like using Promise:

function sleep (time) {
  return new Promise((resolve) => setTimeout(resolve, time));
}

sleep(100)
.then(() => console.log('100ms elapsed'))
.catch(() => console.error('error!'));

Promises can be in two settled states: resolved and rejected. As seen above, you can set up a pair of callbacks to obtain the resolved value and the rejected value.

The fact that we pass callbacks to a promise shows that we often deal with somewhat of a false dichotomy. Obviously, promises need callbacks to do anything meaningful. The real comparison is then between promises and the callback pattern that the JavaScript community has informally agreed upon[2].

Promises represent a single value. Unlike the callback pattern above, you can't get an error followed by success for the a certain invocation. Or get a value and an error later on.

You can think of resolve as the Promise equivalent of return and reject as throw. As we'll see later on, this semantic equivalency is syntactically realized by the async and await keywords.

As far as scheduling goes, the Promise spec has settled on always invoking callbacks "at a future time" (i.e.: the next microtask). This meants the behavior of a Promise is consistently asynchronous every time you call then or catch, whether it's already been settled or not.

If we write our initial example with this API it would look as follows:

export default function getUsers () {
  return getUsers()
  .then(users => filterUsersWithFriends)
  .then(usersWithFriends => getUsersLikes);
}

This already looks much better! That said, if our logic were to change, refactoring the code gets complicated very quickly.

Imagine that in the code above, a particular type of failure of filterUsersWithFriends needs to be handled differently:

export default function getUsers () {
  return new Promise((resolve, reject) => {
    getUsers().then(users => {
      filterUsersWithFriends(users)
      .then(resolve)
      .catch((err) => {
        resolve(trySomethingElse(users));
      });
    }, reject)
  });
}

No amount of chaining "convenience" can save us. Let's look at the solution.

The future: async and await

As known for a while in the C# and F# world, there's an elegant solution to our problems:

export default async function getLikes () {
  const users = await getUsers();
  const filtered = await filterUsersWithFriends(users);
  return getUsersLikes(filtered);
}

For this to work, we just need to make sure that the functions that perform I/O that we depend on (like getUsers) return a Promise.

Not only is it easier to read (as the chaining example was), but now the error handling behavior is the exact same as regular synchronous JavaScript code.

That is, when we await a function, errors (if any) are surfaced and thrown. If our getLikes function is invoked, errors bubble up by default. If you want to handle a particular error differently, just wrap your await invocation with try/catch.

This will increase your productivity and correctness as you won't be writing (or worse, ignoring!) if (err) return fn(err) everywhere.

How certain are we of this future?

Lessons learned

We've been using these features at for many months now and have been extremely happy and productive with them.

I recently published a guide to transpiling with Babel and Node 6, which due to its great support for ES6 now only needs two transformation plugins and exhibits great compilation performance.

If you want support for the browser or older versions of Node, I suggest you also include the es2015 preset. This will compile to a state machine instead of generators.

In order to maximize your usage of this feature, you'll want to use modules from the ecosystem that expose Promise instead of just a callback.

  • Some modules already do both. node_redis, for example, exposes Promise if you suffix Async to the methods it exposes.
  • Some modules exist to wrap existing modules with Promise. You can usually identify these by their prefix or suffix then or promise. Examples: fs-promise, then-sleep.

In addition to these, Node is considering returning Promises directly in the standard library. You can follow that discussion here.

I also need to stress that this syntax doesn't make Promise go away from your codebase. In fact, you must have a thorough understanding of them, which you'll frequently need.

A common example where Promise makes an appearence is code that requires multiple values as part of a loop, which are requested concurrently:

const ids = [1, 2, 3];
const values = await Promise.all(ids.map((id) => {
  return db.query('SELECT * from products WHERE id = ?', id);
}));

Notice also that in the example presented above (async getLikes()), I opted to return getUserLikes() instead of return await getUserLikes().

Since the goal of the async keyword is to make the function return a Promise, those two snippets are therefore equivalent.

This means that the following code:

async function getAnswer () {
  return 42;
}

is perfectly valid and equivalent to its sync counter-part const getAnswer = () => 42 with the exception that when invoked with await it will resolve in the next microtask. When called without await, it will return a Promise.

The boulevard of broken Promises

Earlier I mentioned the Promise spec set out to solve a host of problems we would frequently run into with callbacks.

I'll cover some of the problems that have remained or have been now introduced, and some behaviors that were left unspecified but are critical for our needs.

Debugging difficulties

When you use a Promise and don't attach an error handler, in many environments you might never find out about the error.

This is the equivalent to ignoring the err parameter in the callback pattern, with the difference that a TypeError is likely to occur when you try to access the value you're interested in.

In the callback pattern, while you can manage to ignore err, you're likely to find out with a crash later on when the error does occur.

Ignoring errors is normally quite difficult to do with async and await, however. The exception would be the entry point of your asynchronous code:

async function run () {
  // your app code…
}

run().catch((err) => {
  // make sure to handle the error!
});

Fortunately, there are workarounds and a potential definitive solution to this problem:

  • Chrome and Firefox warn about unhandled rejections in the developer tools.
  • Node.js emits unhandledRejection, with which you can log manually. I recommend you read this discussion about the implications of unhandled rejections for backend systems.
  • Top-level support for await in the future would make the manual Promise instantiation and catching unnecessary!

Finally, I mentioned earlier that Promises will be resolved once, unlike callbacks that could fire multiple times unexpectedly.

The problem is that once again, Promises will swallow subsequent resolutions and more concerningly, rejections. There might be errors that are never logged!

Cancellation

The original Promise spec left out the semantics of cancelling the ongoing asynchronous retrieval of a value.

As fate would have it, browser vendors went on to implement them as the return value of functions that have historically needed cancelation, like HTTP requests.

Namely, with XMLHttpRequest you can call abort on the resulting object, but with the new and shiny fetch… you can't.

TC39 is now considering the addition of a third state: cancelled. You can read more about the stage 1 proposal here.

While retro-fitted to Promises, cancellation is a fundamental property of the next abstraction we'll cover: the Observable.

Observables

Earlier in the post it became evident that waiting on a Promise to resolve is somewhat equivalent to a function doing some work and returning a value synchronously.

The Observable is a more general (and therefore more powerful) abstraction that represents a function invokation that can return several values.

Unlike Promise, Observable objects can return synchronously (same tick) or asynchronously.

These design decisions make an Observable suitable for a wider range of usecases.

In the spirit of our earlier examples, here's how Observable can work with setInterval to give us a value over time:

function timer (ms) {
  return new Observable(obv => {
    let i = 0;
    const iv = setInterval(() => {
      obv.next(i++);
    }, ms);
    return () => clearInterval(iv);
  });
}

As I mentioned earlier, Observable covers a broader spectrum of possibility. From this lense, a Promise is simply an Observable that returns a single value and completes:

function delay(ms) {
  return new Observable(obv => {
    const t = setTimeout(() => {
      obv.next();
      obv.complete();
    }, ms);
    return () => clearTimeout(t);
  });
}

Notice that the value returned in the setup of the Observable is a function that performs cleanup. Such a function is executed when no subscriptions are left:

const subscription = delay(100).subscribe();
subscription.unsubscribe(); // cleanup happens

This means that Observable also fills another missing gap in Promise: cancelation. In this model, cancelation is simply a consequence of the cease of observation.

With this said, a lot of asynchronous work can be expressed with only the Promise subset just fine. As a matter of fact, a great portion of the core library of Node.js only needs that (the exceptions being Stream and some EventEmitter).

What about async and await? One could implement an operator that restricts the behavior of a givenObservable to that of a Promise (which libraries like RxJS already have) and await it:

await toPromise(timer(1000));

This example shows us the generalization in action: the timer function is just as useful as delay, but also works for intervals!

Future directions

async and await will enable significant improvements in your codebases.

Our open-source library micro is a great example of how the request / response cycle can be made a lot more straightforward.

The following microservice responds with a JSON encoded array of users a database.
If any of the handlers throw, the response is aborted with err.statusCode.
If unhandled exceptions occur, a 500 response is produced and the error logged.

export default async function (req, res) {
  await rateLimit(req);
  const uid = await authenticate(req);
  return getUsers(uid);
}

As mentioned, proposals have been made for ES6 modules to admit a top-level await. For Node.js this would mean being able to write code like this:

import request from 'request';
const file = await fs.readFile('some-file');
const res = await request.post('/some-api', { body: { file } });
console.log(res.data);

and then run it without any wrappers (and straight-forward error handling)!

▲ node my-script.mjs

Simultaneously, Observable continues to make progress within TC39 to become a first-class construct of the language.

I believe these new primitives for managing concurrency and asynchrony will have a very profound impact on the JavaScript ecosystem. It's about time.

  1. ^ A litmus test for callback hell: does a Ryu performing Hadouken fit in your indentation?
  2. ^ The pattern can be summarized as follows: the callback is invoked once, on a different tick, with an Error object as the first parameter in the case of an error, or null and the intended value as the second. However, deviations from this implicit agreement are commonly encountered in the ecosystem. Some libraries omit the error object and emit an error event somewhere else. Some callbacks fire with multiple values. Et cetera.