A beginner’s guide to JavaScript callbacks

Before promises and async/await were introduced, asynchronous code was dealt with exclusively using the callback functions or ‘callbacks’.

Callbacks are not really a feature of JavaScript in themselves. They are rather a way of structuring regular functions to run tasks in a certain order. The method involves effectively nested a function within a function.

It is important to note that callbacks executing functions serially (i.e. one after the other). For more flexibility, such as executing asynchronous tasks in parallel, promises are a better solution.

Executing three tasks in order 1️⃣→2️⃣→3️⃣

In a previous post on asynchronous JavaScript, we used the following example to demonstrate that tasks do not always complete in the order we want them to:

setTimeout(function(){
  console.log("First task");
},700);
// 2️⃣

setTimeout(function(){
  console.log("Second task");
},1500);
// 3️⃣
 
setTimeout(function(){
  console.log("Third task");
},500);
// 1️⃣

Callbacks are a solution to the puzzle of how we can solve this problem of ordering using functions.

First, we wrap each setTimeout in a function so we can call them and pass in a parameter when doing so.

The way callbacks work is that the passed in parameter will be a function (defined when called). This passed in function should be called after the original function has completed it task.

Making these changes to the example above produces the following:

function firstTask(callback) {
  setTimeout(function(){
    console.log("First task");
    callback()
  },700);
}

function secondTask(callback) {
  setTimeout(function(){
    console.log("Second task");
    callback()
  },1500);
}

function thirdTask() {
  setTimeout(function(){
    console.log("Third task");
  },500);
}

// Calling
firstTask(function() {
  secondTask(function() {
    thirdTask()
  })
})

By convention, the new parameter is called callback (but it can be named something else without changing the functionality).

They are now serially executed in the correct order!

Just to be clear, here is what is going on in the above code, step-by-step:

  1. First, we call firstTask. JavaScript runs the content of firstTask, which ends with callback(). This calls the function we passed in as the first parameter of firstTask. Inside this function, we call secondTask.
  2. JavaScript now runs the content of secondTask, ending with callback(). This runs the function that we pass in as an argument to secondTask. Within the function, we call thirdTask.
  3. thirdTask is the final function we run. Because of this, we did not include a callback parameter in its definition and do not pass in any argument.

Error-handling ⚠️

In the example above we have not included error-handling. But in a real-world situation, this would need to be included, too.

We first need to make some changes to the task functions. Namely, we add a parameter to each callback function (called error or err by convention).

If there is an error when we call the function, we want the value of error to be information about the error. If there is no error, we want the value to be null (falsy). This information is passed to the callback function and we can check for the error there.

But first, here is how our functions look with error-handling incorporated:

function firstTask(callback) {
  const randomNumber = Math.floor(Math.random() * 10);
  if (randomNumber == 1) {
    callback(new Error("Error executing first task!"));
  } else {
    setTimeout(function(){
      console.log("First task");
      callback(null)
    },700);
  }
}

function secondTask(callback) {
  const randomNumber = Math.floor(Math.random() * 10);
  if (randomNumber == 1) {
    callback(new Error("Error executing second task!"));
  } else {
    setTimeout(function(){
      console.log("Second task");
      callback(null)
    },1500);
  }
}

function thirdTask() {
  const randomNumber = Math.floor(Math.random() * 10);
  if (randomNumber == 1) {
    console.log(new Error("Error executing third task!"));
  } else {
    setTimeout(function(){
      console.log("Third task");
      console.log("Task three completed successfully");
    },500);
  }
}

Notice that to simulate a possible error, we now create a random number between 1 and 10 at the beginning of each function. And then we create an IF statement that runs error handling if the number generated is 1 and otherwise runs the task.

Now we call the functions as before but now check the error parameter in each callback function using an IF statement. If it contains something, we have an error and log this to the console. Otherwise we log a message to the console that the task was completed successfully:

firstTask(function(error) {
  if (error) {console.log(error); } else {
  console.log("Task one completed successfully");
    }
  secondTask(function(error) {
    if (error) {console.log(error); } else {
    console.log("Task two completed successfully");
      }
      thirdTask()
  })
})

You may wonder why there is no error-checking within thirdTask. This is because it is the final task and so the error is handled within the thirdTask function itself (not a callback).

Callback function drawbacks

#1: Callback 'hell' 😈🔥⬅🏃

Notice an emerging problem with the coding above?

The subjective but well-recognized problem with callbacks is nesting. Namely, once there are several nested callbacks, it is easy to lose our way in our code – especially if there is error-handling.

The example above of three nested tasks is already going this way. But in a real project, there may be many more tasks to run in sequence.

The below would certainly qualify as callback hell or the pyramid of doom:

firstTask(function(error) {
  if (error) {console.log(error); } else {
  console.log("Task one completed successfully");
    }
  secondTask(function(error) {
    if (error) {console.log(error); } else {
    console.log("Task two completed successfully");
      }
      thirdTask(function(error) {
        if (error) {console.log(error); } else {
        console.log("Task three completed successfully");
          }
          fourthTask(function(error) {
            if (error) {console.log(error); } else {
            console.log("Fourth three completed successfully");
              } 
              fifthTask(function(error) {
                if (error) {console.log(error); } else {
                console.log("Fifth three completed successfully");
                  }
        }) 
      }) 
    }) 
  })    
})

Because of the nesting problem, this type of code for handing multiple asynchronous tasks increasingly frowned upon, especially in light of more appropriate alternatives (promises and async-await syntax).

#2: Flexibility

Callback hell grabs all the headlines but the lack of flexibility of callbacks in handling asynchronous code if just as important.

For example, what if we want the third task to wait for the completion of the first and second task but don't care about the order the first and second tasks complete in?

In the earlier example in this article, the second task waits for the completion of the first task. If we do not care about the ordering of the first and second task, this is inefficient: we would like them both to start executing immediately and call the third task as soon as both are done. But we cannot do this using the callbacks frameworks because tasks are executed one at a time (serially).

For more sophisticated sequencing, we would need to use promises or async-await.

Summary

Callbacks provide a solution to the problem of asynchronous code execution. They execute code statements serially and are a useful solution when wanting to order a limited number of tasks.

But working with callbacks soon becomes cumbersome when there are more than two processes to order (especially if error-handling is required). Moreover, if the sequencing of tasks should be more complex than serial, other techniques are needed, such as promises or the promise-based async-await syntax.