The Whys and Hows of Promise in JavaScript

If you have done any web development after 2015, chances are that you have heard of the concept of Promise. It wouldn’t be an overstatement to claim that Promise is ubiquitous in modern-day front-end codebases. However, many web developers—especially those who do not have much experience in front-end development—have been using Promise without thoroughly understanding its inner working. This has led to countless misuses of Promise and consequent bugs. In this article, I will walk you through what motivated the creation of Promise, how Promise works, and how we should use it in our code, under the assumption that you don’t have much experience in front-end development.

Synchronicity: the Start of Everything

Do you still remember your first programming class? Your teacher probably taught you that computers execute your code one line after another. They won’t move on to the subsequent line unless they have finished the execution of the line in front of it. We commonly describe such a sequential manner of code execution as synchronous. It certainly has its merits, such as logical simplicity and readability. However, it does come with a significant shortcoming: it is wasteful.

Suppose that your code contains an operation that needs to retrieve a piece of data from another computing system. The data retrieval is time-consuming, but it does not require your code to conduct any computation. If your code is synchronous, the system that executes it has no choice but to idle while waiting for the data retrieval to finish, which inevitably wastes time and computation resources.

Asynchronicity: a More Time-Efficient Substitute for Synchronicity

To remedy synchronous programming’s time inefficiency, the pioneers in the programming world devised the concept of asynchronicity. Unlike synchronicity, asynchronicity allows time-consuming and non-compute-intensive operations to branch off from the main program flow to make room for the processing of subsequent operations. Such a new way of programming has tremendously improved the time performance of modern-day computer programs, particularly those involving large amounts of cross-system communication.

Callback: We Still Need Sequentiality in Asynchronicity

Now that we have the great concept of asynchronicity, can we just let all operations run in parallel? The answer is NO because there are operations that are dependent on others and cannot start until the completion of their dependencies. Therefore, we still need to find a way to allow branched-off operations to trigger their dependants upon completion. In other words, we still need to express sequentiality in asynchronous programming. In response to this, our forerunners in the world of computer science came up with callback functions. A callback function is a function that you can pass into another function. In that other function, you can execute the callback function after it finishes certain operations. With the help of callback functions, you can safely set aside a time-consuming operation, knowing full well that its dependent operations, specified in a callback function, will subsequently start after its completion.

The following is an example of how you would use a callback function. Let’s say that we need to retrieve user information from the database. After we receive the user information, we need to display it on our user interface. We can describe the sequential relation of the above two operations using the code below:

function getUserInfo(id, callback) {
    setTimeout(() => {
        if (typeof id === "number") {
            const userInfo = {
                id,
                name: 'Adam Smith',
                age: 30
            };
            callback(userInfo); // Calling the callback function with the retrieved user information
        } else {
            throw new Error("Invalid ID")
        }
    }, 2000); // Simulating a delay of 2 seconds
}

function displayUserInfo(userInfo) {
    console.log(userInfo)
}

console.log('Fetching user information...'); // Executed immediately
getUserInfo(1, displayUserInfo)

As we can see, displayUserInfo is the callback function. We pass it into getUserInfo, which first retrieves user information from the database (simulated by setTimeout) and then calls the provided callback function with the obtained user information as an argument. In this way, we can be sure that our program will call displayUserInfo only after the user information is ready.

Callback Hell: the Inescapable Pitfall of Callback Functions

Callbacks do provide a way for us to express sequential relations between operations. Nevertheless, misusing them can give rise to a horrendous situation called callback hell. According to callbackhell.com, “The cause of callback hell is when people try to write JavaScript in a way where execution happens visually from top to bottom.” Here is an example to illustrate what it looks like.

function1(function2 () {
    //Some time-consuming operations
    function3(function4 () {
        //Some time-consuming operations
        function5(function6 () {
            //Some time-consuming operations
        })
    })
})

As you can see, when we call function1, we pass function2 to it as a callback. After function1’s execution finishes, function2 will start its execution. Inside function2, we have a similar situation where we call function3 and pass function4 to it as a callback. The same issue plagues function4. Such convoluted code structure results from writing callbacks in a way that visually follows the sequence of code execution, and it can get even worse if more functions are involved in this process. It impairs both code readability and maintainability.

Promise to the Rescue!

“Are there any ways to avoid callback hell,” you might ask. You can try modularizing your code. However, you still cannot fully disentangle your callback functions and the functions using them. Is there a more sophisticated alternative? Allow me to introduce Promise! In JavaScript, a Promise represents an eventual completion or failure of a time-consuming operation and its result. It frees us from having to nest functions in one another and consequently enables us to produce readable and maintainable code. Next, I will demonstrate how Promise achieves this.

Promise 101: I Promise to Return Something, but I Am Not 100% Sure

Let’s first look at how to create a simple Promise object.

const promise = new Promise((resolve, reject) => {
    // Some code
    if (isFulfilled) {
        resolve(result)
    } else {
        reject(error)
    }
})

When constructing a Promise object, we pass a function as an argument to the constructor. This function itself takes in two functions as arguments. The first argument is a resolve function. We call the resolve function with the result of the asynchronous operation in this function as an argument so that we can utilize this result in subsequent operations. The second argument is a reject function. We invoke it with an error message as an argument when the asynchronous operation has failed.

Now let’s rewrite the previous example of getting user information from the database and then displaying the user information with Promise.

function getUserInfo(id) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (typeof id === "number") {
                const userInfo = {
                    id,
                    name: 'Adam Smith',
                    age: 30
                };
                resolve(userInfo); // Resolving the Promise with the retrieved user information      
            } else {
                const error = new Error("Invalid ID")
                reject(error) // Rejecting the Promise with an error
            }
        }, 2000); // Simulating a delay of 2 seconds
    })
}

function displayUserInfo(userInfo) {
    console.log(userInfo)
}

console.log('Fetching user information...'); // Executed immediately
getUserInfo(1)
    .then((userInfo) => displayUserInfo(userInfo))
    .catch((error) => console.log(error))
    .finally(() => console.log("Finished"))

The first noticeable modification we have made in getUserInfo is that now we wrap the original code in a function, construct a new Promise object with the function, and return the Promise object. In addition, we have replaced the callback function with the resolve function, which helps us decouple getUserInfo from callback functions. We only need to worry about calling the resolve function with what getUserInfo can offer to the next step as arguments. We do not have to consider the additional parameters necessary for invoking specific callback functions anymore. Another conspicuous change is that we call the reject function in getUserInfo when there’s an error instead of directly throwing the error.

When calling getUserInfo, we no longer need to pass in a callback function. getUserInfo returns a Promise object. We call the then method on the Promise object and pass in a function we want to call after the Promise object is successfully resolved. This function takes the same arguments with which we call the resolve function in getUserInfo. As for error handling, we call the catch method on the Promise object with a function for handling the error that we pass to the reject function in getUserInfo. In the end, the function passed to the finally method gets executed when the Promise object is settled (fulfilled or rejected).

What is powerful about the then method is that you can call the then method multiple times in a pattern known as method chaining. Chaining invocations of the then method highlights the sequentiality of all the operations you pass to the then method, which makes your code cleaner and more readable. The following is an example.

function multiplyByTwo(input) {
    return new Promise((resolve, reject) => {
        if (typeof input === "number") {
            const output = input * 2
            resolve(output)
        } else {
            const error = new Error("Invalid Input")
            reject(error)
        }
    })
}

multiplyByTwo(1)
    .then((input) => multiplyByTwo(input))
    .then((input) => multiplyByTwo(input))
    .then((input) => multiplyByTwo(input))
    .then((input) => multiplyByTwo(input))
    .then((input) => console.log(input))

As we can see, we have chained five invocations of multiplyByTwo using the then method, each of which (except for the first and the last one) takes the output of the previous one as an input and passes its output to the next one as an input. Can you guess what number does the code example above write to the console?

In this blog, I have covered why we need to use Promise in asynchronous programming and how to use Promise. I started by explaining the differences between synchronous and asynchronous programming. Then I demonstrated the usage of callback functions in asynchronous programming and its unavoidable downside. Last, I introduced the benefits of using Promise alongside several examples. I hope this article has helped you gain an insight into the whys and hows of Promise.