2020-05-20T15:41:28Z

Introduction to JavaScript Promises

The use of callback functions is so common in JavaScript that often you find that in the callback from one asynchronous operation the only option that you have is to start another. In many cases you end up having a long chain of callbacks within callbacks, a situation that has been appropriately referenced as callback hell.

Modern JavaScript has introduced the concept of Promises to better deal with asynchronous notifications and prevent callback hell. In this article I'm going to show how to work with libraries based on promises, how to use promises in your own functions, and how to upgrade callback-based functions to promises.

Callback Hell

I mentioned callback hell in the introduction. Have you seen what that looks like?

Consider a web application that receives a request from a client. Some of the tasks that the application might need to carry out are:

  • Read the user referenced in the authentication credentials from the database
  • Verify the password from the incoming request against the hashed password that was retrieved from the database
  • Invoke the application's logic to handle the request

Note that each of these tasks depends on the previous task, so they cannot run in parallel. If we assume that all these tasks are asynchronous and are based on callbacks, the implementation might look more or less like this:

function handleRequest(request) {
    database.getUser(request.getUsername(), (user, error) => {
        if (error) {
            console.log(err);
        }
        else {
            user.verifyPassword(request.getPassword(), (error) => {
                if (error) {
                    console.log(err);
                }
                else {
                    callAppRoute(request, user, (error) => {
                        if (error) {
                            console.log(err);
                        }
                        else {
                            console.log('request handled');
                        }
                    });
                }
            });
        }
    });
}

Note that in this example I'm using the newer arrow function syntax introduced in the ES6 version of the language. But even with the more compact function notation, this example isn't very nice, right? Each task runs in the callback of the previous task, so the code tends to move towards the right the more functions are chained because of the added indentation levels.

Introduction to Promises

If the example from the previous section was coded using promises, it could look like this:

function handleRequest(request) {
    database.getUser(request.getUsername()).then((user) => {
        user.verifyPassword(request.getPassword())
    }).then((user) => {
        callAppRoute(request, user)
    }).then(() => {
        console.log('request handled')
    }).catch((err) => {
        console.log(err)
    });
}

This is much much better, don't you agree? We have replaced the callback-inside-callback model with a sequence of then(...), and finalize with a single catch(...) statement for error handling. We have also eliminated the indentation level increasing with each task.

Promises were introduced to the JavaScript language in the ES6 version in 2015, and support is now widespread. Node.js supports them, and all major web browsers except Internet Explorer and Opera Mini have implemented them as well (see the browser support matrix for details). For the few platforms that do not have them, there is a polyfill.

Overall promises make code simpler and easier to read, but there is one disadvantage that can be seen in the example above. Unlike with callbacks, the response from a task is not directly available to the next tasks in the chain. Consider the user variable returned by the database.getUser() to its callback. In the callback hell example this variable is in the scope of all the subsequent callbacks, so when the callAppRoute() function is called we can just pass user as an argument.

In the promises example the callback functions are not inside each other, so they do not share their local variables. The database.getUser() function passes user to its callback, but I had to extend the callback for the user.verifyPassword() function that follows to pass user to its own callback, so that this variable is available when the call to callAppRoute() happens. Even with this inconvenience, promises a great help in keeping asynchronous code organized. Later you will see an alternative syntax for promises that allows for a shared context similar to that of old-style callbacks.

Creating a Promise

Writing an asynchronous function that invokes a callback when it completes is simple, but how do you do the same with a promise?

Let's say that we want to write a delayedLog function, which waits for the requested amount of time and then writes a message to the console log. Using standard callbacks the function could be implemented as follows:

function delayedLog(message, milliseconds, cb) {
    setTimeout(() => {
        console.log(message);
        cb();
    }, milliseconds);
}

If you paste the above function definition in your browser's JavaScript console, then you can invoke it as follows:

delayedLog("Hello, World!", 5000, () => console.log('callback invoked'));
undefined
Hello, World!
callback invoked

The undefined will appear immediately. Five seconds later you will see the Hello, World! message appear in the console, followed by the callback invoked from our callback function.

The Promise Object

To implement the same logic using promises our function needs to return a Promise instance. Below you can see the boilerplate necessary to create a Promise object:

new Promise((resolved, rejected) => {
    # do your asynchronous work here
    # call resolved() when you are done
    # call rejected() if there is an error
});

The Promise() constructor takes a function as its only argument, and this function takes two arguments that I called resolved and rejected. The promise framework will call the function immediately, with the resolved and rejected arguments set to callback functions. We don't really need to know what these arguments are set to, all we need to do is implement our logic inside the function, and once we are done call resolved() or rejected() to indicate success or failure respectively. You can pass any arguments that you like on these calls.

Below you can see a simple version of the delayedLog() function implemented with a promise instead of a callback. For this first version we are only going to work with the success callback:

function delayedLog(message, milliseconds) {
    return new Promise((resolved, rejected) => {
        setTimeout(() => {
            console.log(message);
            resolved();
        }, milliseconds);
    });
}

Enter the above function in the JavaScript console of your browser, then try it out as follows:

delayedLog("Hello, World!", 5000).then(() => console.log('callback invoked'));
Promise {<pending>}
Hello, World!
callback invoked

Handling Errors

One of the nice advantages I see in using promises is the well defined way of handling errors. Let's add some validation to our delayedLog() function to prevent it from being invoked with a negative timeout:

function delayedLog(message, milliseconds) {
    return new Promise((resolved, rejected) => {
        if (milliseconds < 0) {
            rejected('timeout cannot be negative!');
        }
        else {
            setTimeout(() => {
                console.log(message);
                resolved();
            }, milliseconds);
        }
    });
}

In this version if we find that the milliseconds argument is negative we call the rejected callback. To make the example a little bit more interesting we are also passing an error message to it.

Enter this new definition of the function in your console, then try to invoke this function with a negative number to see what happens:

delayedLog("Hello, World!", -5000).then(() => console.log('callback invoked'));
Promise {<rejected>: "timeout cannot be negative!"}
Uncaught (in promise) timeout cannot be negative!

Now JavaScript is reporting that we had an exception that was unhandled, and it even includes the argument that we passed in the rejected call as part of the error. Also if you are a good observer you may have noticed that the promise object returned by the function has a "rejected" state now, while in the previous example it appeared as "pending". The reason for this is that our timeout validation check executed before the function returned the promise, so by the time the promise object was returned it was already in a failed state.

Let's now catch the error by adding a catch() clause to our promise chain:

delayedLog("Hello, World!", -5000)
    .then(() => console.log('callback invoked'))
    .catch((error) => console.log('An error has occurred: ' + error));
An error has occurred: timeout cannot be negative!
Promise {<resolved>: undefined}

With this example the function that we pass to the catch() clause is invoked when our timeout validation fails.

Now that we have a fully implemented promised-based function, we can get crazy and create function chains:

delayedLog('first', 1000)
    .then(() => delayedLog('second', 2000))
    .then(() => delayedLog('third', 3000))
    .then(() => delayedLog('fourth', 4000))
    .catch((error) => console.log('An error has occurred: ' + error));
Promise {<pending>}
first
second
third
fourth

As a final example, we can simulate an error occurring somewhere in the middle of the chain by passing a negative number into one of our steps:

delayedLog('first', 1000)
    .then(() => delayedLog('second', 2000))
    .then(() => delayedLog('third', -3000))
    .then(() => delayedLog('fourth', 4000))
    .catch((error) => console.log('An error has occurred: ' + error));
Promise {<pending>}
first
second
An error has occurred: timeout cannot be negative!

Promises and Async/Await

JavaScript promises have an alternative syntax that is even more convenient, based on the async and await keywords. Here is the final example from the previous section coded in the async/await style:

async function test() {
    try {
        await delayedLog('first', 1000);
        await delayedLog('second', 2000);
        await delayedLog('third', -3000);
        await delayedLog('fourth', 4000);
    }
    catch(error) {
        console.log('An error has occurred: ' + error);
    }
}
test();

The first important change in this example is that we now have a test() function wrapper, and this function is declared with the async keyword. This is because the await keyword can only be used inside functions that are declared with async. Inside the function, we can invoke promise-based functions by prepending the call with await. To handle errors, we use a standard try/catch.

The async and await keywords were introduced in the ES7 version of the JavaScript language in 2016. Support for async/await in JavaScript implementations is good, with Internet Explorer and Opera Mini being once again the only two mainstream platforms lacking support. See the browser support matrix for details. Transpilers such as babel can translate async/await code to ES5 or ES6 compatible code.

Calling Async Functions

As I mentioned in the previous section, it is not allowed to use the await keyword outside of functions declared with async. Some implementations such as the one in the Chrome browser make an exception to this rule and allow await also in the REPL.

You may be wondering what happens if you want to call an async function and then do something when it ends. For example, take the test() function from the previous section. If we wanted to do something after this function completed, we would do:

await test();
console.log('test completed');

You could do this in the JavaScript console in Chrome, but what if you want to do this in your application? To be able to use await test(); you would also need to make the function that contains that code async, but then the problem just moves one level up in the call stack.

JavaScript does not provide a way to use await to call an async function from a function that is not async itself. This is the only case where you are required to use the then(...).catch(...) syntax, which can be used anywhere without restrictions:

test().then(() => console.log('test completed'));

Handling Results

The delayedLog() function does not return any results. For a function that does, the return value(s) are the result of the await expression, so they can be assigned to variables and used later:

var my_result = await my_async_function(my_argument);

This provides a big advantage over the standard promise syntax. Do you remember the very first example in this article, where one of the functions in the call chain needed an argument that was the result of one of the earlier functions? The callback hell solution presented no problem, because each callback was defined in the context of the previous one, so it had access to all the previous results through closures. The same example coded with promises could not do this, so to preserve this argument it had to be passed as a result from one function to the next.

The great news is that using the async/await syntax this is not a problem anymore, because we can keep our results in local variables. Here is that first example coded for async/await:

async function handleRequest(request) {
    try {
        var user = await database.getUser(request.getUsername());
        await user.verifyPassword(request.getPassword());
        await callAppRoute(request, user);
        console.log('request handled');
    }
    catch(error) {
        console.log(error);
    }
}

Conclusion

I hope this article served as a good overview of promises, especially how they can help clean up asynchronous JavaScript code. Based on the good support in browsers and in Node.js, my recommendation is that you use promises over callbacks.

4 comments

  • #1 MANUEL ROBALINHO said 2020-05-20T22:54:21Z

    Nice. Great document!

  • #2 Bob said 2020-05-21T10:24:59Z

    Hi Miguel,

    Great post! How does the async await in python compare with the javascript async await?

    Also kind of noob question but I get that the .then() syntax shows the order of the execution, but does the async await follow the order of execution going top down?

  • #3 Miguel Grinberg said 2020-05-21T14:29:34Z

    @Bob: the JS and Python async/await implementations are actually quite similar. The big difference is that in JS you don't have to invent a new async subsystem because the asynchrony is well established in the language. In Python you have large parts of the standard library that need to be replaced with asyncio-based versions. So from that point of view, async/await in JS has a much lower barrier of entry, since it can be integrated into any codebase. I wish it was that easy in Python!

    The await calls are handled in the order they are given, and more importantly, they are sequential. The first await needs to return before the second can start.

  • #4 dartungar said 2020-05-26T18:36:19Z

    Thanks for the great introduction. Now I can make my JS code less spaghetti-like. Keep up the good work!

Leave a Comment