Building a Mental Model Around JavaScript Promises

7 min read

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.

If you thought this article was helpful, consider donating, so I can continue serving you new programming content.