DEV Community

Nick Parsons
Nick Parsons

Posted on

JavaScript: Promises and Why Async/Await Wins the Battle

Async/Await

Asynchronous functions are a good and bad thing in JavaScript. The good side is that asynchronous functions are non-blocking and, therefore, are fast – especially in a Node.js context. The downside is that dealing with asynchronous functions can be cumbersome, as you sometimes have to wait for one function to complete in order to get its “callback” before proceeding to the next execution.

There are a handful of ways to play to the strengths of asynchronous function calls and properly handle their execution, but one is far superior to the rest (Spoiler: it’s Async/Await). In this quick read, you’ll learn about the ins and outs of Promises and the use of Async/Await, as well as our opinion on how the two compare.

Enjoy!

Promises vs. Callbacks 🥊

As a JavaScript or Node.js developer, properly understanding the difference between Promises and Callbacks and how they work together, is crucial.

There are small but important differences between the two. At the core of every Promise, there is a callback resolving some kind of data (or error) that bubbles up to the Promise being invoked.

The callback handler:

function done(err) {
    if (err) {
        console.log(err);
        return;
    }

    console.log('Passwords match!');
}
Enter fullscreen mode Exit fullscreen mode

Calling the validatePassword() function:

function validatePassword(password) {
    if (password !== 'bambi') {
        return done('Password mismatch!');
    }

    return done(null);
}
Enter fullscreen mode Exit fullscreen mode

The code snippet below shows a full end to end check for validating a password (it’s static and must match “bambi”, my favorite cartoon character as a child):

// provided a string (password)
function validatePassword(password) {
    // create promise with resolve and reject as params
    return new Promise((resolve, reject) => {
        // validate that password matches bambi (the deer)
        if (password !== 'bambi') {
            // password doesn't match, return an error with reject
            return reject('Invalid Password!');
        }

        // password matches, return a success state with resolve
        resolve();
    });
}

function done(err) {
    // if an err was passed, console out a message
    if (err) {
        console.log(err);
        return; // stop execution
    }

    // console out a valid state
    console.log('Password is valid!');
}

// dummy password
const password = 'foo';

// using a promise, call the validate password function
validatePassword(password)
    .then(() => {
        // it was successful
        done(null);
    })
    .catch(err => {
        // an error occurred, call the done function and pass the err message
        done(err);
    });
Enter fullscreen mode Exit fullscreen mode

The code is commented pretty well, however, if you’re confused, the catch only executes in the event that a reject() is called from the promise. Since the passwords don’t match, we call reject(), therefore “catching” the error and sending it to the done() function.

Promises 🤞

Promises provide a simpler alternative for executing, composing and managing asynchronous operations when compared to traditional callback-based approaches. They also allow you to handle asynchronous errors using approaches that are similar to synchronous try/catch.

Promises also provide three unique states:

  1. Pending - the promise’s outcome hasn’t yet been determined because the asynchronous operation that will produce its result hasn’t completed yet.
  2. Fulfilled - the asynchronous operation has completed, and the promise has a value.
  3. Rejected - the asynchronous operation failed, and the promise will never be fulfilled. In the rejected state, a promise has a reason that indicates why the operation failed.

When a promise is pending, it can transition to the fulfilled or rejected state. Once a promise is fulfilled or rejected, however, it will never transition to any other state, and its value or failure reason will not change.

The Downside 👎

The one thing promises don’t do is solve what is called “callback hell”, which is really just a series of nested function calls. Sure, for one call it’s okay. For many calls, your code becomes difficult, if not impossible, to read and maintain.

Looping in Promises 🎡

To avoid deeply nested callbacks with JavaScript, one would assume that you could simply loop over the Promises, returning the results to an object or array, and it will stop when it’s done. Unfortunately, it’s not that easy; due to the asynchronous nature of JavaScript, there’s no “done” event that is called when your code is complete if you’re looping through each Promise.

The correct way to approach this type of situation is to use Promise.all(). This function waits for all fulfillments (or the first rejection) before it is marked as finished.

Error Handling 💣

Error handling with multiple nested Promise calls is like driving a car blindfolded. Good luck finding out which Promise threw the error. Your best bet is to remove the catch() method altogether and opt-in for a global error handler (and cross your fingers) like so:

Browser:

window.addEventListener('unhandledrejection', event => {
    // can prevent error output on the console:
    event.preventDefault();

    // send error to log server
    log('Reason: ' + event.reason);
});
Enter fullscreen mode Exit fullscreen mode

Node.js:

process.on('unhandledRejection', (reason) => {
    console.log('Reason: ' + reason);
});
Enter fullscreen mode Exit fullscreen mode

Note: The above two options are the only two ways to ensure that you’re catching errors. If you miss adding a catch() method, it’ll be swallowed up by the code.

Async/Await? 🤔

Async/Await allows us to write asynchronous JavaScript that looks synchronous. In previous parts of this post, you were introduced to Promises – which were supposed to simplify asynchronous flow and avoid callback-hell – but they didn’t.

Callback Hell? 🔥

Callback-hell is a term used to describe the following scenario:

Note: As an example, here’s an API call that would get 4 specific users from an array.

// users to retrieve
const users = [
    'W8lbAokuirfdlTJpnsNC5kryuHtu1G53',
    'ZinqxnohbXMQdtF6avtlUkxLLknRxCTh',
    'ynQePb3RB2JSx4iziGYMM5eXgkwnufS5',
    'EtT2haq2sNoWnNjmeyZnfUmZn9Ihfi8w'
];

// array to hold response
let response = [];

// fetch all 4 users and return responses to the response array
function getUsers(userId) {
    axios
        .get(`/users/userId=${users[0]}`)
        .then(res => {
            // save the response for user 1
            response.push(res);

            axios
                .get(`/users/userId=${users[1]}`)
                .then(res => {
                    // save the response for user 2
                    response.push(res);

                    axios
                        .get(`/users/userId=${users[2]}`)
                        .then(res => {
                            // save the response for user 3
                            response.push(2);

                            axios
                                .get(`/users/userId=${users[3]}`)
                                .then(res => {
                                    // save the response for user 4
                                    response.push(res);
                                })
                                .catch(err => {
                                    // handle error
                                    console.log(err);
                                });
                        })
                        .catch(err => {
                            // handle error
                            console.log(err);
                        });
                })
                .catch(err => {
                    // handle error
                    console.log(err);
                });
        })
        .catch(err => {
            // handle error
            console.log(err);
        });
}
Enter fullscreen mode Exit fullscreen mode

Whew, that’s ugly and takes up a TON of space in the code. Async/Await is the latest and greatest thing to come to JavaScript, allowing us to not only avoid callback-hell but ensure that our code is clean and that errors are properly captured. What I find most fascinating about Async/Await is that it is built on top of Promises (non-blocking, etc.), yet allows for code to be readable and reads as if it were synchronous. This is where the power lies.

Note: Here’s an example of the same set of API calls to retrieve 4 users from an array, in more than half the lines of code:

// users to retrieve
const users = [
    'W8lbAokuirfdlTJpnsNC5kryuHtu1G53',
    'ZinqxnohbXMQdtF6avtlUkxLLknRxCTh',
    'ynQePb3RB2JSx4iziGYMM5eXgkwnufS5',
    'EtT2haq2sNoWnNjmeyZnfUmZn9Ihfi8w'
];

// array to hold response
let response = [];

async function getUsers(users) {
    try {
        response[0] = await axios.get(`/users/userId=${users[0]}`);
        response[1] = await axios.get(`/users/userId=${users[1]}`);
        response[2] = await axios.get(`/users/userId=${users[2]}`);
        response[3] = await axios.get(`/users/userId=${users[3]}`);
    } catch (err) {
        console.log(err);
    }
}
Enter fullscreen mode Exit fullscreen mode

Fancy, right? 💃

And because Async/Await is built on top of Promises, you can even use Promise.all() with the await keyword:

async function fetchUsers() {
  const user1 = getUser1();
  const user2 = getUser2();
  const user3 = getUser3();

  const results = await Promise.all([user1, user2, user3]);
}
Enter fullscreen mode Exit fullscreen mode

Note: Async/await is slightly slower due to its synchronous nature. You should be careful when using it multiple times in a row as the await keyword stops the execution of all the code after it – exactly as it would be in synchronous code.

How Do I Start Using Async/Await? 💻

Working with Async/Await is surprisingly easy to understand and use. In fact, it’s available natively in the latest version of Node.js and is quickly making its way to browsers. For now, if you want to use it client side, you’ll need to use Babel, an easy to use and setup transpiler for the web.

Async

Let’s start with the async keyword. It can be placed before function, like this:

async function returnTrue() {
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Await

The keyword await makes JavaScript wait until that promise settles and returns its result. Here’s an example:

let value = await promise; // only works inside of an async function
Enter fullscreen mode Exit fullscreen mode

Full Example

// this function will return true after 1 second (see the async keyword in front of function)
async function returnTrue() {

  // create a new promise inside of the async function
  let promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve(true), 1000) // resolve
  });

  // wait for the promise to resolve
  let result = await promise;

  // console log the result (true)
  console.log(result);
}

// call the function
returnTrue();
Enter fullscreen mode Exit fullscreen mode

Why Is Async/Await Better? 😁

Now that we’ve gone over a lot of what Promises and Async/Await have to offer, let’s recap why we (Stream) feel that Async/Await is was a superior choice for our codebase.

  1. Async/Await allows for a clean and concise codebase with fewer lines of code, less typing, and fewer errors. Ultimately, it makes complicated, nested code readable again.
  2. Error handling with try/catch (in one place, rather than in every call)
  3. Error stacks make sense, as opposed to the ambiguous ones that you receive from Promises, which are large and make it difficult to locate where the error originated. Best of all, the error points to the function from which the error came.

Final Thoughts 📃

I can say that Async/Await is one of the most powerful features that has been added to JavaScript in the past few years.

It took less than one day to understand the syntax and see what a mess our codebase was in that regard. It took about two days total to convert all of our Promise based code to Async/Await, which was essentially a complete rewrite – which just goes to show how little code is required when using Async/Await.

Lastly, thank you for reading this post. If you’re interested in what I do at Stream all day, you should give our 5-minute API tutorial a try – I promise it's worth it. For more awesome posts, you can also follow me on Twitter – @nickparsons.

Happy coding! 🤓

Top comments (9)

Collapse
 
nepeckman profile image
nepeckman • Edited

I think you aren't giving promises enough credit, and I'd like to address your points one by one.

Async/Await allows for a clean and concise codebase with fewer lines of code, less typing, and fewer errors. Ultimately, it makes complicated, nested code readable again.

This was the most problematic claim for me. Promises were introduced to the language in order to prevent callback hell.

// users to retrieve
const users = [
    'W8lbAokuirfdlTJpnsNC5kryuHtu1G53',
    'ZinqxnohbXMQdtF6avtlUkxLLknRxCTh',
    'ynQePb3RB2JSx4iziGYMM5eXgkwnufS5',
    'EtT2haq2sNoWnNjmeyZnfUmZn9Ihfi8w'
];

// array to hold response
let response = [];

function getUsers(users) {
    let promises = [];
    promises[0] = axios.get(`/users/userId=${users[0]}`);
    promises[1] = axios.get(`/users/userId=${users[1]}`);
    promises[2] = axios.get(`/users/userId=${users[2]}`);
    promises[3] = axios.get(`/users/userId=${users[3]}`);
    Promise.all(promises)
       .then((userDataArr) => response = userDataArr)
       .catch((err) => console.log(err));
}

Enter fullscreen mode Exit fullscreen mode

This does the same thing as your async/await code, and its just as readable. Your example promise code seemed crafted specifically to make promises look bad.

Error handling with try/catch (in one place, rather than in every call)

Promise chains have fall through. If I chain several promises, I can put a catch statement at the end and it'll catch any promise that fails above it.

asyncAction1()
   .then((x) => asyncAction2)
   .then((x) => asyncAction3)
   .catch((err) => console.log(err)) //This will catch any errors in the 3 actions
Enter fullscreen mode Exit fullscreen mode

Once a promise fails, it won't execute any other part of the chain until it is caught. So this behaves exactly as the try/catch block in the async/await code.

Error stacks make sense, as opposed to the ambiguous ones that you receive from Promises, which are large and make it difficult to locate where the error originated. Best of all, the error points to the function from which the error came.

I have had the exact opposite experience. Async/await code needs to be compiled down when targeting old browsers. While the functionality can be replicated easily, this compilation step destroys the stack traces with confusing functions required to emulate the behavior.

The one advantage of aysnc/await code is that is it easier to learn for a new comer to JavaScript. It takes some time to really understand how to use and compose promises effectively, while async/await code looks synchronous so it is less intimidating. But once one understands promises, async/await loses that advantage. Well crafted promise code is just as readable and maintainable, and arguably fits better with functional paradigms that are becoming more popular in the front end.

Collapse
 
tunaxor profile image
Angel Daniel Munoz Gonzalez

Thank you!
for some reason many people forget that methods like Promise.all and the fact that you can always return a promise and let it be fulfilled on the next then call prevents you from nesting promises themselves!

Collapse
 
leadiv profile image
Paul Borrego

Hi Nick thanks for writing on this subject. I have yet to use async/await in a project but could see how it can be useful.

One thing I wanted to note from your section "Callback Hell?" is that the promises in that section could be structured to avoid that callback hell.

I realize that the example is to show a good contrast between callback hell and async/await but it seemed to be presented as promises are not able to be a solution for callback hell. I thought of two ways the promises could be restructured:

const getUser = (user) => () => {

    return axios
        .get(`/users/userId=${user}`)
        .then((res) => response.push(res));
}

function getUsers(users) {

    const [
        getFirstUser,
        getSecondUser,
        getThirdUser,
        getFourthUser
    ] = users.map(getUser);

    getFirstUser()
        .then(getSecondUser)
        .then(getThirdUser)
        .then(getFourthUser)
        .catch(console.log);
}

This is a generic version of the previous one...

const getUser = (user) => () => { 

    return axios
        .get(`/users/userId=${user}`)
        .then((res) => response.push(res));
}

const promiseChain = (all, next) => all.then(next);

function getUsers(users) {

    users
        .map(getUser)
        .reduce(promiseChain, Promise.resolve())
        .catch(console.log);
}

Still not a clear as the async/await version but does the same thing and does avoid the callback hell.

Collapse
 
kepta profile image
Kushan Joshi • Edited

Nick this is a great article, but there are some problems (as others have pointed too) with the way you interpret Async/Await as the next evolution of Promises.

You can call Promises as the next evolution of the callback code, but the Async/Await is not exactly an evolution. It is meant to supplement Promises and not replace it. For example you can use a combination of Promises and Await to get a much more readable code than flooding you code with awaits.

In your example

async function foo() {
        response[0] = await axios.get(`/users/userId=${users[0]}`);
        response[1] =  await axios.get(`/users/userId=${users[1]}`);
        response[2] = await axios.get(`/users/userId=${users[2]}`);
       // do the thing
}

Async/Await heavy codes like the one above, encounter a problem of running out of variable names (you have used an array, which is clever but doesn't really improve readability)

This code can be improved by doing something by

async function() {
        response =  await Promise.all([1,2,3].map(i => axios.get(`/users/userId=${i}`));
       // do the thing
}

In a lot of cases you really want to pipe a bunch of results to multiple async calls, for this again promises work like a charm:

async function foo() {
   const final = await axios.get(`/users/userId=${users[0]}`)
  .then(r => processUser(r))
  .then(r => getUserData(r))
}

Collapse
 
daanwilmer profile image
Daan Wilmer

One thing to note: async functions are supported by only 85% of used browsers (according to caniuse.com/#feat=async-functions). This can be acceptible in some cases, but not all.

Promises, however, are supported by 90% of browsers and you can add a polyfill — syntactically speaking, it's just an object. As long as it's there it's fine, whether provided by the browser or the polyfill. Async functions, however, are a syntax feature, which makes a polyfill impossible.

How many people do you want to reach?

Collapse
 
jswhisperer profile image
Greg, The JavaScript Whisperer

I'm wondering is async await actually slower? My understanding is it's not you write it like synchronous code but it is still async, with babel it's converted to generator functions that don't block the event loop. Without babel I still think it is non blocking.
Also neat trick I use is await fetch().catch(e => console.log(e)) you can catch them inline also.

Collapse
 
milkstarz profile image
malik

Great post.

Async/await is great and provides a lot of simplicity, but the cost for transpiling it with Babel can be kinda heavy.

Here's a great article I read on the topic. Thanks again for the read!

medium.com/@benlesh/async-await-it...

Collapse
 
juanfrank77 profile image
Juan F Gonzalez

Thanks for this great post Nick! now I have a clearer understanding of Promises and I'm sure to be trying out Async/Await in my next project. 8)

Collapse
 
mtallerico1 profile image
Mike Tallerico

Great read! Thanks for the clear examples. Bookmarked!