This article examines Promises and async/await in JavaScript. It explains the benefits of async and await, and the value they add to asynchronous JavaScript compared to using Promises alone.
In this tutorial, we will cover why we need async/await
when we could achieve the same fit with JavaScript Promises, to this effect we’ll demonstrate why you should rather use async/await
whilst also exclusively drawing comparisons to their use cases.
With constantly emerging technologies and tools, developers often times wonder “why do we need this? What’s the advantage of this new tool? Does it solve a bigger problem” etc. Just like in this StackOverFlow question below:
Inside a function marked as async
, you are allowed to place the await
keyword in front of an expression that returns a Promise
. When you do, the execution is paused until the Promise
is resolved.
Before we dive into it, let’s take a moment to familiarize you with the async/await
style. First, async/await
makes the asynchronous code appear and behave like synchronous code. Being that it was built on top of Promises
, you could simply see it as a new way of writing synchronous code. Just like Promises
themselves, async/await
is equally non-blocking.
The purpose of async/await
functions is to simplify the behavior of using Promises
synchronously and to perform some behavior on a group of Promises
. Just as Promises
are similar to structured callbacks, one can say that async/await
is similar to combining generators and Promises
.
Basically, there are two keywords involved, async
and await
, let’s understand them better:
Putting the keyword async
before a function tells the function to return a Promise
. If the code returns something that is not a Promise
, then JavaScript automatically wraps it into a resolved promise with that value e.g when it returns an AsyncFunction
object:
1async function oddNumber() { 2 return 7; 3}
Then it’ll return a resolved Promise
with the result of 7
, however, we can set it to explicitly return a Promise
like this:
1async function evenNumber() { 2 return Promise.resolve(8); 3}
Then there’s the second keyword await
that makes the function even much better.
The await
keyword simply makes JavaScript wait until that Promise
settles and then returns its result:
let result = await promise;
Note that the await
keyword only works inside async functions, otherwise you would get a SyntaxError
. From the async
function above, let’s have an await
example that resolves in 2secs.
1async function evenNumber() { 2 let promise = new Promise((resolve, reject) => { 3 setTimeout(() => resolve("8"), 2000) 4 }); 5 6 let result = await promise; // pause till the promise resolves 7 8 alert(result); // "8" 9}
await
simply makes JavaScript wait until the Promise
settles, and then go on with the result. Meanwhile, as it waits, the engine carries on with performing other tasks like running scripts and handling events. Thus, no CPU resources will be lost.
async function name([param[, param[, ... param]]]) { statements }
Where:
A Promise which will be resolved with the value returned by the async
function, or rejected with an uncaught exception thrown from within the async
function.
Now that you have a fair understanding of how async/await
works and it’s syntax, let’s go ahead and dive into more awesome features that will convince you to adopt it:
Using the try/catch
construct, async/await
makes it relatively easy to handle both synchronous and asynchronous errors:
1const oddRequest = () => { 2 try { 3 getJSON() 4 .then(result => { 5 // this parse may fail 6 const data = JSON.parse(result) 7 console.log(data) 8 }) 9 // handle asynchronous errors 10 .catch((err) => { 11 console.log(err) 12 }) 13 } catch (err) { 14 console.log(err) 15 } 16 }
In this promise example, the try/catch
will not handle the error if JSON.parse
fails. This is because it’s happening inside a promise. Hence, we need to call .catch
on the promise, this will (hopefully) be more sophisticated than console.log
in your production-ready code. Now let’s simplify it with async/await
:
1const oddRequest = async () => { 2 try { 3 // this parse may fail 4 const data = JSON.parse(await getJSON()) 5 console.log(data) 6 } catch (e) { 7 console.log(e) 8 } 9 }
With async/await
, the catch block will handle parsing errors. As can be seen evidently, this is much more efficient, simple and less complicated.
async/await
handles conditionals in a much better fashion as compared to using Promises
. Often times, we want to fetch some data and then decide whether it should return that fetched data or get more data(make another call for more data) based on some value in the initially fetched data. Take for example :
1const getNumbers = () => { 2 return getJSON() 3 .then(firstNumber=> { 4 5 /*we can return "firstNumber" but then it needs an even number 6 so we'd make another call to return an even number then return it*/ 7 8 if (secondNumber.requiresEvenNumber) { 9 return getEvenNumber(firstNumber) 10 .then(secondNumber=> { 11 console.log(secondNumber) 12 return secondNumber 13 }) 14 } else { 15 console.log(firstNumber) 16 return firstNumber 17 } 18 }) 19 }
Now, this could get ridiculously complicated and confusing as you go on with values that require other values, however, async/await
makes it really simple to handle:
1const getNumbers = async () => { 2 const firstNumber = await getJSON() 3 if (firstNumber.requiresEvenNumber) { 4 const secondNumber = await getEvenNumber(data); 5 console.log(secondNumber) 6 return secondNumber 7 } else { 8 console.log(firstNumber) 9 return firstNumber 10 } 11 }
This is undoubtedly simpler, less ambiguous and direct. This is one of the advantages of the async/await
syntax, it handles conditionals in a seamless manner.
Consider a situation where we have a sequence of asynchronous tasks to be done one after another. For instance, loading scripts or returning Promises
from an API etc. It will result in a promise chain and we’ll have to split the functions into many parts to handle it. Consider this example:
1function getAPIData(url) { 2 return contentData(url) // returns a promise 3 .catch(e => { 4 return somethingElse(url) // returns a promise 5 }) 6 .then(v => { 7 return someOtherThing(v); // returns a promise 8 }) 9 .then(x => { 10 return anotherOtherThing(v) 11 }); 12 // the chain continues with more .then() handlers 13 }
Here the flow is:
contentData(url)
resolves.then
handler is called.then
handlercatch
handler handles itAs the result is passed along the chain of handlers, we can see even more functions being created to handle it.
However, that entire chain can be rewritten with a single async function:
1async function getAPIData(url) { 2 let payload; 3 try { 4 const v = await contentData(url); 5 payload = await anotherOtherThing(v) 6 } catch(e) { 7 v = await somethingElse(url); 8 } 9 return payload; 10 }
The way Promises
report errors are quite misleading and complicated as compared to async/await
. Consider a function that calls multiple Promises
in a chain, and somewhere down the chain an error is thrown:
1const fetch =() =>{ 2 return new Promise((resolve, reject) =>{ 3 resolve('{ "text": "some content" }') 4 }) 5 } 6 const foo = () =>{ 7 return fetch() 8 .then(result => fetch()) 9 .then(result => fetch()) 10 .then(() =>{ 11 throw new Error("Oopps") 12 }) 13 } 14 foo().catch(error =>{ 15 console.log(error) 16 })
The logcat reads:
This suggests that the error occurs from fetch()
whereas in essence, it doesn’t. As can be seen from the code, the error clearly is as a result of the foo()
method but there was no mention of it in the stack.
However, if we are to rewrite the code in async/await
syntax:
1const fetch = async() =>{ 2 return new Promise((resolve, reject) =>{ 3 resolve('{ "text": "some content" }') 4 }) 5 } 6 const foo = async() =>{ 7 await fetch() 8 await fetch() 9 throw new Error("Oopps") 10 } 11 foo().catch(error =>{ 12 console.log(error) 13 })
The logcat here reads:
Now, this points exactly to the foo()
method and better still, it points to the exact location of the error in the codebase.
This is an extension of the error reporting function above however with more attention to performance and memory efficiency.
Imagine a scenario where a function doe
is called when a call to an asynchronous function boo
resolves:
1const foo = () => { 2 boo().then(() => doe()); 3};
When foo
is called, the following happens synchronously:
boo
is called and returns a promise that will resolve at some point in the future..then
callback (which is effectively calling doe()
) is added to the callback chain.After that, we’re done executing the code in the body of function foo
. Note: foo
is never suspended, and the context is gone by the time the asynchronous call to boo
resolves. Imagine what happens if boo
(or doe
) asynchronously throws an exception. The stack trace should include foo
, since that’s where boo
(or doe
) was called from, right? How is that possible now that we have no reference to foo
anymore? That’s exactly the same case we had on the third step above.
To make it work, the JavaScript engine needs to do something in addition to the above steps,
it also captures and stores the stack trace within foo
while it still has the chance.
Capturing the stack trace takes time (i.e. degrades performance); storing these stack traces requires memory.
Here’s the same program, written using async/await
instead of vanilla promises:
1const foo = async () => { 2 await boo(); 3 doe(); 4};
With await
, there’s no need to store the current stack trace — it’s sufficient to store a pointer from boo
to foo
. During the execution of boo
, foo
is suspended, so its context is still available. If boo
throws an exception, the stack trace can be reconstructed on-demand by traversing these pointers. If doe
throws an exception, the stack trace can be constructed just like it would be for asynchronous function, because we’re still within foo
when that happens. Either way, stack trace capturing is no longer necessary — instead, the stack trace is only constructed when needed. Storing the pointers requires less memory than storing entire stack traces
The async/await
syntax is generally very clean and concise. Considering our previous examples, you can look at how much code we didn’t have to write. it’s clear we saved a decent amount of code (thereby saving us time and effort). We didn’t have to:
.then
These small advantages add up quickly, to enhance both the code structure and the javascript engine functions.
Compared to using Promises
directly, not only can async and await make the code more readable for developers — they enable some interesting optimizations in JavaScript engines, too! As we have seen with memory and performance.
The fundamental difference between await
and vanilla Promises
is that await X()
suspends execution of the current function, while promise.then(X)
continues execution of the current function after adding the X
call to the callback chain. In the context of stack traces, this difference is pretty significant.
When a Promise
chain throws an unhandled exception at any point, the JavaScript engine displays an error message and (hopefully) a useful stack trace. As a developer, you expect this regardless of whether you use vanilla Promises
or async
and await
.