Building a Mental Model Around JavaScript Promises
In this article, we'll look at building a mental model for using promises to make your applications more efficient.
We are going to be using JavaScript in our examples, but the concepts apply to any modern programming language: Java, Python, Go, etc.
What Are Promises?
Promises are a way for the program to say:
I can't immediately get what you need right now, but I'll go and get it and let you know when I have it.
Here are some tasks that take a relatively long time to complete:
- Reading from disk
- Retrieving data over the network via HTTP, RPC, etc.
- Computationally expensive tasks like deserialization
Our programs could just say:
OK, I'll just wait and do nothing while you go and read from disk, retrieve data over the network, etc.
A promise allows your computer to work on other things while this operation is happening, freeing up compute resources on your machine.
When the promise is complete, your program will be informed that it can resume execution where it left off.
How to Work with Promises
The easiest way to work with promises is with the async
/await
syntax.
We'll use the free and open JSON Placeholder API for these examples. Feel free to try them out yourself.
async function fetchPosts() {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
return response.json();
}
By adding the async
keyword to our function fetchPosts
,
we tell JavaScript that this function will return a Promise
.
We won't have the data immediately because we need to go and fetch it from the REST API.
We use the await
keyword to signal that we will wait until the result
is fetched.
The mental model we can have here is that the program runs synchronously, meaning that one line executes after another in sequential order.
That may not be happening under the hood in your operating system. It could be working on any number of other tasks: Handling JavaScript events in the UI, loading an ad from Google, playing songs on Spotify.
You don't need to worry about any of that though. All you need to know is
when you use the await
keyword in front of an asynchronous function such as
fetch
, it is as if the code is running synchronously.
Speeding Up Your Programs with Promise.all
Now you've learned about the basics of promises. Let's look at how we can speed up your programs even further.
Let's create a new function that will pull the comment data in addition to the post data for a given post ID.
We'll use the familiar async
/await
syntax from before so that we can
think about our program as if it were running synchronously.
async function fetchPostWithComments(postId) {
const postResponse = await fetch(
`https://jsonplaceholder.typicode.com/posts/${postId}`,
);
const post = await postResponse.json();
const commentsResponse = await fetch(
`https://jsonplaceholder.typicode.com/comments?postId=${postId}`,
);
const comments = await commentsResponse.json();
return {
post,
comments,
};
}
Looks good, right? Almost.
The problem here is we are waiting for the post data to be fetched and deserialized before doing the same for the comments.
The issue is doing the work to fetch the comments doesn't depend on the results of fetching the post.
In other words, we can fetch the post and fetch the comments in parallel. And we can
do so with Promise.all
.
Let's refactor our program. We'll split the fetching of the post and comments into two separate
functions. Then, we'll create a function that will let us fetch both in parallel using Promise.all
.
The Promise.all
function accepts an array P
of n
promises and executes them in parallel.
It returns an array R
of n
results where each result R[i]
is the result of P[i]
.
async function fetchPost(postId) {
const postResponse = await fetch(
`https://jsonplaceholder.typicode.com/posts/${postId}`,
);
return postResponse.json();
}
async function fetchComments(postId) {
const commentsResponse = await fetch(
`https://jsonplaceholder.typicode.com/comments?postId=${postId}`,
);
return commentsResponse.json();
}
async function fetchPostWithComments(postId) {
const [post, comments] = await Promise.all([
fetchPost(postId),
fetchComments(postId),
]);
}
Proof that Promise.all
is Faster
Let's say fetchPost
takes p
seconds to complete while fetchComments
takes c
seconds to complete.
Our previous implementation would take p + c
seconds to complete, while the new implementation
would take max(p, c)
seconds to complete.
Since max(p, c)
is equal to either p
or c
, it follows that max(p, c) < p + c
. ◼️
The point to remember is this:
If 2 or more promises are mutually exclusive, then execute them in parallel with Promise.all
.
Error Handling
In the examples we looked at previously, we made an assumption that everything would go well.
We'd go and fetch the data and everything would just work. But we can't rely on that all the time.
We want to handle the case when something goes wrong.
What if we pass in a
postId
that does not exist on the server, and we get a 404 error?
We can guard against these cases using try
/catch
blocks.
The way you handle error cases will depend on the business rules of your app.
For demonstration purposes,
let's refactor fetchPost
to return null
and log a warning if the promise is rejected.
async function fetchPost(postId) {
try {
const postResponse = await fetch(
`https://jsonplaceholder.typicode.com/posts/${postId}`,
);
return postResponse.json();
} catch (err) {
console.warn(`An error occurred in fetchPost(${postId}): ${err.message}`);
return null;
}
}
As an exercise, refactor fetchComments
to log a warning and return []
in the error case.
As a final note to reiterate, there's not a one-size-fits-all approach to error handling. You need to think about how you want to relay the fact there was an error to your customers.
This can be done in a number of ways, and should be done thoughtfully as dealing with software errors can be frustrating to customers.
Ignoring Promises
Sometimes, you may want to kick off a process, but you don't necessarily need to block execution of the program while it's happening.
In this case you could consider omitting await
when you call the promise-returning function.
For example, let's pretend we're creating a createComment(postId, message)
function. We won't concern
ourselves with the all the details of the implementation. But let's say we want to fire off a push notification
to the author of the post we're commenting on.
We'll do so with a sendCommentNotification(postId, message)
function. This function would run asynchronously to
send the push notification.
We don't need the result of sendCommentNotification
, so there's no need for us to await
the result.
Instead, we'll catch
the error case and log an error so that we have some visibility into any issues
that may occur.
We can do this by chaining catch
onto the promise itself.
async function sendCommentNotification(postId, message) { ... }
async function createComment(postId, message) {
// ✂️ Business logic for creating the comment omitted
sendCommentNotification(postId, message).catch((err) => {
console.error(
`An error occurred in sendCommentNotification(${postId}): ${err.message}`,
);
});
return true;
}
We can kick off the process of sending the push notification without blocking the thread.
createComment
can return true
while sending of the notification is still ongoing.
If we were to have used await
in front of sendCommentNotification
, we would have had to have
waited for that promise to be resolved or rejected before createComment
could return true
.
Conclusion
Promises are a powerful concept that are used all over the place in modern applications.
Hopefully this article helped you understand how they work and how you can leverage them to make your apps more efficient.
Recapping everything we touched on in this article:
- Use
async
/await
syntax to program asynchronous code as if it were synchronous. - Use
Promise.all
to execute mutually exclusive asynchronous functions in parallel. - Use
try
/catch
to handle error cases gracefully. - Consider not using
await
if you don't need the result of the promise.