Understanding Asynchronous Programming in JavaScript: Callback, Callback Hell, Promises, and Await

Understanding Asynchronous Programming in JavaScript: Callback, Callback Hell, Promises, and Await

In our last article, we discussed the concept of asynchronous programming and its benefits in JavaScript. Asynchronous programming allows for non-blocking execution of code, which can greatly improve the performance and responsiveness of your application. By utilizing this technique, you can ensure that your application can continue to function even when certain tasks, such as network requests, take longer to complete. In this article, we will delve deeper into the topic and explore specific examples of how asynchronous programming can be used to improve your JavaScript code. For those who have not read our previous article on this topic, we strongly recommend taking a look before continuing, using this link. It will provide valuable context and background information that will help you better understand the material in this article.

In this article, we will explore different techniques for dealing with asynchronous code in JavaScript, including callbacks, callback hell, promises, and the await keyword.

Callbacks

A callback is a function that is passed as an argument to another function, and is executed after the main function has completed its task. In JavaScript, callbacks are commonly used to handle asynchronous events, such as network requests or file operations.

Callbacks are commonly used in event-driven programming, such as in working with the DOM. For example, you can use the addEventListener() function to attach a callback to a specific DOM event, such as a button click or a form submission. The callback function will be invoked whenever the specified event occurs, allowing you to perform any necessary actions.

Here is an example of a callback function that logs the result of an asynchronous operation:

function getData(callback) {
  setTimeout(() => {
    const data = 'Hello, World!';
    callback(data);
  }, 1000);
}

getData(data => {
  console.log(data);
});

In this example, the getData function simulates an asynchronous operation by waiting for one second before executing the callback function. The callback function receives the result of the operation as an argument and logs it to the console.

Callback Hell

Callback hell is a term used to describe a situation in which a program becomes difficult to read and understand due to the excessive use of nested callback functions. This can happen when a program needs to perform a series of asynchronous operations, such as making HTTP requests, reading from a file, or interacting with a database.

Problems with callback hell include:

  • Code can become difficult to read and understand, especially for developers who are new to the project.

  • Debugging can be challenging, as it can be difficult to trace the flow of execution through multiple nested callbacks.

  • Maintaining the code can be difficult, as changes to one callback function may inadvertently affect other callbacks.

Here is an example of callback hell:

function getData(url, success, error) {
    if (!url) {
        return;
    }
// load content of page from url
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.send();
xhr.onreadystatechange = function () {
    if (xhr.status === 200) {
        // Run success callback
        success(xhr.responseText)
    } else {
        // Run error callback
        error(xhr.status)
    }
} }


function success(result) {
    console.log("Finally done")
    console.log(result)
}

function error(status) {
    console.log(`An error with status code ${status} occurred: `)
}

// using the browser prompt api 
const url = prompt("Enter a URL")

// Using multiple callbacks can result in a callback hell.
// Using multiple callbacks can result in a callback hell.
getData(url, (res1) => {
    console.log("Success 1", res1);
    getData("https://reqres.in/api/users/1", (res2) => {
        console.log("Success 2", res2);
        getData("htps://reqres.in/api/users/3", (res3) => {
            console.log("Success 3", res3);
            getData("https://reqres.in/api/users/4", 
            success, 
            error);
        }, error);
    }, error);
}, error);

This code block defines a function called getData() that takes in three arguments: a URL, a success callback function, and an error callback function. The function first checks if the URL is present, and if not, the function returns without doing anything.

Then, the code creates a new instance of the XMLHttpRequest object, which is used to make a GET request to the specified URL. The onreadystatechange event is set to a function that checks the status of the request. If the status is 200 (meaning the request was successful), the success callback function is called and passed the response text as an argument. If the request was not successful, the error callback function is called and passed the status code as an argument.

The code also defines two sample callback functions, success() and error(), which log messages to the console.

The code then uses the browser's prompt() function to ask the user to enter a URL. The entered URL is passed as an argument to the getData() function along with the success() and error() callback functions.

The code also makes multiple calls to the getData() function in a nested manner, and it is called within the success callback function of the previous call. This can result in a situation called "callback hell" which is a phenomenon where the callbacks are nested in multiple layers and it becomes difficult to read, understand and maintain the code.

Promises

A JavaScript promise is an object that represents the eventual completion (or failure) of an asynchronous operation, and its resulting value. A promise can be in one of three states: fulfilled, rejected, or pending. A promise is said to be fulfilled if the asynchronous operation completed successfully and the promise has a resulting value. A promise is rejected if the asynchronous operation failed and the promise has a reason for the failure. If a promise is neither fulfilled nor rejected, it is said to be pending. Promises provide a way to register callbacks to be invoked when the promise is fulfilled or rejected.

Promises are commonly used to handle asynchronous operations such as fetching data from a server or reading a file. For example, a function that fetches data from a server could return a promise that is fulfilled with the data when it becomes available.

function getData(url) {
    return new Promise((resolve, reject) => {
       // check if a url is provided
        if (!url) {
            //if not, reject the promise with an error message
           reject("No URL provided"); 
       }
        // create a new XMLHttpRequest 
       const xhr = new XMLHttpRequest(); object
        // open a GET request to the provided URL
       xhr.open("GET", url); 
        // send the request
       xhr.send(); 
        // when the request is loaded
       xhr.onload = function () { 
        // check if the status is 200 (success)
        if (xhr.status === 200) { 
            // if so, resolve the promise with the response text
            resolve(xhr.responseText); 
        } else {
            // if not, reject the promise with the status code
            reject(xhr.status); 
        }
    }}
)}

The code above is similar to the previous one, just that we promisified it. If the request status is 200 (success), the promise is resolved with the response text. If the request status is not 200, the promise is rejected with the status code.

const promises = [
    getData("https://example.com/api/post/2"),
    getData("https://example.com/api/users/2"),
    getData("https://example.com/api/unknown")
];

Promise also has a static method Promise.all() which takes an array of promises and returns a single promise that is fulfilled with an array of the fulfilled values of the input promises, in the same order as the input promises.

Promise.all(promises)
    .then((results) => { // when all promises are resolved successfully
        console.log("Success!", results); // log the results
    }).catch(status => { // if any of the promises are rejected
        console.log(`An error with status code ${status} occurred:` ); // log the error with the status code
    });

Promise also has a static method Promise.race() which takes an array of promises and returns a single promise that is fulfilled or rejected as soon as one of the input promises is fulfilled or rejected, with the value or reason from that promise.

Promise.race(promises)
    .then((result) => { //waits only for the first settled promise and gets its result (or error)
        console.log("Success!", result);
    }).catch(status => {
        console.log(`An error with status code ${status} occurred: `);
    });

The Promise.any() method is used to return a promise that is fulfilled by the first input promise that is fulfilled. The method takes an iterable of promises as its input and returns a single promise that is fulfilled with the value of the first input promise that is fulfilled. If all input promises are rejected, the returned promise is rejected with an aggregate of the rejection reasons.

Promise.any(promises)
    .then((result) => { //waits for any of the first successful/fulfilled promise
        console.log("First Success!", result);
    }).catch(status => {
        console.log(`An error with status code ${status} occurred: `);
  });

await keyword

The await operator is a more concise and readable alternative to using the then() and catch() methods of Promises. It is used to pause the execution of an async function until a promise is resolved or rejected. The result of the resolved promise is then returned as the value of the await expression.

function getData(url) {
    return new Promise((resolve, reject) => {
        if (!url) {
            reject("No URL provided");
        }

        const xhr = new XMLHttpRequest
        ();
        xhr.open("GET", url)
        xhr.send()
        xhr.onload = function () {
            if (xhr.status === 200) {
                resolve(xhr.responseText)
            } else {
                reject(xhr.status)
            }
        }
    })
}

async function getAllData() {
    const result = await getData(url);
    console.log("Success 1", result);

    const result2 = await getData(url2);
    console.log("Success 2", result2)

    const result3 = await getData(url3);
    console.log("Success 3", result3)
}

The await keyword is used to wait for the promise returned by these functions to be fulfilled before moving on to the next line of code.

Conclusion

Asynchronous programming is an important concept in JavaScript that allows you to write efficient and responsive code. There are multiple ways to handle asynchronous tasks in JavaScript, such as callbacks, promises, and async/await. Understanding and utilizing these techniques can help you write more robust and maintainable code.

Link to previous article

Did you find this article valuable?

Support Eddy's Space by becoming a sponsor. Any amount is appreciated!