DEV Community

Paige Niedringhaus
Paige Niedringhaus

Posted on • Originally published at paigeniedringhaus.com on

JavaScript's Async / Await versus Promises: The Great Debate

Office Space meme about refactoring all the JavaScript over the weekend

The spark that ignited the firestorm

One day, just a few weeks ago at work, I was minding my own business, writing some React code, submitting pull requests, and another developer on my team, in essence, lobbed a grenade at our codebase.

He asked a seemingly benign question about why we were continuing to use promises in JavaScript versus the newer ECMAScript 17 version, async / await. 🚨 It seemed innocent enough, at first glance.

And let me tell you, normally, I’m all for using the new hotness in JavaScript (especially when it’s built into the language and supported by most modern browsers — looking at you for continued non-compliance, Internet Explorer), but even though I’ve heard the about the greatness of async / await, I hadn’t really used it up to that point.

I didn’t really see any benefits of async / await that outweighed using promises — they both ended up accomplishing the same thing: handling asynchronous data calls in a performant, consistent manner.

Toy Story meme where Buzz describes Space to Woody, except promises

All promises, all the time.

I like promises, I’m comfortable with the syntax of .then(), with the error handling of .catch(), with all of it, and switching required rewriting a lot of asynchronous calls our application currently makes. Before I uprooted all the hard work the team had written in traditional promise form, I wanted to find some reasoning beyond “it’s ES7, and it’s newer” to make the jump to async / await.

And so, I set off to do some learning.

Today, I’ll compare the benefits (and personal preferences) of choosing promises or async / await for your asynchronous data needs in your JavaScript application.


A short history of JavaScript's asynchronous data handling (or lack thereof)

Now before I go into detail about promises and async / await, I want to backtrack to a darker time in JavaScript, when asynchronous data fetching was more of a problem and the lesser solution was known as callbacks.

AJAX & callbacks

AJAX, which stands for Asynchronous JavaScript And XML and callbacks were an OG way of handling asynchronous calls in JavaScript. What it boils down to, is when one function is meant to be executed after another function has finished executing — hence the name "call back".

Callbacks in a nutshell

The first function normally returns data the second function needs to perform some sort of operation on, and unlike multi threaded languages like Java, the single threaded JavaScript event loop will push that first function to the call stack and execute it, pop that function call off the call stack once it’s completed it’s request, and continue running, pushing other operations that were waiting in the queue to the call stack in the meantime (which keeps the UI from seeming as if it’s frozen to the user).

Then, when the response from the other server comes back (the asynchronous data) it’s added to the queue, the event loop will eventually push it to the call stack when it sees the stack is empty, and the call stack will execute that response as the callback function.

This article isn’t about callbacks though, so I won’t go into too much more detail, but here’s an example of a what a JavaScript event with a callback function looks like.

Traditional JavaScript callback example

// the original function to call
function orderFood(food, callback) {
  alert(`Ordering my ${food} at the counter.`);
  callback();
}
// the callback once the order's up
function alertFoodReady(){
  alert(`Order's ready for pickup`);
}
// calling the function and adding the callback as the second 
// parameter
orderFood('burger', alertFoodReady);
Enter fullscreen mode Exit fullscreen mode

This is a pretty simple example, and to be fair, it’s only showing the "happy path" — the path when the callback works and the food’s prepared and ready to be picked up.

What’s not covered here is the error handling, nor is it an example of what can happen when you have many, many asynchronous dependencies nested inside one another. This is fondly known among JavaScript developers as “Callback Hell” or “The Pyramid of Doom”.

Callback Hell

Below is an example of what Callback Hell looks like. This is a nightmare, don’t try to deny it. This happens when multiple functions need data from other functions to do their jobs.

Callback Hell code example

A perfect example of callback hell: a callback, inside a callback, inside another callback for eternity.

If you’d like to error handle that or even try to add some new functionality in the middle of that mess, be my guest.

I, however, won’t have anything to do with it, so let’s agree that AJAX and callbacks were once a way to handle asynchronous data, but they are no longer the de facto way. There’s much better solutions that have come about that I’ll show you next. Let’s move on to promises.

Promises, promises

Mozilla does an excellent job of defining promises, so I’ll use their definition as the first introduction to what a promise is.

A Promise is an object representing the eventual completion or failure of an asynchronous operation…Essentially, a promise is a returned object to which you attach callbacks, instead of passing callbacks into a function. — Mozilla Docs, Using promises

Here’s another example of an asynchronous callback function called createAudioFileAsync(). It takes in three parameters: audioSettings, a successCallback, and a failureCallback.

Traditional JavaScript callback example

function successCallback(result) {
  console.log("Audio file ready at URL: " + result);
}

function failureCallback(error) {
  console.log("Error generating audio file: " + error);
}

createAudioFileAsync(audioSettings, successCallback, failureCallback);
Enter fullscreen mode Exit fullscreen mode

That’s a lot of functions and code for just one asynchronous data call.

Here’s the shorthand for that same function when it’s transformed using promises.

JavaScript promise example with callbacks

createAudioFileAsync(audioSettings)
  .then(successCallback, failureCallback);
Enter fullscreen mode Exit fullscreen mode

Does that look nicer to you? It looks nicer to me.

But wait, there’s more. Instead of the success and failure callbacks both inside the .then(), it can be changed to:

Modern day JavaScript promise example

createAudioFileAsync(audioSettings)
  .then(successCallback)
  .catch(failureCallback);
Enter fullscreen mode Exit fullscreen mode

And even this can be modernized one more time, by swapping the original successCallback() and failureCallback() functions for ES6 arrow functions.

ES6 arrow function promise example

createAudioFileAsync(audioSettings)
  .then(result => console.log(`Audio file ready at URL: ${result}`))
  .catch(error => console.log(`Error generating audio file: ${error}`));
Enter fullscreen mode Exit fullscreen mode

This may seem like a minor improvement right now, but once you start chaining promises together or waiting for multiple promises to resolve before moving forward, having one single .catch() block at the end to handle anything that goes wrong within, is pretty handy. Read on and I’ll show you.

Pros of promises over callbacks

In addition to a cleaner syntax, promises offer advantages over callbacks.

  • Callbacks added with .then() even after the success or failure of the asynchronous operation, will be called, as above.
  • Multiple callbacks may be added by calling .then() several times. Each callback is executed one after another, in the order in which they were inserted (this is the chaining I mentioned earlier).
  • It’s possible to chain events together after a failure, i.e. a .catch(), which is useful to accomplish new actions even after an action failed in the chain.
  • Promise.all() returns a single Promise that resolves when all of the promises passed as an iterable have resolved or when the iterable contains no promises. Callbacks can’t do that.
  • Promises solve a fundamental flaw with the callback pyramid of doom, by catching all errors, even thrown exceptions and programming errors. This is essential for functional composition of asynchronous operations.

To back up a moment, a common need is to execute two or more asynchronous operations back to back, where each subsequent operation starts when the previous operation succeeds, with the result from the previous step. This can be accomplished with a promise chain.

In the olden days, we entered Callback Hell when callbacks depended on each other for information. See below (note also, the multiple failureCallbacks that had to be added for each callback).

Traditional, nested callback chain example

doSomething(function(result) {
  doSomethingElse(result, function(newResult) {
    doThirdThing(newResult, function(finalResult) {
      console.log('Got the final result: ' + finalResult);
    }, failureCallback);
  }, failureCallback);
}, failureCallback);
Enter fullscreen mode Exit fullscreen mode

With the introduction of the promise chain, that "pyramid of doom", became what you see below (see the improvement of just one failureCallback instance now at the very end).

New promise chain example

doSomething()
.then(function(result) {
  return doSomethingElse(result);
})
.then(function(newResult) {
  return doThirdThing(newResult);
})
.then(function(finalResult) {
  console.log('Got the final result: ' + finalResult);
})
.catch(failureCallback);
Enter fullscreen mode Exit fullscreen mode

And with the advent of ES6 arrow functions, that code becomes even more compact in the next example.

ES6 arrow function promise chain example

doSomething()
.then(result => doSomethingElse(result))
.then(newResult => doThirdThing(newResult))
.then(finalResult => {
  console.log(`Got the final result: ${finalResult}`);
})
.catch(failureCallback);
Enter fullscreen mode Exit fullscreen mode

Not bad, huh?

Note that with arrow functions, the return statement is unnecessary to pass on the result, instead the result is returned via implicit returns instead.

Likewise, sometimes you need two or more unconnected promises to all resolve before moving on, which is where Promise.all() becomes a godsend.

Promise.all() example

var promise1 = Promise.resolve(3);
var promise2 = 42;
var promise3 = new Promise(function(resolve, reject) {
  setTimeout(resolve, 100, 'foo');
});
Promise.all([promise1, promise2, promise3]).then(function(values) {
  console.log(values);
});
// expected output: Array [3, 42, "foo"]
Enter fullscreen mode Exit fullscreen mode

Simply by passing the three promises as an array to Promise.all(), the promise waited until all three had resolved before moving on to the .then() part of the statement.

I’d like to see callbacks do that gracefully.

Promise FTW?

This is the kind of code my team had been writing in our React application. It was clean, compact, easy to read (in my opinion), I saw nothing wrong with it. Then I investigated async await.

ECMAScript 17's new hotness: async / await

Matrix meme where Morpheus promises even cleaner code

The promise of async / await. See what I did there? 😏

Once more, I turn to Mozilla for the most succinct definitions of async and await.

The async function declaration defines an asynchronous function , which returns an AsyncFunction object. An asynchronous function is a function which operates asynchronously via the event loop, using an implicit Promise to return its result. But the syntax and structure of your code using async functions is much more like using standard synchronous functions...

An async function can contain an await expression that pauses the execution of the async function and waits for the passed Promise's resolution, and then resumes the async function's execution and returns the resolved value. — Mozilla Docs, Async Function

While that may make sense to you, I usually benefit from seeing code to really get the gist of it. Here’s a couple of code examples so you can see the differences for yourself.

Promise-based example

Here’s an example of a promise-based fetch() HTTP call.

function logFetch(url) {
  return fetch(url)
    .then(response => response.text())
    .then(text => {
      console.log(text);
    }).catch(err => {
      console.error('fetch failed', err);
    });
}
Enter fullscreen mode Exit fullscreen mode

Ok, this seems pretty straightforward so far.

Async / await example

Here’s the async / await version of that same call.

async function logFetch(url) {
  try {
    const response = await fetch(url);
    console.log(await response.text());
  }
  catch (err) {
    console.log('fetch failed', err);
  }
}
Enter fullscreen mode Exit fullscreen mode

And this too, seems pretty easy to understand - but in a more concise manner.

Pros of async / await over promises

So what’s the big deal?

What this seems to boil down to in practice, is that async / await is really syntactic sugar for promises, because it still uses promises under the hood.

Game of Thrones meme about one not using async / await without knowing promises

It’s all promises in the end!

The change to the syntax, though, is where its appeal to many starts to become apparent.

  • The syntax and structure of your code using async functions is much more like using standard synchronous functions.
  • In the examples above, the logFetch() functions are the same number of lines, but all the callbacks are gone. This makes it easier to read, especially for those less familiar with promises.
  • Another interesting tidbit, is that anything you await is passed through Promise.resolve() (for us, typically the .then(result) resolution of the promise), so you can safely await non-native promises. That’s pretty cool.
  • And you can safely combine async / await with Promise.all() to wait for multiple asynchronous calls to return before moving ahead.

To show another example of async / await which is more complex (and better demonstrates the readability of the new syntax), here’s a streaming bit of code that returns a final result size.

Second promise-based example

function getResponseSize(url) {
  return fetch(url).then(response => {
    const reader = response.body.getReader();
    let total = 0;

    return reader.read().then(function processResult(result) 
    { 
      if (result.done) return total;

      const value = result.value;
      total += value.length;
      console.log("Promise-Based Example ", value);

      return reader.read().then(processResult);
    })
  });
}
Enter fullscreen mode Exit fullscreen mode

Matrix meme with Neo wondering how the world really works

Me, at the first glance of the code above.

It looks fairly elegant, but you have to stare at the code for a good bit, before you finally understand what it’s doing. Here’s that same code again but with async / await.

Second async / await example

async function getResponseSize(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();
  let result = await reader.read();
  let total = 0;

  while (!result.done) {
    const value = result.value;
    total += value.length;
    console.log("Data received ", value);
    // get the next result
    result = await reader.read();
  }

  return total;
}
Enter fullscreen mode Exit fullscreen mode

All right, now I’m getting a better idea of async / await syntax being more readable. That code is much easier read, I can agree with that. And the fact that I can combine it with Promise.all() is pretty nifty as well.

But to change all the promise-based calls in the entire codebase, I needed more convincing beyond readability for developers...I needed cold, hard, performance-driven benefits.

Office Space meme asking for benefits of refactoring all the promises

Convince me to refactor everything.

The silver bullet moment: when async / await won the day

And here’s the article that really changed my mind, from the team that actually builds and maintains the JavaScript V8 engine running all my Chrome browsers right now.

The article sums up how some minor changes to the ECMAScript specifications and the removal of two microticks of time, actually lets “async / await outperform hand-written promise code now,” across all JavaScript engines.

Yes, you read that right. The V8 team made improvements that make async / await functions run faster than traditional promises in the JavaScript engine.

That was all the proof I needed. It actually runs faster in the browser? Well sign me up.


Conclusion

Promises and async / await accomplish the same thing. They make retrieving and handling asynchronous data easier. They eliminate the need for callbacks, they simplify error handling, they cut down on extraneous code, they make waiting for multiple concurrent calls to return easy, and they make adding additional code in between calls a snap.

I was on the fence about whether rewriting all our traditional promises to use async / await was worth it, until I read (from the maintainers of the JavaScript V8 runtime engine) that they’d actually updated the engine to better handle async / await calls than promises.

If there’s performance improvements for our application that can be gained from something as simple as a syntax change to our code, I’ll take a win like that any day. My ultimate goal is always a better end user experience.

Check back in a few weeks, I’ll be writing about JavaScript, ES6 or something else related to web development.

If you’d like to make sure you never miss an article I write, sign up for my newsletter here: https://paigeniedringhaus.substack.com

Thanks for reading, I hope this helps you make a better informed decision about which syntax style you prefer more: promises or async / await so you can go forward with a consistent asynchronous data handling strategy in your code base.


Further References & Resources

Top comments (0)