Skip to content

Escaping the callback hell using Javascript Promises

A common pattern to handle asynchronous events is to use callbacks. As these callbacks are nested to chain operations, the code gets difficult to write and even more difficult to understand. Promises are special objects in Javascript that can be used to avoid the callback hell. The resulting code is more readable and easier to write.

In this post, we’ll show what is the callback hell and explain how Promises can be used to avoid it.

Callback functions

Let’s start with a simple example. Let’s suppose we’re retrieving a value from an external network. This request can result in a success – we get the value we need – or fail (for example if the external server isn’t operational).

We’ll emulate this situation with the following code.

function fetchValue(resolve, reject) {
  let val;
  if (Math.random() > 0.5) val = (Math.random() * 10).toFixed(0);

  if (val) resolve(val);
  else reject(val);
}

function successCallback(val) {
  console.log(`Succesfully retrieved ${val}!`);
}

function failureCallback(val) {
  console.error(`Invalid value! Response was ${val}`);
}

fetchValue(successCallback, failureCallback);

Function fetchValue returns a random value between 0 and 9. To simulate a failure in the process of retrieving that value, there’s a 50% chance of returning and undefined value instead. On success, successCallback is called and it prints the value. If it fetchValue fails, it will print an error message by calling failureCallback.

Up to this point, the code is easy to understand. Let’s see, however, what happens when we start chaining operations, even simple ones.

Callback hell

Let’s add two extra operations to the example above. Let’s say that after retrieving the value, we check if it’s even and also if it’s divisible by three. We could perform all of those checks in a single function. For illustration purposes, we’ll chain the operations as it’s sometimes required using callbacks.

The two extra callbacks will be:

function checkParity(val, resolve, reject) {
  if (val % 2 == 0) resolve(val);
  else reject(val);
}

function divisibleByThree(val, resolve, reject) {
  if (val % 3 == 0) resolve(val);
  else reject(val);
}

They will receive a value, val, coming from fetchValue. For both, we call the resolve function on success and the reject function on failure.

The corresponding failure callbacks when the value isn’t even or isn’t divisible by three are:

function failureCallbackParity(val) {
  console.error("Value ", val, " isn't even!");
}

function failureCallbackDiv3(val) {
  console.error("Value ", val, " isn't divisible by 3!");
}

Here comes the ugly part. Chaining those callbacks, we’ll get the following code:

fetchValue(function (value) {
    console.log(`Succesfully retrieved ${value}!`);
    checkParity(
      value,
      function (evenValue) {
        console.log(`Value ${evenValue} is even!`);
        divisibleByThree(
          evenValue,
          function (finalValue) {
            console.log(`Value ${finalValue} is divisible by 3!`);
          },
          failureCallbackDiv3
        );
      },
      failureCallbackParity
    );
  }, failureCallback);

Even with the detailed explanation that came before, understanding this piece of code isn’t easy. This is what we call the callback hell (aka pyramid of doom).

Promises

Promises were introduced in JavaScript to handle deferred results. By deferred, we refer to operations that take an unknown amount of time to complete at the time they are called. For example, fetching data from an external network. They are particularly helpful when dealing with asynchronous events.

Let’s see how to refactor the example above using Promises.

Single callback

The fetchValue function can be refactored using Promises as follows:

function fetchValue() {
  let val;
  if (Math.random() > 0.5) val = (Math.random() * 10).toFixed(0);

  return new Promise(function (resolve, reject) {
    if (val) return resolve(val);
    else return reject(`Invalid value! Response was ${val}`);
  });
}

See that we return a Promise object. Its constructor accepts a function with parameters resolve and reject. They correspond to successCallback and failureCallback from the example above. We have refactored the reject callback to accept the whole error message instead of the single value.

The callbacks are:

function successCallback(val) {
  console.log(`Succesfully retrieved ${val}!`);
}

function failureCallback(err) {
  console.error(err);
}

Then, we can chain the fetchValue function (that returns a Promise) with the callbacks as:

fetchValue().then(successCallback, failureCallback);

The process takes place as follows:

  • fetchValue is called and returns a Promise.
  • Promises have the special method then, to which we pass successCallback and failureCallback. They will be called in place of the resolve and reject functions, respectively.

In our example, the Promise resolves instantly and either of successCallback or failureCallback are called. In a more general situation, where the Promise takes some time to process, the resolve or reject functions are called only if and when the Promise resolves.

A glimpse of the asynchronous nature of Promises can be seen by adding a console.log after calling fetchValue.

fetchValue().then(successCallback, failureCallback);
console.log("First!");

Even though fetchValue is called first, the message First! will show on the console first. Roughly speaking, this is because Promises are scheduled for execution and belong to a special queue of events. They aren’t part of the “normal” execution flow of the program.

Finally, we can chain the reject callback using catch instead:

fetchValue().then(successCallback).catch(failureCallback);

We’ll see how using catch is helpful when chaining callbacks below.

Multiple callbacks

The usefulness of Promises can be more clearly appreciated when we chain callbacks. We saw above that chaining callbacks results in the infamous callback hell. Let’s see how Promises can come to the rescue.

First, let’s redefine the extra callbacks to use Promises.

function checkParity(val) {
  if (val % 2 == 0) return val;
  throw `Value ${val} isn't even!`;
}

function divisibleByThree(val, resolve, reject) {
  if (val % 3 == 0) return val;
  throw `Value ${val} isn't divisible by 3!`;
}

There are three main differences. First, they accept a single value, val. Second, they directly return the result instead of calling a callback. Third, instead of calling the reject function they use a throw statement. This statement is equivalent in this context to return Promise.reject(val).

We’ll use a single function as the reject callback:

function failureCallback(err) {
  console.error(err);
}

The resulting chaining of callbacks using Promises is presented below:

fetchValue()
  .then((value) => {
    console.log(`Succesfully retrieved ${value}!`);
    return checkParity(value);
  })
  .then((evenValue) => {
    console.log(`Value ${evenValue} is even!`);
    return divisibleByThree(evenValue);
  })
  .then((finalValue) => console.log(`Value ${finalValue} is divisible by 3!`))
  .catch(failureCallback);

This is already an improvement:

  • There’s no nesting.
  • The callbacks aren’t intertwined.
  • There’s a single failure callback to handle all errors.

The last catch expression will handle any of the throw statements from the callbacks.

The chaining of then expressions can be done because each then, by default, returns a new Promise.

This callback chain can be simplified even further if our only purpose is retrieving a single valid value after all of the operations.

We may be tempted to write it as follows:

let value = fetchValue()
  .then(checkParity)
  .then(divisibleByThree)
  .catch(failureCallback);

However, as it was stated before, all then statements return a Promise. The code above will assign a Promise to value.

As a workaround, we have the following:

let value;
fetchValue()
  .then(checkParity)
  .then(divisibleByThree)
  .then((finalValue) => value = finalValue)
  .catch(failureCallback);

The purpose of the last callback is to assign finalValue to value. This is possible because inside of the then statement, the “value” of the Promise is accessible.

This last code block is very telling. Instead of the callback hell that we first presented, we have just a few lines of very clear code that even resembles spoken language.

As a final note, Promises are generally used in the context of asynchronous events. While they can be handled with the then and catch methods that we presented here, a preferred approach is to use async and await keywords (introduced to JavaScript with ES2017).


References

Published inProgramming
Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments