Asynchronous JavaScript: Callbacks and promises explained

While callbacks work fine for handling asynchronous code, promises are cleaner and more flexible

Asynchronous JavaScript: Callbacks and promises explained
Thinkstock

Dealing with asynchronous code, meaning code that doesn’t execute immediately like web requests or timers, can be tricky. JavaScript gives us two ways out of the box to handle asynchronous behavior: callbacks and promises.

Callbacks were the only natively supported way for dealing with async code 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 deal with coordinating multiple promises.

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

// 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);

Usually the function that takes a callback takes it as its last argument. This is not the case above, so let’s pretend there’s a new function called wait that is just like setTimeout but takes the first two arguments in opposite order:

// We’d use our new function like this:
waitCallback(3000, () => {
  console.log('Called!');
});

Nested callbacks and the pyramid of doom

Callbacks work fine for handling asynchronous code, but they get tricky when you start having to coordinate 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 becomes deeply nested.

// We’d use our new function like this:
waitCallback(2000, () => {
  console.log('First Callback!');
  waitCallback(3000, () => {
    console.log('Second Callback!');
    waitCallback(4000, () => {
      console.log('Third Callback!');
    });
  });
});

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. If your AJAX library uses callbacks, you’ll see the structure above play out. In examples that are more deeply nested, you’ll see what is referred to as the pyramid of doom, which gets its name from the pyramid shape made in the indented whitespace at the beginning of the lines.

As you can see, our code gets structurally mangled and harder to read when dealing with asynchronous functions that need to happen sequentially. But it gets even trickier. Imagine if we wanted to initiate three or four web requests and perform some task only after all of them have returned. I encourage you to try to do it if you haven’t run across the challenge before.

Easier async with promises

Promises provide a more flexible API for dealing with asynchronous tasks. It requires the function be written such that it returns a Promise object, which has some standard features for handling subsequent behavior and coordinating multiple promises. If our waitCallback function was Promise-based, it would only take one argument, which is the milliseconds to wait. Any subsequent functionality would be chained off the promise. Our first example would look like this:

let myHandler = () => {
  console.log(‘Called!’);
};
waitPromise(3000).then(myHandler);

In the example above, waitPromise(3000) returns a Promise object that has some methods for us to use, such as then. If we wanted to execute several asynchronous functions one after another, we could avoid the pyramid of doom by using promises. That code, rewritten to support our new promise, would look like this:

// No matter how many sequential async tasks we have, we never make the pyramid.
waitPromise(2000)
  .then(() => {
    console.log('First Callback!');
    return waitPromise(3000);
  })
  .then(() => {
    console.log('Second Callback!');
    return waitPromise(4000);
  })
  .then(() => {
    console.log('Second Callback!');
    return waitPromise(4000);
  });

Better yet, if we need to coordinate asynchronous tasks that support Promises, we could use all, which is a static method on the Promise object that takes several promises and combines them into one. That would look like:

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

Next week, we’ll dig further into how promises work and how to use them idiomatically. If you’re just learning JavaScript or you’re interested in testing your knowledge, try to waitCallback or try to accomplish the equivalent of Promise.all with callbacks.

As always, reach out to me on Twitter with any comments or questions.

Copyright © 2018 IDG Communications, Inc.