Why async/await is more than just syntactic sugar

#javascript

My takes on async/await vs Promise

Despite thousands of posts on async/await vs. Promise already existing, many of them left a lot to be desired. So I want to write up my own post on this topic.

The point I'd like to make in this post is that async/await is more than syntactic sugar on top of Promise, as async/await does offer tangible benefits:

  • async/await allows us to use all the language constructs that are available in synchronous programming, resulting in more expressive and readable code;
  • async/await unifies the experience of asynchronous programming; and
  • async/await provides better error stack trace;

    This blog post assumes basic knowledge of Promise and async/await. I am not here to compete with tutorials on MDN and javascript.info.

A little bit of history of async programming in JavaScript#

Asynchronous programming is common in JavaScript. Whenever we need to make a web service call, a file access, or a database operation, asynchronicity is how we prevent the UI from being blocked despite the language being single-threaded.

Before the major upgrade JavaScript underwent in ES2015 (ES6), callbacks were how people dealt with asynchronous programming. The only way for us to express temporal dependency (i.e. the execution order of asynchronous operations) is nesting one callback inside of the other. This led to so called Callback Hell.

Reddit user @theQuandary pointed out that before ES6, there were other better alternatives to asynchronous programming in JavaScript than callbacks. Sorry for not being 100% accurate here, as I wasn't around for much of that history.

Promise then was introduced in JavaScript in ES2015. It is a first-class object for asynchronous operations which we can easily pass around, compose, aggregate and apply transformations to. Temporal dependency is cleanly expressed via then method chaining.

More on the history…

The idea of Promise in JavaScript wasn’t original. It was inspired by a very old language called E language. Its creator Mark Miller is also a TC39 representative. And the async/await syntax was borrowed from C#.

With Promise serving as a powerful primitive, it sounds like asynchronous programming is a solved issue in JavaScript, right?

Well, not quite yet, because sometimes Promise can be a little too low level to work with…

Sometimes Promise can be too low level to work with#

Despite the advent of Promise, there was still a need for a higher-level language construct for asynchronous programming in JavaScript.

Let's consider this example where we need a function to poll an API at some interval. It resolves to null when the maximum number of retries has met.

Here is one possible solution with Promise:

let count = 0;

function apiCall() {
  return new Promise((resolve) =>
    // at 6th retry, it resolves to 'value'
    count++ === 5 ? resolve('value') : resolve(null)
  );
}

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

function poll(retry, interval) {
  return new Promise((resolve) => {
    // skip error handling for brevity...

    if (retry === 0) resolve(null);
    apiCall().then((val) => {
      if (val !== null) resolve(val);
      else {
        sleep(interval).then(() => {
          resolve(poll(retry - 1, interval));
        });
      }
    });
  });
}

poll(6, 1000).then(console.log); // 'value'

How intuitive and readable this solution is would depend one's familiarity with Promise, how Promise.resolve "flats" Promise and recursion. To me, this is not the most readable way to write such a function.

You can use setInterval instead

There is almost always another way to write a function. Here is a solution with setInterval written by my friend James:

const pollInterval = (retry, interval) => {
    return new Promise((resolve) => {

        let intervalToken, timeoutToken;

        intervalToken = setInterval(async () => {
            const result = await apiCall();
            if (result !== null) {
                clearInterval(intervalToken);
                clearTimeout(timeoutToken);
                resolve(result);
            }
        }, interval);

        timeoutToken = setTimeout(() => {
            clearInterval(intervalToken);
            resolve(null);
        }, retry * interval);
        });
};

Enter async/await#

Let's rewrite the above solution using the async/await syntax:

async function poll(retry, interval) {
  while (retry >= 0) {
    const value = await apiCall().catch((e) => {}); // skip error handling for brevity...
    if (value !== null) return value;
    await sleep(interval);
    retry--;
  }

  return null;
}

I expect most people'd find the above solution more readable, because we are able to use all normal language constructs such as such as loops, try-catch for an asynchronous operations.

The recursive approach

However, this is not strictly an apple-to-apple comparison as I switched from a recursive approach to an iterative approach. Let’s rewrite the above solution using recursion:

const pollAsyncAwait = async (retry, interval) => {
    if (retry < 0) return null;

    const value = await apiCall().catch((e) => {}); // skip error handling for brevity...
    if (value !== null) return value;

    await sleep(interval);
    return pollAsyncAwait(retry - 1, interval);
};

This is probably the biggest selling point of async/await - enabling you to write asynchronous code in a synchronous-looking way. On the other hand, this is where probably the most common objection to async/await comes from. More on that later.

By the way, await even has the right operator precedence, so that await a + await b does mean (await a) + (await b), as opposed to, let's say, await (a + await b).

async/await offers an unified experience across sync and async code#

Another nice thing about async/await is that await automatically wraps any non-Promises (non-thenables) into Promises. The semantics of await equates to Promise.resolve, that means you can await anything:

function fetchValue() {
  return 1;
}

async function fn() {
  const val = await fetchValue();
  console.log(val); // 1
}

// 👆 this is equal to the following

function fn() {
  Promise.resolve(fetchValue()).then((val) => {
    console.log(val); // 1
  });
}
Note this is a browser-dependent behavior...

Saying await foo equals to Promise.resolve(foo).then(...) is not 100% accurate.

Before Chrome 73, the ECMAScript spec translated await foo to new Promise(resolve => resolve(p)). Then there was a change in the spec made in this PR. But until now, not every browser respects the change in the spec; as of this writing, Safari still hasn't implemented the updated spec. As a result, running this snippet in Safari gives you a different result from that in Chrome.

If we were to attach the then method to the number 1 returned from fetchValue, the following error would occur:

function fetchValue() {
  return 1;
}

function fn() {
  fetchValue().then((val) => {
    console.log(val);
  });
}

fn(); // ❌ Uncaught TypeError: fetchValue(...).then is not a function

Lastly, anything returned from an async function is always a Promise:

Object.prototype.toString.call((async function () {})()); // '[object Promise]'

async/await provides better error stack tracing#

V8 engineer Mathias wrote a post called Asynchronous stack traces: why await beats Promise#then() covering why the engine has an easier time capturing and store stack trace for async/await compared to Promise.

Here is a demo:

async function foo() {
  await bar();
  return 'value';
}

function bar() {
  throw new Error('BEEP BEEP');
}

foo().catch((error) => console.log(error.stack));

// Error: BEEP BEEP
//     at bar (<anonymous>:7:9)
//     at foo (<anonymous>:2:9)
//     at <anonymous>:10:1

The async version captures the error stack trace correctly.

Le's take a look at the Promise version:

function foo() {
  return bar().then(() => 'value');
}

function bar() {
  return Promise.resolve().then(() => {
    throw new Error('BEEP BEEP');
  });
}

foo().catch((error) => console.log(error.stack));

// Error: BEEP BEEP  at <anonymous>:7:11

Stack trace is lost. Switching from anonymous arrow function to named function declaration helps a bit, but not by much:

function foo() {
  return bar().then(() => 'value');
}

function bar() {
  return Promise.resolve().then(function thisWillThrow() {
    throw new Error('BEEP BEEP');
  });
}

foo().catch((error) => console.log(error.stack));

// Error: BEEP BEEP
//    at thisWillThrow (<anonymous>:7:11)

Common objections to async/await#

I've seen two common objections to async/await.

First, async/await can be a footgun when one unnecessarily sequentializes independent async function calls when they can be handled concurrently (or “in parallel” if we use the term loosely) with Promise.all.

This tends to happen when people try to muddle through asynchronous programming without truly understanding how Promise works behind the scenes.

The second one is more nuanced. Some functional programming enthusiasts think async/await invites imperative style programming. From a FP programmer's point of view, being able to use loops and try catch is not a blessing, as those language constructs imply side effects and encourages less-than-ideal error handling.

I sympathize with this argument. FP programmers rightfully care about certainty in their programs. They want to be absolutely confident in their code. In order to get there, a sophisticated type system with types like Result is warranted. But I don't think async/await itself is incompatible with FP. My friend James, an expert in FP, said that there is an equivalent of async/await in Haskell - the Do-notation feature.

Anyway, I think for most people, including me, FP remains an acquired taste (although I do think FP is super cool and I am slowly learning it). Normal control flow statements and try catch error handling provided by async/await are invaluable for us to orchestrate complicated asynchronous operations in JavaScript. That's precisely why saying "async/await is just a syntactic sugar" is an understatement.

Further reading#