Promise

Imagine it's Friday night and you are having some friends over to watch a movie. You've cleaned up your living room, know which movie you're going to watch and how you're going to stream it, and your friends are on their way over. Your first friend arrives and they're super excited to watch the movie, but they say that they're hungry. Oh no! Out of everything you have planned, you forgot to prepare food to feed everyone. But not to worry, there's a pizza place just a few minutes down the street. So you hop onto your computer, go to the pizza restaurant's website, and place an order for two pizzas to be delivered.

After selecting your pizzas and entering your credit card info and the tip for the delivery driver, you close your computer and wait for the rest of your friends and your pizzas to arrive. You're happy because you've offloaded the work of preparing your meal to the pizza restaurant, and you can concentrate on entertaining your friends. The pizza restaurant can prepare your food and deliver it as soon as it's ready, or if something goes wrong, like they're out of ingredients, they can contact you to refund your money so you can figure something else out for dinner.

This is analogous to systems relationships that come up regularly in web development:

  • There's a service that provides data, like a weather API, that gets transferred over a network. This data transfer takes time. In the analogy above, the service is the pizza restaurant. The data is the pizza.
  • There's an application that consumes that data once it is ready, like a webpage that displays the user's local weather report. In the analogy above, this is you and your friends that want to eat the pizza for dinner.
  • Lastly, there needs to be a way for the "consuming application" to talk to the "producing service", to say, "Hey, we want weather data for our ZIP code, which is 12345." In the analogy above, this connection is the pizza restaurant's website where you placed your order. In JavaScript, the thing that connects the consumer and the service is called a Promise.

Synchronous vs. Asynchronous JavaScript

Before getting more into what a Promise is, it's important to understand the problem that it solves, and to do that we need to understand Synchronous JavaScript and Asynchronous JavaScript.

Say you have the following function:

function multiplyAll() {
  return Array.prototype.reduce.call(arguments, (a, b) => {
    return a * b;
  });
}

multiplyAll(1, 2);
// Expected result: 2
multiplyAll(1, 2, 100);
// Expected result: 200

We have a function, multiplyAll(), that uses .reduce() to multiply any and all arguments passed to it and returns the product of that operation.

When we run multiplyAll(1, 2), it multiplies 1 by 2 and returns 2 immediately.

When we run multiplyAll(1, 2, 100), it multiplies 1 by 2 by 100 and returns 200 immediately.

This is an example of Synchronous JavaScript, in that the operations complete as soon as the browser is told to execute them.

In the pizza restaurant analogy above, there are multiple things going on at once. Your friends are arriving, so you need to entertain them. You place the order for the pizza, and the pizza restaurant goes off and prepares the pizza and handles the delivery, so you can focus on your guests and getting the movie set up. This is a poor example of an asynchronous operation.

Asynchronous JavaScript does not run immediately, and has some operations that take time to complete. Async code is all about managing future values that have not yet been delivered, and writing code that handles those future values. Let's do some pseudocode to illustrate what Asynchronous JavaScript might look like. Thank you to the author of callbackhell.com for this example, which I am shamelessly all but ripping off:

function downloadPhoto(url) {
  const photo = url.file.data;
  return photo;
}

const photo = downloadPhoto('https://joeyreyes.dev/images/barbeque.jpg');
console.log(photo);
// Made up expected result: undefined

So in our little pseudocode example we have a function, downloadPhoto that accepts a parameter url, which is the URL of the image we want to retrieve. Within that function we grab that image's data, save it to a variable named photo, and return photo.

Then we actually call downloadPhoto() passing it a made up URL for a picture of some barbeque, and save the result to a variable named photo. Then we console.log() photo, and the result is undefined.

This happens because it takes time for the browser or server to actually go out and get the photo data and assign that as the value for photo. The program therefore runs console.log on the defined photo variable before the variable has any actual value. We're trying to do some Asynchronous programming but it's coded as a Synchronous task.

Callbacks

So how might we make this example work? Let's refactor using a callback function. While the photo is downloading (which can take an unknown amount of time, and may not ever actually happen), we don't want our program to stop running. This is called blocking and it's not a good thing.

So we will store instructions for what to do when either the photo downloads or doesn't download into a function. This function is called a callback function.

downloadPhoto('https://joeyreyes.dev/images/barbeque.jpg', handlePhoto);

function handlePhoto(error, photo) {
  if (error) {
    console.error('Uh oh!', error);
  } else {
    console.log('Got the photo!', photo);
  }
}

console.log('Going to get the photo now');

A few things are important to note here:

  1. We have now defined a function handlePhoto(), that has two parameters: error and photo. error will be populated with the reason that the image download failed in the event that happens, and photo will be populated with the image's data if the download is successful. In the error scenario, we console.error() the string "Uh oh!" as well as the error message, and in the success scenario, we console.log() the string "Got the photo!" and the photo data.
  2. This handlePhoto() function is passed to the downloadPhoto() function as its callback function, so it only executes in relationship to the attempt to download the image.
  3. In this example, the first thing that probably happens is the console.log() message "Going to get the photo now", which is at the bottom of the program.

Callback Hell

Things not happening in order is one of the confusing things about writing callback-based asynchronous code. Further confusion can occur when you have multiple chained asynchronous operations. Sometimes you need to chain together operations, like retrieving a date, then retrieving some data based on that date, then retrieving more data based on that first data, and your code starts to grow into a pyramid that looks like:

detDate(args, function() {
  getDataBasedOnDate(args, function() {
    getMoreDataBasedOnFirstData(args, function() {
      // ...etc.
    })
  })
})

Now imagine each of those callback functions also having their own error/success blocks! Before you know it, your program has gotten to be an unreadable mess of nested callback functions. It's a big, ugly pyramid of code, nothing runs in the order it's actually read in, and error tracing is very difficult. This is a scenario that is commonly called callback hell.

Promise to the Rescue

Promise was added to the language as a solution for callback hell. In JavaScript, a Promise is an object representing the eventual completion or failure of an asynchronous operation. Data takes time to travel across networks, and it will either eventually come back or it won't. As such, a Promise is an asynchronous technique that represents a promise to return some kind of value, and instructions for what to do if that data request fails and no data is delivered.

There are four states that a Promise can be in:

  • Pending: This is a Promise's initial state, which is to say that it has not yet fulfilled or rejected.
  • Fulfilled: If the Promise operation completes successfully, then it is in a fulfilled state, and it carries with it a value, which might be the data retrieved from the API call.
  • Rejected: If the Promise operation fails, then it is in a rejected state, and it carries with it an error, which is the reason that the operation failed.
  • Settled: The Promise is considered settled when it is either fulfilled or rejected. A settled state is simply the opposite of the initial pending state

Let's Write a Promise

So what does a Promise actually look like? Well let's take it step by step, building out the three things we talked about earlier that are needed in asynchronous code: the producing code, the consuming code, and the Promise that connects them.

The Promise (The Connection)

First, let's define the Promise using a constructor:

const data = new Promise();

On its own, this code doesn't do anything. A Promise accepts as its argument a function that will eventually produce the result or rejection.

The Executor (The Producing Code)

const data = new Promise((resolve, reject) => {

});

The function that has the resolve and reject parameters is the producing code, or the code that retrieves the data. This function is called the executor. When we call new Promise(), the executor function runs automatically.

The resolve and reject parameters are callbacks that are provided automatically by the JavaScript language. Depending on the result of whatever the executor is trying to do, it will either call the resolve(value) callback or the reject(error) callback. This corresponds to moving the Promise from the "pending" state to either the "fulfilled" state or the "rejected" state.

So let's play around with those states. It's difficult to illustrate the time-based nature of a Promise, but we can use a setTimeout() function that mimics an operation that takes a little bit of time.

const data = new Promise((resolve, reject) => {
  setTimeout(() => resolve("done"), 2000);
});

We've now added the setTimeout() to our data Promise. In this instance we are resolving the string "done" after two seconds.

Instance Methods (The Consuming Code)

Now that we have a Promise resolving, we need to write the "consuming code" that will actually use the result of that Promise. This is where .then(), .catch(), and .finally() methods come into play. These three methods, known as instance methods, append callback functionality to a Promise.

.then()

.then() is the most important method for consuming a Promise. It takes two arguments: a callback function for the fulfilled state of the Promise and a callback function for the rejected state of the Promise.

const data = new Promise((resolve, reject) => {
  setTimeout(() => resolve("done"), 2000);
});

data.then(
  (result) => console.log(result),
  (error) => console.error(error)
);
// Expected result: "done" after two seconds

In the example above, we are using data.then() to handle the results of our Promise when the Promise is in its "settled" state. If the Promise is "fulfilled" the first callback function simply console.log()s the result. In this case, this is the "done" string that we see after two seconds.

However, if the Promise is "rejected" the second callback console.error()s the error, or the reason given for rejection. Let's show what that might look like:

const data = new Promise((resolve, reject) => {
  setTimeout(() => reject("error"), 2000);
});

data.then(
  (result) => console.log(result),
  (error) => console.error(error)
);
// Expected result: "error" after two seconds

In this code we will simply see the string "error" after two seconds.

The two callback functions within .then(), are known as handlers, as they handle the result of the Promise.

It's important to note that .then() itself returns another Promise, which allows for multiple Promises to get chained together (more on that later). However, the behavior and content of the Promise that .then() returns depends on what the handler function returns:

  • If the handler function returns a value, then the Promise returned by .then() gets resolved with the returned value as its value.
  • If the handler function returns nothing, then the Promise returned by .then() gets resolved with an undefined value.
  • If the handler function throws an error, then the Promise returned by .then() gets rejected with the thrown error as its value.
  • If the handler function returns an already fulfilled Promise, then the Promise returned by .then() gets fulfilled with that Promise's value as its value.
  • If the handler function returns an already rejected Promise, then the Promise returned by .then() gets rejected with that Promise's value as its value.
  • If the handler function returns another pending Promise, then the resolution/rejection of the Promise returned by .then() will be subsequent to the resolution/rejection of the Promise returned by the handler. Also, the resolved value of the Promise returned by .then() will be the same as the resolved value of the Promise returned by the handler.

.catch()

The .catch() instance method also returns a Promise, but this method only deals with rejected cases. It behaves the same as calling .then(undefined, onRejected) (where onRejected is the error handler). As a matter of fact, calling .catch(onRejected) internally calls .then(undefined, onRejected). Let's show an example of .catch() usage:

const data = new Promise((resolve, reject) => {
  setTimeout(() => reject("error"), 2000);
});

data.catch((error) => console.error(error));
// Expected result: "error" after two seconds

Since internally .catch() is just syntactic sugar on the .then() instance method, this method also produces a Promise, and abides by a subset of the .then() rules:

  • If the handler function throws an error, then the Promise returned by .catch() gets rejected with the thrown error as its value.
  • If the handler function returns an already rejected Promise, then the Promise returned by .catch() gets rejected with that Promise's value as its value.
  • If something other than these two things happens, then the Promise returned by .catch() is simply resolved.

Let's take a look at an example of this final case, in which a Promise would get resolved:

// This Promise will not call the `onReject` handler in `.catch()`
const promise1 = Promise.resolve("success!");

promise1.catch((error) => {
  // This is never called
  console.error(error);
});
// Expected result: Nothing, the `Promise` resolves so the `.catch()` doesn't run

.finally()

The third instance method, .finally() offers a chance to run some code or operation regardless of whether a Promise resolved or rejected. Calling .finally(f) would be similar to calling .then(f, f) (where both success and failure handlers are the same function), just in a cleaner manner. This method is good for performing code/memory cleanup, for example stopping the animation if you had a "data is loading" animation. Really, it's for anything you need to take care of after the Promise is settled, no matter the outcome of that Promise.

Here's an example:

function randomResolveOrReject() {
  return new Promise((resolve, reject) => {
    if (Math.random() > 0.5) {
      resolve('Success!');
    } else {
      reject(new Error('Failure!!'));
    }
  });
}

randomResolveOrReject()
  .then((successMessage) => {
    console.log(successMessage);
  })
  .catch((err) => {
    console.error(err);
  })
  .finally(() => {
    console.log("Finished running!");
  });
/*
 * Expected result:
 * "Success!" or "Failure!!", depending on `Math.random()` value
 * "Finished running!" will always show
 */

In this code we have a Promise that delivers a success or a failure rejection at random. If there's success, it console.log()s "Success!". If there's failure, it console.error()s "Failure!!". But the main point here is that the .finally() instance method always runs, no matter what.

It should be clarified that .finally(f) is not exactly an alias of .then(f, f). The key difference is that .finally() accepts a generalized handler function as its sole argument, which will run regardless of the Promise's success or failure. This function will in fact pass the success or failure of its operations onto the next instance method. What?!

Despite its name, .finally() does not actually have to be the last instance method run in a chain. It can be used at any point in the chain of instance methods when you want to do something regardless of success or failure, but it will still pass that success or failure status onto any subsequent instance methods.

For example, let's pass a success through .finally() onto a .then() method:

function randomResolveOrReject() {
  return new Promise((resolve, reject) => {
    resolve('Success!');
  });
}

randomResolveOrReject()
  .finally(() => {
    console.log("This always runs!");
  })
  .then((successMessage) => {
    console.log(successMessage);
  })
  .catch((err) => {
    console.error(err);
  })
  .finally(() => {
    console.log("Finished running!");
  });
/*
 * Expected result:
 * "This always runs!"
 * "Success!"
 * "Finished running!"
 */

Now let's pass a failure through .finally() onto a .catch() method:

function randomResolveOrReject() {
  return new Promise((resolve, reject) => {
    reject(new Error('Failure!!'));
  });
}

randomResolveOrReject()
  .finally(() => {
    console.log("This always runs!");
  })
  .then((successMessage) => {
    console.log(successMessage);
  })
  .catch((err) => {
    console.error(err);
  })
  .finally(() => {
    console.log("Finished running!");
  });
/*
 * Expected result:
 * "This always runs!"
 * "Failure!!"
 * "Finished running!"
 */

Like .then() and .catch(), .finally() returns a Promise whose handler is set to the function passed as an argument.

Chaining Promises

Now remember back when we had the big callback hell pyramid where we needed to do several asynchronous tasks in sequence, with a function depending on the function that ran before it, and so on? This behavior can be accomplished (in much cleaner and easier to read fashion) using Promises in something called a promise chain.

The magic occurs in the fact that each of the instance methods above (.then(), .catch(), .finally()) each return a new Promise, different from the original one. This allows us to retrieve some data and then subsequently use that retrieved data to produce more data.

const promise1 = new Promise((resolve, reject) => {
  setTimeout(() => resolve(1), 1000);
}).then(result => {
  console.log(result);
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(result * 2), 1000);
  })
}).then(result => {
  console.log(result);
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(result * 2), 1000);
  })
}).then(result => {
  console.log(result);
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(result * 2), 1000);
  })
})
/*
 * Expected result:
 * 1 (after one second)
 * 2 (after two seconds)
 * 4 (after three seconds)
 */

In this little example, we are calling a Promise that resolves the number 1 after one second and returns a new Promise. This new Promise resolves the product of 1 multiplied by 2, giving us 2 after another second and returning a new Promise. This new Promise resolves the product of 2 multiplied by 2, giving us 4 after another second.

There is a lot more to promise chains than this blog post is intended to cover, like handling rejections. It's a very complicated topic that is outside the scope of a single blog post meant to introduce you to the idea of a Promise and all of its parts, but maybe one day soon I'll do a deep dive into promise chains. I'll wrap this post up for now, but if I missed anything or you want something to be further explained, please reach out to me at joey@joeyreyes.dev. Thank you so much for reading!

Glossary

  • Synchronous JavaScript: JavaScript code that can run successfully as soon as the environment/engine/browser is told to execute it.
  • Asynchronous JavaScript: JavaScript code that needs to wait until some operation has completed in order to run correctly. This operation can take an unknown amount of time and may either succeed or failure. Asynchronous code is all about handling that success or failure of a value delivered in the future.
  • Callback: A type of function used in Asynchronous JavaScript that runs after an asynchronous operation has completed.
  • Callback Hell: A coding anti-pattern in JavaScript in which numerous asynchronous tasks have been written using a series of callback functions in such a way that it's difficult to read and do error-tracing on.
  • Promise: A special kind of asynchronous task constructor introduced in ES5 that uses a non-callback based pattern and syntax, helping to eliminate callback hell. Promise is an asynchronous technique that represents a promise to return some kind of value, and instructions for what to do if that data request fails and no data is delivered.
  • Executor: "Producing code," or the operation within a Promise that will take time and return either a success or failure. An example would be an API call to retrieve some data.
  • Instance Method: "Consuming code", or functions that are appended to a Promise that do something when a Promise has settled. These include the following:
    • .then(): Instance method that functions based on either the success or failure of a Promise.
    • .catch(): Instance method that functions only on the failure of a function.
    • .finally(): Instance method that always functions, regardless of the success or failure of a Promise.
  • Handler: A callback function passed as an argument to an Instance Method that handles the results of a Promise.
  • Promise State: A Promise's status. There are four states a Promise can be in:
    • Pending: The default state of a Promise that has not fulfilled or rejected.
    • Fulfilled: The state of a Promise whose executor function has returned successfully; carries a value.
    • Rejected: The state of a Promise whose executor has returned unsuccessfully; carries an error.
    • Settled: The state of a Promise that has either fulfilled or rejected, this is the opposite state of pending.
  • Promise Chain: A technique that connects multiple Promises sequentially, leveraging the fact that Promise instance methods return new Promises that are different from the original Promise and carry either the value from a successful executor function call or the error from a rejected executor function call.

Sources / Further Reading: