Resolving callback hell in JavaScript using asynchronous programming

Resolving callback hell in JavaScript using asynchronous programming

Prerequisites: You should have a basic understanding of functions, structures, and loops in JavaScript before proceeding with this document because asynchronous programming is a slightly daring topic for beginners. These will help you understand the topic with ease. It will also help if you run the examples in your integrated development environment (IDE) such as Vs code or sublime Text to see how it works.

Introduction

In Synchronous programming, tasks have to run from top to bottom, or from the first to the last, but in asynchronous programming, tasks don’t have to run in a hierarchical order, making algorithms faster, without relying on prior processes or tasks to run first. In this article, you will learn how promises and async functions can resolve callback hells and synchronous programming in JavaScript because synchronous programming and the continuous nesting of callbacks into a function can be hard to understand or debug.

Synchronous programming

In Javascript, programs are executed sequentially from top to bottom, this is called synchronous programming. When the task at the top hasn’t been executed, the one below it or at the bottom won't be executed. Since the execution in synchronous programming is linear or sequential, it takes a significant amount of time to execute because smaller tasks have to wait for time-consuming tasks above them to get executed first. Here’s an example:

Paste and run this into your IDE or run it in the browser's console.

function aTimeConsumingTask() {
    const startTime = Date.now();
    console.log(`startTime: ${startTime}`)
    let sum = 0;

    //A loop that represents a time consuming task
    for (let i = 0; i < 10000000000; i++) {
      sum += i;
    }

    const endTime = Date.now();
    console.log(`endTime: ${endTime}`)
    const runTime = endTime - startTime;
    console.log(`Time-consuming task took ${runTime} milliseconds.`);
  }

  console.log('Start of the program.');

  aTimeConsumingTask();

  console.log('End of the program.');

In the example, the `aTimeConsumingTask` function contains a loop that will increment the value of `sum` ten billion times. This will cause some delay before the `runtime` value can be created. because the ten-billion loop is above `endtime` in the order of arrangement. `startTime` will be printed immediately after the execution starts, but all tasks below the loop have to wait till the iteration is complete before they can run.

The solution to the complications of the hierarchical order of task execution in synchronous programming is asynchronous programming.

Asynchronous programming

Asynchronous programming, unlike synchronous programming, allows simultaneous and independent execution of tasks. In asynchronous programming, task execution is not hierarchical or from top to bottom. Smaller tasks can be executed, while longer tasks can run in the background instead of causing a delay.

Asynchronous programming helps when data has to be fetched from a server. While the data fetching is pending, other user interfaces on the page can keep loading. Therefore the page rendering can continue without delays or lags.

Higher-Order-Functions and callbacks in Javascript

Functions are reusable blocks of code. Higher-Order-Functions are functions that take in other functions as arguments, these arguments are known as callback functions. Check here for more on functions. Here’s an example of a high-order-function:

function highOrderFunction(a_callback_function){
console.log('This is a high-order-function.');
    a_callback_function()
};
//high-order-function usage
highOrderFunction(()=>{
// The callback function passed as an argument
  console.log('But this is the callback function.');
})

Callback hell

When the functions nested inside the high-order function take in another callback as arguments, if this process continues, the code becomes a pyramid of confusion and is hard to read or understand.

let order = (make_breakfast) => {
//a one secound time
    setTimeout(() => {    
        make_breakfast();
    }, 1000);
  };
  let breafastProduction = () => {
    setTimeout(() => {
        console.log("Making bacon egg and cheese sandwich  ");
      console.log("Gathering Ingredients");
      setTimeout(() => {
        console.log("Cooking bacon");
        setTimeout(() => {
          console.log("Toasting bread");
          setTimeout(() => {
            console.log("Frying egg");
            setTimeout(() => {
              console.log("Adding cheese to egg");
              setTimeout(() => {
                console.log("Assembling sandwich");
                setTimeout(() => {
                  console.log("Adding topping");
                  setTimeout(() => {
                    console.log("Toping with secound bread");
                    setTimeout(() => {
                      console.log("Cutting sandwich");
                      setTimeout(() => {
                        console.log("Break fast prepared");
                        setTimeout(() => {
                          console.log("A call backhell");
                        }, 1000);
                      }, 1000);
                    }, 1000);
                  }, 1000);
                }, 1000);
              }, 1000);
            }, 1000);
          }, 1000);
        }, 1000);
      }, 1000);
    }, 1000);
  };
  order(breafastProduction);

You can see how making a simple breakfast becomes a pyramid of doom, each step is delayed for one second.
Javascript "promises" resolves synchronous programming and callback hell.

Promises

Promises are queries or data that might not be available or that might take some time to fetch at the start of a program but will be obtained later during the task. Promises make programming in Javascript easier because they are easier to read and less stressful to write. Promises are asynchronous and therefore more efficient in task execution than synchronous programs.

An illustration of a promise is when someone is promised a gift or some information. When the person requests the promised information, depending on if all criteria are met, the information might be delivered, or the request might be rejected if something goes wrong. This illustration can be likened to promises and their states.

The states of a promise are:

1. Pending: This is when the query is in progress. In this state, the promise has been made but hasn't been "fulfilled" or "rejected".

2. Resolved: Also known as the fulfilled stage The resolved stage is when the query request is accepted or successful. The request gets the value, information, or data needed. In this stage, the promise holds the response value. Then the attached callback(s) `.then()` will be executed for that promise.

3. Reject: If the query encounters any errors or issues during the pending state, the promise falls into the reject state. Here the request gets an error, and the promise holds the cause of the error. The callback `.catch()` is executed. An extra `then()` callback can be attached to handle the rejection of the promise.

4. Finally: After the promise has been resolved or rejected, the "finally" state finishes the promise cycle. The "finally" state is not a core part of promises, but it is used to clean up. The `.finally()` method takes a callback, which is executed whether the promise is "resolved" or rejected."

A promise's state is not reversible. If a promise has been rejected, the same promise cannot be resolved, and vice versa. When there are multiple `.then()` callbacks attached to a promise, it is called promise chaining.

Here’s an example of a promise:


let thePromise = new Promise((resolve,reject)=>{
//an asynchronous call will be made inside here
    resolve(result)
    //runs when the promise is accepted or resolved
    reject(error)
    //runs when the promise encounters an error
})
let scholarshipEligibility = (applicant)=>{
    return new Promise((resolve, reject)=>{
    // Simulating a review process with a delay of 1 second
    setTimeout(() => {
      if (applicant.age >= 18 && applicant.grade >= 85) {
        resolve(`Hurray, ${applicant.name}! You got the scholarship.`);
      } else {
        reject(`Sorry, ${applicant.name}. You did not meet the criteria for the scholarship.`);
      }
    }, 1000);
})
}

The `scholarshipEligibility` function takes an `applicant` parameter for a scholarship and returns a promise that checks the two criteria for the scholarship which are:

-Is the applicant older than 18?

-If the applicant's grade is up to 85

The promise is resolved if the criteria are met and rejected if they are not met. Now pass some `applicant` objects into the function like this:

 // Applicant details
const applicant1 = {
  name: 'Doyin Matin',
  age: 20,
  grade: 90
};

const applicant2 = {
  name: 'Ola Tyson',
  age: 17,
  grade: 78
};

console.log('Start of the scholarship application process.');

// To check eligibility for applicant 1
scholarshipEligibility(applicant1)
  .then((result) => {
    console.log(result);
  })
  .catch((error) => {
    console.error(error);
  })
  .finally(() => {
    console.log('Thanks for applying!');
  });

// To check eligibility for applicant 2
scholarshipEligibility(applicant2)
  .then((result) => {
    console.log(result);
  })
  .catch((error) => {
    console.error(error);
  })
 .finally(() => {
    console.log('Thanks for applying!');
  });

The applicants' objects are passed as arguments into the function `scholarshipEligibility` and it returns a promise and the corresponding `.then()` or `.catch()` block will be executed. The `.finally()` will run if the promise is resolved or rejected.

For more example, on promise chaining, let's have a third applicant named Rose:

const applicant3 = {
  name: 'Rose',
  age: 19,
  grade: 85
};
scholarshipEligibility(applicant3)
  .then((result) => {
    console.log(result);
  })
  .then((result)=>{
      console.log("IF you are selected continue with registration")
    })
  .then((result)=>{
      console.log("You can checout other avaliable programs ")
    })
  .catch((error) => {
    console.error(error);
  })
 .finally(() => {
    console.log('Thanks for applying!');
  });

For the third applicant, three `.then()` were used for the chaining. Note that a semicolon should only be added at the end of the last promise chain, which in the example above is the `.finally()` chain.

Async and await

Async and await structures are based on a promise. The async function is declared using the "async" keyword. With async functions, you can write asynchronous programs sequentially like synchronous programs. The use of "await" eliminates the need for explicit promise chaining, and tasks can be paused till a particular promise is resolved. Another advantage of the async function is that you can write with regular synchronous flow like `if` statements, `for` loops, and `try...catch` blocks. For example, using the previous `scholarshipEligibility` function you can replace the previous promise changing with an async function:

const applicant4 = {
  name: 'Jame giwa',
  age: 20,
  grade: 85
};
const applicant5 = {
  name: 'femi john',
  age: 16,
  grade: 89
};

async function handleScholarshipRequests(applicant){
//The try block handles the resolve while the catch handles the errors
        try{
        const result = await scholarshipEligibility(applicant);
        console.log(result);
}     catch(error){
        console.error(error);
}//a finally can be added
      finally{
           console.log("Thanks for applying!")
    } 
}
//calling the async handleScholarshipRequests 
 handleScholarshipRequests(applicant4);
 handleScholarshipRequests(applicant5)
.then(()=>console.log("You can apply when you are 18"))
//you can add a promise chain right after call the asyn function

You notice the code is shorter, and with the "await" keyword, the asynchronous program waits for the `scholarshipEligibility` function to be called and for the promise to be resolved or rejected. You can add a promise chain just as is done to the fifth applicant right after calling the async function.

Conclusion

You haven't learned everything about asynchronous programming and “Promises” in this document. There are a lot of features that you will learn later as you advance your skill. check more about promises here

When dealing with time-consuming tasks, I/O operations, or circumstances where responsiveness and efficiency are crucial, asynchronous programming is vital because it enables separate, non-blocking task execution. Promise chaining provides software engineers with a well-structured and elegant approach to handling asynchronous operations.