Key takeaways:
- Promises can run code after some other code has completed
async/await
is just syntactic sugarPromise.all
is great for parallel tasks
What is a Promise?
A promise is some task that we can use to chain together later tasks.
For example, imagine telling your friend how to make tea. Here's one way to do it:
Say to your friend, "Boil water."
Stand next to them awkwardly, waiting for them to finish boiling water
Say to your friend, "Pour the hot water into a cup and add a teabag."
But it's usually easier to provide all the instructions right away, without having to wait:
Say to your friend, "Boil water... and then when you are done with that you should pour it into a cup and add a teabag".
With this second approach, we don't have to wait around watching our friend boil water and can go off and do something else. Sooner or later, our friend will complete the whole task and have a lovely cup of tea.
The key insight here is recognizing how we can give instructions that will only be executed after some other instruction has been completed. Promises are the mechanism by which we provide instructions to execute later.
In the language of promises, we could say that "boil water" is a function that returns a Promise of boiled water and we chain on a then
to do something with that boiled water when it is ready.
Promise Syntax
Lets look at a more practical programming example. Imagine we have a website where users can 'favourite' products. We might store user information in a database, which can only be accessed asynchronously. To get a user's wishlisted products, we might have some code that looks like:
const getProductWishlistForUser = (userId: string): Promise<Array<Product>> =>
fetchUser(userId).then((user) => user.wishlist);
fetchUser
is some function that accesses our database. It might be rather slow, or perhaps need to run over a network request, so it can't return synchronously. Instead, it returns a promise of a user, or in TypeScript syntax, that would be Promise<User>
. To do something with that user
when the Promise
is ready, we use .then
. Every Promise
resolves to some result eventually, and the .then
method is how you indicate what you want to do with that result when it is ready. In this example, we are saying, "get the user and then return just the wishlist on that user.
Async/Await Syntax
The async/await
syntax is a more recent addition to JavaScript. Here's that same example but rewritten using async/await
:
const getProductWishlistForUser = async (
userId: string
): Promise<Array<Product>> => {
const user = await fetchUser(userId);
return user.wishlist;
};
Some people find this syntax easier to read. The await
keyword converts a Promise
into its resolved type. In this case, turning Promise<User>
into just User
. We can then access the User
object directly without needing to be inside a .then
callback. But it is very important to understand that these two examples will be executed in exactly the same way. Notice how the return type of this function is still a Promise
. Every async
function is wrapped in a Promise
automatically. This can be occasionally surprising and misleading. But if you understand that fundamentally async/await
is just syntactic sugar for promises, then you won't be confused.
Parallelism
A common mistake I see with async/await
is waiting for promises to resolve one after the other, even when they could be running in parallel. Consider this example:
const getAllUsersAndProducts = async () => {
const users = await fetchAllUsers();
const products = await fetchAllProducts();
return { users, products };
};
The problem here is that we fully wait for the user request to complete before even starting the product request. If I had to boil two pots of water, I wouldn't wait for one to begin boiling before starting the second one, I would start both at the same time. There isn't a direct way to do this with the async/await
syntax, but we can achieve this with Promise.all
:
const getAllUsersAndProducts = () => {
return Promise.all([fetchAllUsers(), fetchAllProducts()]).then(
([users, products]) => ({ users, products })
);
};
Promise.all
takes an array of promises and returns a single combined promise. You can use this to start multiple asynchronous tasks at the same time, and then chain on a later task only when all the initial tasks have resolved.
In this case, we can expect the code to run roughly two times faster, because we are waiting for two things running at the same time rather than having to wait for them to finish one after the other. The benefit of this approach is even more significant when handling three or more promises that can run in parallel.
So for situations like this, it seems the promise syntax is the better choice.
Async/Await shared scope
Promises aren't always better than async/await
, however. There's one thing that async/await
can do, which promises can only do with great difficulty. Suppose that we have an orderId
which we can use to look up order details and also lookup details about the user that placed the order. We want to combine this information to display the user's name next to the id of the product they have ordered. We might try to do this with the following code:
const getOrderSummary = (orderId: string) => {
return (
fetchOrder(orderId)
.then((order) => fetchUser(order.userId))
// Error on the next line because `order` is not in scope!
.then((user) => `${user.name} | ${order.productId}`)
);
};
But as you can see this doesn't work because each individual .then
callback has its own isolated scope. We can't access variables in one scope from a different scope. There is a way to get around this by passing along the variable with the chained task:
const getOrderSummary = (orderId: string) => {
return fetchOrder(orderId)
.then((order) => Promise.all([order, fetchUser(order.userId)]))
.then(([order, user]) => `${user.name} | ${order.productId}`);
};
But this is very unusual and I find very difficult to read and maintain. If however we use async/await
instead, there is no problem at all:
const getOrderSummary = (orderId: string) => {
const order = await fetchOrder(orderId);
const user = await fetchUser(order.userId);
return `${user.name} | ${order.productId}`;
};
Because the await
keyword exposes the resolved value in the same scope that we start in, it is much easier to combine results from different stages of the asynchronous task.
Hybrid: Getting the best of both
So we've seen at least one example where a Promise
is best, and another where async/await
is best. How can we get the best of both?
Since Async/Await is just syntactic sugar for promises, we can mix and match the two syntaxes together. This unlocks the power of parallel promises with Promise.all
while also getting the shared variable scoping of await
like so:
const getOrderDetails = (orderId: string) => {
const order = await fetchOrder(orderId);
const [user, product] = await Promise.all([
fetchUser(order.userId),
fetchProductDetails(order.productId),
]);
return `${order.timestamp} | ${user.name} | ${product.name}
};
In this final example, we fetch an order by its id, then in parallel we fetch user and product details. We return a summary including the order, user, and product information.
Summary
Many people like to argue about Promises vs. Async/Await, but in truth, they are different tools with different specialties. You should learn to understand and use both and apply each where it will be most effective.
Take care, Rupert