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: 2multiplyAll(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:
- We have now defined a function
handlePhoto()
, that has two parameters:error
andphoto
.error
will be populated with the reason that the image download failed in the event that happens, andphoto
will be populated with the image's data if the download is successful. In theerror
scenario, weconsole.error()
the string "Uh oh!" as well as the error message, and in the success scenario, weconsole.log()
the string "Got the photo!" and thephoto
data. - This
handlePhoto()
function is passed to thedownloadPhoto()
function as its callback function, so it only executes in relationship to the attempt to download the image. - 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 Promise
s 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 anundefined
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 thePromise
returned by.then()
gets fulfilled with thatPromise
's value as its value. - If the handler function returns an already rejected
Promise
, then thePromise
returned by.then()
gets rejected with thatPromise
's value as its value. - If the handler function returns another pending
Promise
, then the resolution/rejection of thePromise
returned by.then()
will be subsequent to the resolution/rejection of thePromise
returned by the handler. Also, the resolved value of thePromise
returned by.then()
will be the same as the resolved value of thePromise
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 thePromise
returned by.catch()
gets rejected with thatPromise
'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 aPromise
has settled. These include the following:.then()
: Instance method that functions based on either the success or failure of aPromise
..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 aPromise
.
- 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 aPromise
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.
- Pending: The default state of a
- Promise Chain: A technique that connects multiple
Promise
s sequentially, leveraging the fact thatPromise
instance methods return newPromise
s that are different from the originalPromise
and carry either the value from a successful executor function call or the error from a rejected executor function call.