Promises in JavaScript: An Introduction

Image by Hao Wang.

In Brief

A promise represents the eventual completion or failure of an operation and lets you attach reactions with then(), catch() and finally(). It helps organise results from asynchronous host APIs, but it does not make CPUheavy JavaScript concurrent or move work off the current agent. Remember that then() returns a new promise, and always handle rejection somewhere in the chain.

JavaScript executes one job at a time on an agent. Browsers and server runtimes provide timers, networking and other host facilities that can complete work later, whilst promises schedule reactions as jobs once they settle. Promises organise those eventual results; they do not make arbitrary work concurrent or nonblocking.

Promises are a feature introduced in ES6 to handle asynchronous operations. They provide a way to handle asynchronous operations and their results in a more readable and manageable way. Promises allow you to write cleaner and more concise code by replacing complex nested callbacks with a chain of then() and catch() methods.

Fetching data or loading resources through host APIs are common asynchronous operations. A CPUheavy calculation remains blocking when it runs on the same JavaScript agent, even if its result is wrapped in a promise; moving it off that agent requires a worker, process or another execution facility.

Promises work to resolve this by presenting an easytounderstand pattern in JavaScript. In this article, we will introduce promises in JavaScript, how they work, and how they can be used in realworld scenarios.


What are Promises?

A promise is an object that represents the eventual completion or failure of an asynchronous operation and its resulting value. In other words: a promise is a placeholder for a future value that we expect to receive and can act upon once it arrives.

A promise has three states:

  • Pending:

    The initial state of a promise when it is created.
  • Fulfilled:

    The state of a promise when it has been resolved successfully and has value.
  • Rejected:

    The state of a promise when it has been rejected with a reason, indicating that the operation failed.

A promise can transition from the pending state to either the fulfilled or rejected state, but once it is in one of these states, it cannot transition to any other state.


Creating a Promise

The Promise constructor receives an executor function, which JavaScript calls synchronously during construction. The executor may start a host operation or arrange for another event to settle the promise later, but the constructor itself does not make its code asynchronous.

To start with, let's look at an example where we create a promise which resolves after a timeout of 2 seconds:

const myPromise = new Promise((resolve, reject) => {  setTimeout(() => {    resolve('Promise resolved!');  }, 2000);});

In this example, we create a new promise using the Promise constructor and pass an executor function as its argument. The executor function takes two parameters: resolve and reject. These are callback functions that are used to transition the promise to either the fulfilled or rejected state.

The executor calls the hostprovided setTimeout API synchronously. The host later queues the callback, which resolves the promise with 'Promise resolved!' after at least two seconds.


Using Promises

To consume a promise, we use the then() method, which is called on the promise object. The then() method takes two callback functions as its arguments: one to handle the fulfilled state and one to handle the rejected state.

Here's an example using myPromise from the previous example:

myPromise.then(  (result) => {    console.log(result);  //=> 'Promise resolved!'  },  (error) => {    console.log(error);  // never gets called in this example  });

In this example, we call the then() method on the myPromise object and pass two callback functions as its arguments. The first callback function is called if the promise is fulfilled (result) and receives the result of the promise, which in this case is the string 'Promise resolved!'.

The second callback function (error) is called if the promise is rejected, but since we didn't reject the promise in our example, this will never get called.


Chaining Promises

Promises can be chained together to handle a sequence of asynchronous operations. When a promise is fulfilled, we can return another promise, which allows us to chain multiple asynchronous operations together.

Let's delve into a more complex, realworld example:

const getUser = (userId) => {  return new Promise((resolve, reject) => {    fetch(`/users/${userId}`)      .then((response) => {        if (response.ok) {          resolve(response.json());        } else {          reject(new Error('Failed to fetch user'));        }      })      .catch((error) => reject(error));  });};const getPosts = (userId) => {  return new Promise((resolve, reject) => {    fetch(`/users/${userId}/posts`)      .then((response) => {        if (response.ok) {          resolve(response.json());        } else {          reject(new Error('Failed to fetch posts'));        }      })      .catch((error) => reject(error));  });};getUser(1)  .then((user) => getPosts(user.id))  .then((posts) => {    // handle posts data  })  .catch((error) => {    // handle error  });

This is a modified example from a project I've recently worked on. Here, we define two functions that return promises: getUser and getPosts. The getUser function fetches a user from an API and returns a promise that resolves with the user data. The getPosts function takes a user ID as a parameter and fetches the posts for that user from the same API. It returns a promise that resolves with that user's post data.

As an aside, the more eagleeyed amongst you may have also noticed that I've used template literals in there to create our API endpoint URLs.

We then call these both by chaining the two functions together using the then() method. First, we call getUser() and then we use the then() method to pass the user object to the getPosts() function. This allows us to fetch the posts for the user and handle them in the same chain of promises.


Limitations and Pitfalls

Whilst promises are a powerful pattern for handling asynchronous code, there are some limitations and pitfalls to be aware of.

Forgetting to Handle Errors

One common pitfall I've seen during many code reviews is forgetting to handle errors. We can have the most robust API in the world, and it will still fail from time to time. Promise rejections should be handled at an appropriate boundary. An unhandled rejection is reported according to the host: browsers emit an unhandledrejection event, whilst Node.js behaviour depends on its version and commandline policy. It must not be described as silently ignored.

Misunderstanding then()

Another pitfall is the misunderstanding of the then() method. The then() method returns a new promise that is resolved with the value returned by the callback function. If the callback function returns a promise, the new promise will be resolved with the value of the returned promise, not the promise itself. This can lead to unexpected behaviour if not understood properly.

Not a One‑Size‑Fits‑All Remedy

Finally, it's worth noting that promises are not a silver bullet for all asynchronous problems and development. They are just one pattern for dealing with asynchronous code and are not suitable for every situation. It's important to consider other patterns and techniques, such as callbacks or async/await, depending on the use case.


The Wrap‑Up

Promises are a powerful pattern in JavaScript for handling asynchronous operations. They provide a better way to deal with asynchronous code and help avoid callback hell. By understanding how promises work and their limitations, developers can write more robust and maintainable code.


Untangling a delivery problem?

Send the symptoms, constraints, and affected routes. I'll help identify whether the issue sits in the application, platform, content model, deployment path, or search surface.