Usually, when discussing Promises and async/await syntax, people frame it as an "either-or". You either devote to using one or the other and that's it.
But this is not at all true. Async/await was designed as a mechanism building upon (introduced earlier) Promises. It was meant as an enhancement, not as a replacement.
There are still things that are easier to do in Promise syntax. What is more, programming in async/await without understanding what is happening underneath might lead to actual inefficiencies or even errors.
So in this article we want to present Promises and async/await as mechanisms that work well together and support each other, allowing you to have a richer coding vocabulary at your disposal, making asynchronous programming easier to tame.
From async/await to Promises
So let's say you have an extremely basic function, returning some value:
function getFive() {
return 5;
}
It is a function that does not accept any arguments and returns a value that is a number.
For example in TypeScript, we would describe that in the following way:
function getFive(): number;
Now what happens when you declare the very same function as async
?
async function getFive() {
return 5;
}
You might think "well, it still simply returns a number, so the type of that function did change".
That's however false. This time it is a function that represents an asynchronous computation, even if everything in it's body is fully synchronous.
Because of that reason, it is no longer a function that simply returns a number. Now it instead returns a Promise, that itself resolves to a number.
In TypeScript syntax we would write:
function getFive(): Promise<number>;
So let's play around with this "async" function and prove that it is nothing more than a function that returns a Promise with a number inside.
Let's first call that function and check the type of the value that gets returned:
const value = getFive();
console.log(value instanceof Promise);
If you run this in Node.js or a browser, you will see true
printed in the console. Indeed, value
is an instance of a Promise
constructor.
Does this mean that we can simply use then
method to finally get the actual value returned by the getFive
function? Absolutely!
getFive().then(value => console.log(value));
Indeed, after running this code 5
gets printed to the console.
So what we found out is that there is nothing magic about async/await. We can still use Promise syntax on async functions (or rather their results), if it suits our needs.
What would be an example of a situation where we should prefer Promise syntax? Let's see.
Promise.all, Promise.race, etc.
Promises have a few static methods that allow you to program concurrent behavior.
For example Promise.all
executes all the Promises passed to it at the same time and waits for all of them to resolve to a value, unless any of the Promises throws an error first.
Because those static Promise methods always accept an array of Promises and we said that async functions in reality return Promises as well, we can easily combine usage of async functions with, for example, Promise.all
:
async function doA() {
// do some asynchronous stuff with await syntax
}
async function doB() {
// do some asynchronous stuff with await syntax
}
Promise.all([doA(), doB()])
.then(([resultFromA, resultFromB]) => {
// do something with both results
});
So we defined two asynchronous functions, inside of which we can use the full power of async/await.
And yet at the same time nothing stops us from using Promise.all
to execute both tasks concurrently and wait for both of them to complete.
It's use cases like this, that make some people kind of wary of async/await. Note that an inexperienced programmer would probably think that he really needs to use await
syntax on both of those async functions and he/she would end up with a code like this:
const resultFromA = await doA();
const resultFromB = await doB();
// do something with both results
But this is not the same thing at all!
In this example, we first wait for the function doA
to finish executing and only then we run doB
. If doA
takes 5 seconds to finish and doB
takes 6 seconds, the whole code will take 11 seconds to run.
On the other hand, in the example using Promise.all
, the code would run only 6 seconds. Because doA
and doB
would be executed concurrently, the whole code would only take as long as the time to wait for the last resolved Promise from an array passed to Promise.all
.
So we can clearly see that being aware of both async/await and Promise syntax has clear advantages. On one hand we can get more readable, "sync-like" code. On the other we can avoid traps of async/await by using specialized functions for dealing with Promises in more nuanced ways.
From Promises to async/await
So we have seen that even when we use async/await, we can "switch" to the world of Promises with no problem.
Is it possible to do that the other way? That is, can we use async/await syntax, when dealing with Promises that were created without the use of async functions?
The answer is - of course!
Let's construct a classical example of a function that returns a Promise that resolves with undefined
after given number of milliseconds:
const waitFor = (ms) => new Promise(resolve => {
setTimeout(resolve, ms);
});
Now - as we said - it is absolutely possible to use this classically constructed Promise in an async/await code. Let's say we want to create an async function that waits 500 milliseconds between two HTTP requests:
async function makeTwoRequests() {
await makeFirstRequest();
await waitFor(500);
await makeSecondRequest();
}
This example will work exactly as one would expect. We wait for the first HTTP request to finish, then we wait 500 milliseconds and just then we send a second HTTP request.
This shows you an example of a very practical use case, when you might first have to define a Promise wrapping some asynchronous behaviour and just then use it in a friendly async/await syntax.
What is a Promise for an async function?
Let's now ask ourselves a question: what is actually considered a Promise in that await somePromise
syntax?
You might - very reasonably - think that it can be only a native ES6 Promise. That is, it can only be an instance of a built-in Promise
object available in Node.js or browser environments.
But - interestingly - it turns out to be not really true.
await
works on things that can be much more loosely considered a "Promise". Namely, it will work on any object that has a then
property which is a function.
Weirdly, it doesn't really matter what that function does - as long as it is a function and it is under then
property on the object, it's considered a Promise by the async/await mechanism.
If an await
keyword gets called on an object like that, the then
of that object will be called, and async/await will itself pass proper callbacks as arguments to this function. Then the mechanism will (sic!) await until one of the callbacks passed to then
gets called.
This might seem complicated, so let's see it in action, step by step.
First we will create an empty object and call await
on it:
const notReallyAPromise = {};
async function run() {
const result = await notReallyAPromise;
console.log(result);
}
run();
If you run this snippet, you will see that an empty object - {}
- gets logged to the console. That's because if an object doesn't fulfill async/await's expectations of a Promise (does not have then
method), it will simply get passed through the await
syntax.
Note that this happens even if we add a then
property on our object, but still don't make that property a function:
const notReallyAPromise = {
then: 5
};
After this change, the code snippet will result with a { then: 5 }
in the console.
Just as before, our object simply gets passed through the await
syntax and simply gets assigned to result
variable, as usual.
But now let's change then
property to a function:
const notReallyAPromise = {
then() {}
};
This time nothing appears in console. That happens, because async/await mechanism detects that there is a function under the then
property of the object. So it treats this object as a Promise: it calls then
methods, passing to it proper callbacks. But because in this case we don't do anything with them, nothing happens.
Let's take the callback passed as a first argument and call it with some value:
const notReallyAPromise = {
then(cb) {
cb(5);
}
};
This time we will see 5
printed on the console. This happens, because this time we did call a callback passed by async/await mechanism. The value we called the callback with is then treated as a result from our "Promise".
If that's confusing to you, think about how you would use our notReallyAPromise
object without any special syntax:
notReallyAPromise.then(value => console.log(value));
This will also result in a 5
being printed to the console. Note how - even though our object is not an instance of a Promise
constructor, using it still looks like using a Promise. And that's enough for async/await to treat such object as a regular Promise instance.
Of course most of the time you will simply use await
syntax on regular, native Promises. But it is not a stretch to imagine a situation where you will use it on objects that are only "Promise-like" (often also called "thenables").
There exist libraries that use own Promise polyfills or some custom Promise implementations. For example Bluebird features custom Promise implementation that adds interesting, custom behaviors to a regular Promise.
So it's valueable to know that async/await syntax works out of the box not only with native Promises but also with a vast number of libraries, implementations and polyfills. Very often you don't have to wrap that custom code in a native Promise. You can simply use await
on it, as long as this code fulfills a simple contract of having a then
function, that we described earlier.
Conclusion
In this article we learned how the design of Promises and async/await syntax allows us to use both of those solutions interchangeably.
My goal was to encourage you to never just mindlessly use one solution, but rather to think about which one fits your current needs in the best way.
After all, as you just saw, at any point you can switch from one style to the other. So never feel locked to only one syntax. Expand your vocabulary to always write the cleanest and simplest code possible!
If you enjoyed this article, considered following me on Twitter, where I will be posting more articles on JavaScript programming.
Thank you for reading!
(Cover Photo by Cytonn Photography on Unsplash)
Top comments (0)