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.
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);
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.
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);
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);
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);
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}`));
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 singlePromise
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);
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);
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);
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"]
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
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 anAsyncFunction
object. An asynchronous function is a function which operates asynchronously via the event loop, using an implicitPromise
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 anawait
expression that pauses the execution of the async function and waits for the passedPromise's
resolution, and then resumes theasync
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);
});
}
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);
}
}
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.
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 throughPromise.resolve()
(for us, typically the.then(result)
resolution of the promise), so you can safelyawait
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);
})
});
}
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;
}
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.
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
- JavaScript Promises, MDN Docs
- Using Promises, MDN Docs
- Promise.all(), MDN Docs
- Async Await, MDN Docs
- AJAX, W3Schools Docs
- JavaScript Event Loop, MDN Docs
- Callbacks, JavaScript Info
- V8 Developers Blog, Faster Async Functions and Promises
Top comments (0)