JavaScript callbacks, promises, and async/await explained

Callbacks work fine for handling asynchronous code in JavaScript, but promises and the async and await keywords are cleaner and more flexible.

JavaScript callbacks and promises explained
Thinkstock

Dealing with asynchronous code—meaning any kind of code that doesn't execute immediately—can be tricky. Asynchronous behavior is one of the main sources of complexity in any software environment. It represents a break in execution flow that may lead to any number of outcomes. This article introduces the three ways to handle asynchronous behavior in JavaScript. We'll start with a look at callbacks, then I'll introduce promises and the async and await keywords as more modern alternatives.

What is asynchronous code?

Asynchronous code (async code) says: go do something while I do other things, then let me know what happened when the results are ready. Also known as concurrency, async is important in a variety of use cases but is especially vital when interacting with external systems and the network. In the browser, making remote API calls is a ubiquitous use case for asynchronous code, but there are innumerable others on both the front- and back-end.

Callbacks in JavaScript

Callbacks were the only natively supported way to deal with async code in JavaScript until 2016, when the Promise object was introduced to the language. However, JavaScript developers had been implementing similar functionality on their own years before promises arrived on the scene.

Let’s take a look at some of the differences between callbacks and promises, and see how we can use each technique to coordinate multiple layers of execution.

A classic callback example

Asynchronous functions that use callbacks take a function as a parameter, which will be called once the work completes. If you’ve ever used something like setTimeout in the browser, you’ve used callbacks.

Listing 1. Callbacks in timeouts


// You can define your callback separately...
let myCallback = () => {
  console.log('Called!');
};
setTimeout(myCallback, 3000);
// … but it’s also common to see callbacks defined inline
setTimeout(() => {
  console.log('Called!');
}, 3000);
console.log(“This happens first!”);

Listing 1 says, in two different ways: after 3000 milliseconds, output “Callid!” to the console. In both cases, the line “This happens first!” will output first. That’s because these are asynchronous calls, and the code execution flow continues on while the timeouts are waiting to happen. That is the essence of asynchronous programming.

Nested callbacks and the pyramid of doom

Callbacks work well for handling asynchronous code, but they get tricky when you start coordinating multiple asynchronous functions. For example, if we wanted to wait two seconds and log something, then wait three seconds and log something else, then wait four seconds and log something else, our syntax would quickly become deeply nested. This is a state sometimes known as callback hell.

Listing 2. Nested callbacks with setTimeout


setTimeout(() => {
  console.log('First Callback!');
  setTimeout(() => {
    console.log('Second Callback!');
    setTimeout(() => {
      console.log('Third Callback!');
    }, 4000);
  }, 3000);
}, 2000);

Listing 2 offers a glimpse of how ugly callbacks can get. The more code we place inside of nested calls, the more difficult it is to figure out what is going on and where each call completes. This may seem like a trivial example (and it is), but it’s not uncommon to make several web requests in a row based on the return results of a previous request.

The "nested callbacks" problem crops up all the time. In Listing 3, we see a more involved example in a Node.js environment, where we're dealing with multiple files.

Listing 3. Callback hell with Node.js files


fs.readdir('/path/to/dir', function (err, files) {
  if (err) {
    console.log(err);
    return;
  }
  
  files.forEach(function (file) {
    fs.readFile('/path/to/dir/' + file, 'utf8', function (err, contents) {
      if (err) {
        console.log(err);
        return;
      }
      // do something with the file
      
      fs.writeFile('/path/to/new/dir/' + file, contents, function (err) {
        if (err) {
          console.log(err);
          return;
        }
        // do something with the file
      });
    });
  });
});

It’s already difficult to follow along with this code, even though it’s not doing anything very complex and none of the actual business logic is there. It’s hard to figure out where you are and what file handles are open when. We’re just looping through each file in a directory and reading the contents and using it in a new file in a different directory. 

So, now you've seen the limitations of callbacks. They are fine for simple uses but not great in more complex situations. Fortunately, modern JavaScript gives us the Promise object and async and await keywords as more flexible solutions.

Promises

Promises require the function be written such that it returns a Promise object, which has standard features for handling subsequent behavior and coordinating multiple promises.

We can wrap a timeout call in a Promise to see how things work. You can see examples of timeouts in some of the NPM libs and learn more about them here. Listing 4 presents a promise-based timeout called wait.

Listing 4. Promise-based timeout


function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

In Listing 4, we get a glimpse into how service code can use a Promise to deal with asynchronous conditions. In essence, our wait function returns a Promise object, which calls the resolve argument passed into the Promise constructor. (Incidentally, resolve is an example of a higher-order function.) The code might seem complex at first, but the idea is simple: When the code passed to the Promise constructor calls the resolve function, the client code will be alerted that the Promise has been completed. Note that the name resolve is not essential; it is whatever argument is passed into the function passed to the Promise. Similar support exists for error conditions.

Next, in Listing 5, you can see the wait function being used with the then function.

Listing 5. Using Promise with then


wait(3000).then(() => {
  console.log('Called after 3 seconds');
});

The basic idea is that we can now just call wait, passing in the relevant argument (the wait time) and not have to pass in the callback handler as an argument. Instead, the handler is given in the .then() call. This opens up the possibility of chaining together multiple calls, like we see in Listing 6.

Listing 6. Chaining promises with then


wait(2000)
  .then(() => {
    console.log('First Callback!');
    return wait(3000);
  })
  .then(() => {
    console.log('Second Callback!');
    return wait(4000);
  })
  .then(() => {
    console.log('Third Callback!');
    return wait(4000);
  });

We can also put calls together using the Promise.all() static function, as shown in Listing 7.

Listing 7. Using Promise.all()


Promise.all([
  wait(2000),
  wait(3000),
  wait(4000)
]).then(() => console.log('Everything is done!'));

Promise also has a .race() method that returns as soon as any of the promises resolve or error out.

JavaScript async/await

As a final example, Listing 8 shows how to use our wait() function with the async and await keywords.

Listing 8. Using JavaScript async/await


async function foo() {
  await wait(3000);
  console.log('Called after 3 seconds');
}

foo();

Listing 8 creates an asynchronous function, foo(), with the async keyword. This keyword tells the interpreter that there will be an await keyword inside. This keyword allows for executing an asynchronous function like wait() as though it were synchronous. The code will pause here until wait(3000) completes, and then continue on. 

Await functions hold more capability, including handling results. See How to use async and await in JavaScript for more about these keywords.

Copyright © 2023 IDG Communications, Inc.