Think twice before using async/await

There is a lot to like about modern Javascript (often abbreviated as ES 2015). Static imports are great. Rest/Spread syntax is great. Destructuring assignment is pretty good. Default arguments are great. Template strings are amazing. Classes are ok. Arrow functions are nice. Native promises took long enough. These are just the highlights from my perspective. I could go on, but it's not my goal here to catalog all of the changes. If you want that, check out this list.

My goal is to discuss the Async/Await features. These are basically changes to the way you work with promises. And so I'll start there.

Promises

A promise is a Javascript object that represents the results of future work. When that work has completed, the promise is said to be "resolved". If an error was thrown, then the promise is said to be "rejected". A promise provides two functions to consume those resolved or rejected values. .then() takes a callback function, and will execute it when the promise resolves. It will also return a new promise, representing the result of that given callback. .catch() also takes a callback function, and will execute when the promise rejects. And it also returns a new promise. So, this allows designs where asynchronous work can be processed easily, and composed easily into complex application logic.

Async/Await

Async is a keyword used when defining a function. For example: async function doSomethingEventually(){...}. It causes the function to be guaranteed to return a promise. That's (mostly) it.

Await is is an operator. And it's complicated. It causes function execution to halt and wait for a promise to resolve or reject, and then puts that result into the current scope as though it had been synchronous. Basically, it lets you pretend that promises are regular values. For example: const data = await fetch('/api/data'). Fetch is asynchronous, but you just pretend it's not. You get the data and move on with your function. Neat!

Here comes the but

The problem there is in pretending your operation is happening synchronously. For one thing, it hides a lot of complexity. That can cause problems later. For instance, what happens if your async operation never completes? Your async function will just be halted forever. What affect does that have on your application? I honestly don't know, do you?

This also makes it cumbersome to schedule your operations efficiently. Suppose you need to read two files.

import readFile from 'fs-readfile-promise'

const sysConfig = await readFile('system.conf');
const userConfig = await readFile('user.conf');

const config = merge(sysConfig, userConfig);

Await will stop execution at the first call to readFile, and just wait until it completes. Then, and only then, will the second call to readFile begin. If your application is going to behave that way, then why bother doing you IO asynchronously at all? You can of course re-write it to happen efficiently. Something like this:

import readFile from 'fs-readfile-promise'

const sysConfig = readFile('system.conf');
const userConfig = readFile('user.conf');

const config = merge(await sysConfig, await userConfig);

But if you're doing that, then the whole point of using await has already been lost. You can no longer ignore those promises. You have to continue to be aware that they are promises, and take extra steps to retrieve their resolved values.

And that's not even the end of it. Suppose there is an error and your awaited promise rejects instead. Await will re-throw that error. So now you have to try/catch these things.

try {
  const sysConfig = readFile('system.conf');
  const userConfig = readFile('user.conf');

  const config = merge(await sysConfig, await userConfig);
}
catch(err) {
  // Handle error here
}

If you're coming from other languages, or just never learned how to work with promises, that syntax may be more comfortable for you. But, it adds its own problems. For one, try is it's own block, and if you use block scoped variables (const or let), then they won't be accessible to the rest of your function. But block scoped variables were added for a reason. Falling back to regular var means going back to classic javascript scope complexities. And as a matter of style, I find try/catch blocks add visual clutter and worsen readability. Compare with the promise-aware version:

Promise.all([
  readFile('system.conf'),
  readFile('user.conf')
])
.then(merge)
.catch(() => {
  // Handle error here
});

I know this is a matter of style and taste, but I find the second option to be easier to read. It also has the benefit of catching any errors that merge might throw.

Use Sparingly

My conclusion is that Await often causes as many problems as it solves. That's not to say it should never be used. I myself have used it before. There are times when awaiting a value can actually be more clear than resolving a promise. But, I've spoken with more than a few Javascript developers who were very excited about it. This has been especially true of the less experienced, because await purports to make promises easier. And it does, to a limited extent. But, it's still important to understand how those promises work, because they are still there, and still powering things under the hood.