DEV Community

Cover image for Callback, Callback Hell, Promise Chaining and Async/Await in JavaScript
Shameel Uddin
Shameel Uddin

Posted on

Callback, Callback Hell, Promise Chaining and Async/Await in JavaScript

These concepts are must-know kind of thing if you work with JavaScript/TypeScript or its libraries and frameworks and are often asked in technical interviews.

We are going to be discussing:

  1. Callback
  2. Callback Hell
  3. Promise (overview)
  4. Promise Chaining
  5. Async/Await

Callback

Before understanding about callback hell, we first need to know what the hell is callback in JavaScript?

JavaScript deals functions as first class citizens i.e., function can be passed as an argument to another function as well as a function can be returned from another function.

A callback in JavaScript is a function that is passed into another function as an argument.

Callback has nothing to do with the asynchronous behavior of JavaScript. A callback is simply a function that is passed as an argument to another function and is intended to be executed at a later time or after a specific event occurs. This event doesn't have to be an asynchronous operation; it could be a user action, a specific condition being met, or any other event you want to handle with a separate function.

Let's go on and see the examples of callback in context of synchronous and asynchronous behaviour.

Callback Synchronous Example

// A function that takes a callback as an argument and invokes it
function performOperation(callback) {
  console.log("Performing the operation...");
  callback();
}

// Define a callback function
function callbackFunction() {
  console.log("Callback has been executed!");
}

// Calling the main function with the callback
performOperation(callbackFunction);

Enter fullscreen mode Exit fullscreen mode

You will se an output like this:

Performing the operation...
Callback has been executed!
Enter fullscreen mode Exit fullscreen mode

Callback Asynchronous Example

// A function that takes a callback as an argument and invokes it
function doSomethingAsync(callback) {
    setTimeout(function() {
      console.log("Async operation done!");
      callback();
    }, 1000);
  }

  // Define a callback function
  function callbackFunction() {
    console.log("Callback has been executed!");
  }

  // Calling the main function with the callback
  doSomethingAsync(callbackFunction);

Enter fullscreen mode Exit fullscreen mode

You will see output like this:

Async operation done!
Callback has been executed!
Enter fullscreen mode Exit fullscreen mode

Callback Hell

Now we somewhat understood what the hell is callback, let's go on exploring Callback Hell.

Callback hell is introduced when we have nested functions. This is a requirement in almost all real world applications.

As more nested callbacks are added, the code becomes harder to read, maintain, and reason about. This can lead to bugs and difficulties in debugging.

See for yourself:

Declaring Functions:

function asyncFunction1(callback) {
  setTimeout(() => {
    console.log("Async Function 1 Done");
    callback();
  }, 1000);
}

function asyncFunction2(callback) {
  setTimeout(() => {
    console.log("Async Function 2 Done");
    callback();
  }, 1000);
}

function asyncFunction3(callback) {
  setTimeout(() => {
    console.log("Async Function 3 Done");
    callback();
  }, 1000);
}
Enter fullscreen mode Exit fullscreen mode

Calling the code:

asyncFunction1(() => {
  asyncFunction2(() => {
    asyncFunction3(() => {
      console.log("All Async Functions Completed");
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

As you can see pyramid of doom being formed

Image description

If there are more operations to be performed then the pyramid's height will keep increasing and so would be the impact of doom.

Real world example

For example, an authenticated user performed some action from the browser which hits an API that involves getting the data from database and sending notification E-mail back to the user, this could be performed in a series of steps one after another.

  1. Receives request from the user.
  2. Authenticate the user.
  3. Check authorization level of user to perform an action.
  4. Perform the action.
  5. Send Notification E-mail.
  6. Send response back to the user browser.

So, it could be something like...

// Step 1: Receives request from the user
app.post('/performAction', (req, res) => {
  // Step 2: Authenticate the user
  authenticateUser(req.body.username, req.body.password, (authError, isAuthenticated) => {
    if (authError) {
      res.status(401).send('Authentication failed');
    } else if (!isAuthenticated) {
      res.status(401).send('User not authenticated');
    } else {
      // Step 3: Check authorization level of user
      checkAuthorization(req.body.username, (authLevelError, isAuthorized) => {
        if (authLevelError) {
          res.status(403).send('Authorization check failed');
        } else if (!isAuthorized) {
          res.status(403).send('User not authorized');
        } else {
          // Step 4: Perform the action
          performAction(req.body.actionData, (actionError, result) => {
            if (actionError) {
              res.status(500).send('Action failed');
            } else {
              // Step 5: Send Notification E-mail
              sendNotificationEmail(req.body.username, (emailError) => {
                if (emailError) {
                  console.error('Failed to send notification email');
                }

                // Step 6: Send response back to the user browser
                res.status(200).send('Action performed successfully');
              });
            }
          });
        }
      });
    }
  });
});

Enter fullscreen mode Exit fullscreen mode

You can see that the pyramid of doom has doomed us in this example. What happens if there are sockets involved in which a notification is sent in real-time? The data to be sent via. socket and stuff like that? All within one singular operation. Then the doom will keep on increasing and it will be a living hell for developers to maintain the code, change business logic and God forbid, to debug it if any error occurs.

Promises to the rescue

ECMAScript 2015, also known as ES6, introduced the JavaScript Promise object.

Promise in JavaScript is a detailed concept to be studied but for short terms, you can think of it as a really good way to deal with asynchronous behavior of JavaScript.

It has three states:

  1. Pending
  2. Resolved
  3. Rejected

When an action is being performed then the state is in pending, if the action is successful then the result is resolved, otherwise the result is rejected.

JavaScript promises us that it will return us something after a certain duration of time whenever the asynchronous task has been completed.

You can see a short example here:

function asyncFunction() {
    return new Promise((resolve) => {
      setTimeout(() => {
        console.log("Async Function Done");
        resolve();
      }, 1000);
    });
  }

  asyncFunction().then(() => {
    console.log("Async operation performed.");
  });
Enter fullscreen mode Exit fullscreen mode

The result would be like this:

Async Function Done
Async operation performed.
Enter fullscreen mode Exit fullscreen mode

Promise Chaining

Okay, that's great. Promises introduced.. But how does it deal with our problem of callback hell?

The answer is with Promise chaining.

Let's modify our simple example from before:

Function declaration

function asyncFunction1() {
    return new Promise((resolve) => {
      setTimeout(() => {
        console.log("Async Function 1 Done");
        resolve();
      }, 1000);
    });
  }

  function asyncFunction2() {
    return new Promise((resolve) => {
      setTimeout(() => {
        console.log("Async Function 2 Done");
        resolve();
      }, 1000);
    });
  }

  function asyncFunction3() {
    return new Promise((resolve) => {
      setTimeout(() => {
        console.log("Async Function 3 Done");
        resolve();
      }, 1000);
    });
  }
Enter fullscreen mode Exit fullscreen mode

Our functions are now returning promises.
Let's see how we execute them now:

  asyncFunction1()
    .then(() => asyncFunction2())
    .then(() => asyncFunction3())
    .then(() => {
      console.log("All Async Functions Completed");
    })
    .catch((error) => {
      console.error("An error occurred:", error);
    });
Enter fullscreen mode Exit fullscreen mode

We will see the result like this:

Async Function 1 Done
Async Function 2 Done
Async Function 3 Done
All Async Functions Completed
Enter fullscreen mode Exit fullscreen mode

This increased readability, but for inclusion of async/await with the work of promises has given much better working environment to the codebase.

For the real world pseudo example we discussed earlier, that can also be re-written like this for promise chaining:

// Step 1: Receives request from the user
app.post('/performAction', (req, res) => {
  authenticateUserPromise(req.body.username, req.body.password)
    .then((isAuthenticated) => {
      if (!isAuthenticated) {
        res.status(401).send('Authentication failed');
        return Promise.reject();
      }

      // Step 3: Check authorization level of user
      return checkAuthorizationPromise(req.body.username);
    })
    .then((isAuthorized) => {
      if (!isAuthorized) {
        res.status(403).send('User not authorized');
        return Promise.reject();
      }

      // Step 4: Perform the action
      return performActionPromise(req.body.actionData);
    })
    .then(() => {
      // Step 5: Send Notification E-mail
      return sendNotificationEmailPromise(req.body.username);
    })
    .then(() => {
      // Step 6: Send response back to the user browser
      res.status(200).send('Action performed successfully');
    })
    .catch((error) => {
      console.error('An error occurred:', error);
      res.status(500).send('An error occurred');
    });
});

Enter fullscreen mode Exit fullscreen mode

Async-Await

ECMAScript 2017 introduced the JavaScript keywords async and await.

The purpose was to further simplify the use of promises in JavaScript world.

async keyword is written before the function and await is written before the function which executes asynchronous operation.

We can execute the same code like this:

async function runAsyncFunctions() {
  try {
    await asyncFunction1();
    await asyncFunction2();
    await asyncFunction3();
    console.log("All Async Functions Completed");
  } catch (error) {
    console.error("An error occurred:", error);
  }
}
runAsyncFunctions();
Enter fullscreen mode Exit fullscreen mode

Or much shorter way with IIFE (Immediately Invoked Function Execution)

(async ()=>{
    await asyncFunction1();
    await asyncFunction2();
    await asyncFunction3();
    console.log("All Async Functions Completed");
})()
Enter fullscreen mode Exit fullscreen mode

The real world example can be used with promises and async/await like this:

// Step 1: Receives request from the user
app.post('/performAction', async (req, res) => {
  try {
    // Step 2: Authenticate the user
    const isAuthenticated = await authenticateUserPromise(req.body.username, req.body.password);

    if (!isAuthenticated) {
      res.status(401).send('Authentication failed');
      return;
    }

    // Step 3: Check authorization level of user
    const isAuthorized = await checkAuthorizationPromise(req.body.username);

    if (!isAuthorized) {
      res.status(403).send('User not authorized');
      return;
    }

    // Step 4: Perform the action
    const result = await performActionPromise(req.body.actionData);

    // Step 5: Send Notification E-mail
    await sendNotificationEmailPromise(req.body.username);

    // Step 6: Send response back to the user browser
    res.status(200).send('Action performed successfully');
  } catch (error) {
    console.error('An error occurred:', error);
    res.status(500).send('An error occurred');
  }
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

  1. Callback functions can be used to perform asynchronous operation.
  2. Callback hell is introduced with nested operations.
  3. Promise chaining solves callback hell.
  4. Async/Await further improves the solution.

I hope that helps. Let me know if there was any confusion anywhere in the article or the mistake that you think should be rectified.

Follow me here for more stuff like this:
LinkedIn: https://www.linkedin.com/in/shameeluddin/
Github: https://github.com/Shameel123

Top comments (6)

Collapse
 
theaccordance profile image
Joe Mainwaring

giphy

^ Everyone who built complex Node.js apps before 2015 (myself included)

Collapse
 
sc7639 profile image
Scott Crossan

As we also called it the upside down Christmas tree

Collapse
 
shameel profile image
Shameel Uddin

Haha I'm glad I wasn't there in this world back in 2015.

Collapse
 
coderbhi profile image
Abhishek Singh

Are you youtuber too

Collapse
 
shameel profile image
Shameel Uddin

@coderbhi I am a YouTube now! :D

youtube.com/@ShameelUddin123

Collapse
 
shameel profile image
Shameel Uddin

No, I'm not.