Overview of JavaScript Promises
Language
- unknown
by James Smith (Golang Project Structure Admin)
Have you ever found yourself writing JavaScript code that becomes difficult to read because there are so many nested callbacks?
Or do you get frustrated when your browser becomes unresponsive as it struggles to perform long-running operations in client-side code?
Well, promises are designed to solve those problems!
In this post we will discuss exactly what promises are and how they work in JavaScript.
Table of Contents
What Are Asynchronous Operations?
Before we discuss promises, it is useful to understand the more general concept of what asynchronous operations are.
To give a short and simple definition, an asynchronous operation is any operation a computer can perform that does not complete immediately. Instead, the operation will not be expected to finish until some point in the future.
Asynchronous operations are particularly important in JavaScript because they allow us to perform long-running operations without blocking the main thread of execution.
On the other hand, if a JavaScript operation that blocks takes a long time to complete, it can cause the browser window that is running it to become unresponsive.
Remember That Concurrency Isn’t the Same as Parallelism
Unlike in Go, JavaScript did not have native support for parallel programming until Web Workers were introduced, so most JavaScript code is single-threaded by default.
However, as Rob Pike reminded us when talking about Go, just because code cannot operate in parallel does not mean that it can’t be written in a concurrent style. The same principle applies to JavaScript.
Whenever we write asynchronous code, we are working with concurrency.
What Kind of Asynchronous Operations Can JavaScript Handle?
Asynchronous operations are very commonly used in web development for routine tasks.
Some examples are given in the list below:
- downloading data from a server (using the
fetch
function); - uploading files to a server (using the
XMLHttpRequest
constructor function); - performing interactive animations (using the
requestAnimationFrame
function); - scheduling code to run at a later time (using the
setTimeout
orsetInterval
functions); - handling user input (using event listeners on DOM elements);
- getting information about a user’s location (using the
Geolocation.getCurrentPosition
function); - writing to a database (using the IndexedDB API).
How Are Callback Functions Used?
A callback function is any function that is passed as an argument to another function and is invoked by that function at a later time.
In other words, a callback function is a function that is “called back” at a later time when an operation has completed.
So when an asynchronous operation is initiated, it may be passed a callback function that will be called when the operation completes.
The callback function will be given any results produced by the operation as arguments, which can be used to update the global state or the DOM. On the other hand, if there is no result, the callback function will be called with no arguments.
The setTimeout
function shown below runs asynchronously, and its callback runs as soon as it has completed:
setTimeout(() => {
console.log("the timeout has completed");
}, 500);
The second argument to the setTimeout
function is the number of milliseconds that should elapse before the callback function is called, so in this case it will wait for half a second (i.e. 500ms).
What Is Callback Hell?
While callbacks are a powerful way to handle asynchronous operations, they can also lead to callback hell, a situation where nested callbacks become difficult to read, comprehend and maintain.
Below is a hypothetical example of callback hell:
getUserFromDatabase("Mark Jones", (user) => {
getDocumentsWrittenByUserFromDatabase(user, (documents) => {
findOldDocuments(documents, (oldDocuments) => {
deleteDocumentsFromDatabase(oldDocuments, (success, error) => {
if (success) {
console.log("Mark's old documents have been successfully removed from the database.")
} else {
throw error;
}
});
});
});
});
It doesn’t matter how any of the functions actually work in the example above, since we’re just using them to show how complicated and convoluted nesting can become when using multiple callback functions inside one another.
Just imagine how difficult it would be to see what was going on if we had ten or even twenty layers of nested functions!
Promises provide an elegant solution to this problem, because they remove the need to use any significant degree of nesting whatsoever.
What Are Promises?
Promises are objects that represent the eventual completion or failure of an asynchronous operation and its resulting value.
Promises were introduced in JavaScript (in ES6, which was released in 2015) to provide a better way to handle asynchronous operations.
Promises provide a way to write asynchronous code that looks like synchronous code, making it much easier to read and maintain.
Which States Can a Promise Be In?
Promises can be in one of the following three states:
- pending;
- fulfilled;
- rejected.
When a promise is first created, it is in the pending state.
While in the pending state, the promise is waiting for the asynchronous operation to complete.
When the operation completes successfully, the promise transitions to the fulfilled state and is resolved with the result of the operation.
If the operation fails, the promise transitions to the rejected state and is rejected with an error.
How Can a Promise Be Created?
In order to create a promise, we simply call the Promise
constructor with the new
keyword, in the usual way that objects are created in JavaScript.
The Promise
constructor takes a single argument, which is itself a function that takes two arguments, and these arguments are by convention named resolve
and reject
. This function is the only callback that a Promise
will need.
The resolve
function is called when the asynchronous operation completes successfully, whereas the reject
function is called when the operation fails in some way.
Here’s a simple example:
const promise = new Promise((resolve, reject) => {
const result = Number.parseInt("deadbeef", 16);
if (Number.isNaN(result)) {
reject(new Error("parsing failed"));
} else {
resolve(result);
}
});
Within the promise, we parse an integer from a string as an example operation, but note that this is completed synchronously, not asynchronously, so it doesn’t really benefit from being inside the promise — but I’ve just used it for demonstration purposes.
If the parsing operation fails, the result
variable will equal NaN
, so we check for that, and call the reject function with an Error
that describes the problem, if necessary.
Otherwise, we call the resolve
function with the number that we have successfully parsed.
How Are Promises Used?
Once a promise is created, we can use it to handle the result of the asynchronous operation. We do this by attaching a sequence of un-nested callbacks to the promise using the then and catch methods.
The then
method is called when the promise is fulfilled successfully, and the catch
method is called when the promise is rejected.
Here’s an example using the promise that we created earlier:
promise.then((result) => {
console.log(result); // prints 3735928559
}).catch((error) => {
console.error(error); // prints "Error: parsing failed"
});
When this promise runs, we should only expect the then
method to run, since we know that "deadbeaf"
can be parsed as a hexadecimal number to equal the decimal number 3_735_928_559
, but it’s always useful to define a catch
function, just in case any unexpected errors do occur.
How Are Promises Chained?
Promises can be chained together in order to perform multiple asynchronous operations in a sequential order, i.e. one after another.
When one promise completes, the result is passed to the next promise in the chain, and this process can continue indefinitely.
This ability to chain promises is what makes it so easy to write asynchronous code that looks like synchronous code.
Here’s an example of how to chain promises together:
const promise = new Promise((resolve) => {
setTimeout(() => {
resolve("foo");
}, 1_000);
});
const callbackOne = (value) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(value + "bar");
}, 1_000);
});
};
const callbackTwo = (result) => {
console.log(result);
};
promiseOne.then(callbackOne).then(callbackTwo);
We begin by creating a promise and two callback functions, each of which will be passed to the promise’s then method.
The initial promise resolves after a delay of one second (i.e. 1,000 milliseconds) and is resolved with the string "foo"
.
The first callback function takes a value as input and returns a new promise that is resolved with the input value concatenated with the string "bar"
.
The second callback function simply prints the value that it receives from the second promise.
We then chain everything together using the then
method.
How Are Errors Handled in Promises?
One of the best things about promises is the fact that they provide a particularly elegant way to handle errors that arise within any part of the chain.
When a promise is rejected, the error is propagated down the chain of promises until it is caught by a catch
method. This makes it possible to handle errors in a single location, rather than in multiple callback functions.
The example below shows how to handle any errors that occur within promises:
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error("failure"));
}, 1000);
});
promise.catch((error) => {
console.error(error);
});
We first create a promise that is always rejected with an error after a delay of one second. We then attach a catch
method to the promise in order to handle the error, printing it out to the console.
If there were another then
method after the catch
method, the chain would continue, because the error has been dealt with.
Can Multiple Promises Be Executed At Once With the All Function?
Finally, let’s look at the Promise.all
function, which is particularly useful because it gives us the ability to perform multiple asynchronous operations at the same time — and wait for all of them to complete before continuing.
The example below shows how to use the Promise.all
function to download data from multiple different web addresses concurrently:
const urls = [
"https://jsonplaceholder.typicode.com/users/1",
"https://jsonplaceholder.typicode.com/users/2",
"https://jsonplaceholder.typicode.com/users/3",
"https://jsonplaceholder.typicode.com/users/4"
];
const promises = urls.map((url) => {
return fetch(url).then((response) => {
return response.json();
});
});
Promise.all(promises)
.then((results) => {
console.log(results);
})
.catch((error) => {
console.error(error);
});
We begin by declaring an array of URLs that we want to download some data from.
(The particular URLs that we’re using just provide some dummy JSON data for testing purposes.)
We then use the map
method to create an array of promises that will fetch the data from each URL using the browser’s native fetch
function.
The Promise.all
function takes an array of promises as its input and returns a new promise that resolves with an array of results once all the promises in the array have resolved. We end by using the then
method to print out the results, which in this case is an array of JSON objects.
Note that Promise.all
will reject the new promise if any of the input promises are rejected.
However, if you do need to handle errors for each promise individually, then you should use the catch
method on each promise returned by map
before passing them to Promise.all
.