Mastering the concept of Async Javascript

In the field of web development, asynchronous JavaScript (JS) is the cornerstone of building responsive and efficient applications. Understanding the concept of asynchronous programming is crucial for developers to leverage the full potential of JS. In this technical blog post, we will understand the inner workings of asynchronous JS, covering topics from execution models to promises and the event loop.

How JavaScript executes the code ?

JavaScript is a single-threaded programming language, meaning it can only execute one task at a time. At the heart of JS execution is the call stack, a data structure that stores function calls. When a function is invoked, it's added to the call stack, and when it completes, it's removed. This synchronous execution model ensures that tasks are processed sequentially.

function greet() {
  console.log("Hello, world!");
}
greet();

Difference between Sync and Async

Synchronous code executes line by line, blocking further execution until each operation completes. Conversely, asynchronous code allows multiple tasks to run concurrently, without waiting for each other to finish. This non-blocking behavior is essential for handling time-consuming operations like network requests or file I/O, ensuring that the application remains responsive.

// Synchronous code
function syncFunction() {
  console.log("First");
  console.log("Second");
}

syncFunction();

// Asynchronous code
setTimeout(function () {
  console.log("First");
}, 0);

console.log("Second");

Convert Sync code to Async

To make synchronous code asynchronous, developers can utilize techniques such as callbacks, promises, or async/await syntax.

// Using a callback
function fetchData(callback) {
  setTimeout(function () {
    callback("Data received");
  }, 1000);
}

fetchData(function (data) {
  console.log(data);
});

Callback function

A callback function, simply put, is a function that is passed as an argument to another function and is executed after some operation has been completed. In other words, it’s a way to ensure that a certain piece of code is executed at a specific time, often asynchronously.

function fetchData(callback) {
  // Simulating fetching data from a server
  setTimeout(() => {
    const data = 'Some data retrieved from the server';
    callback(data);
  }, 2000);
}
function processData(data) {
  console.log('Processing data:', data);
}
fetchData(processData)

Drawback of callbacks

  • Callback hell

  • Inversion of Control

Callback hell

Callback Hell is essentially nested callbacks stacked below one another forming a pyramid structure. Every callback depends on / waits for the previous callback, thereby making a pyramid structure that affects the readability and maintainability of the code. Because of the pyramid like structure, we also call it pyramid of doom.

Code Snippet of callback hell.

asyncFunction1(param1, function(err, result1) {
    if (err) {
        console.error("Error in asyncFunction1:", err);
    } else {
        asyncFunction2(result1, function(err, result2) {
            if (err) {
                console.error("Error in asyncFunction2:", err);
            } else {
                asyncFunction3(result2, function(err, result3) {
                    if (err) {
                        console.error("Error in asyncFunction3:", err);
                    } else {
                        // Nested callback hell continues...
                    }
                });
            }
        });
    }
});

In this example, asyncFunction1 is called with a callback function, which in turn calls asyncFunction2 with another callback function, and so on. As more asynchronous functions are added, the code becomes deeply nested and harder to manage, leading to callback hell.

Inversion of Control

In simple words, Inversion of Controls means that by creating functions inside functions we lose control of the inner function. It depends on the outer function whether the inner function will be even executed or not. You can also see in the above example to call asyncFunction2, asyncFuntion1 needs to be executed first, and the same goes for assncyFunction3, It will get executed only if assyncFunction1 and asyncFunction2 get executed.

How promises solves the Inversion of control?

Promises provide a cleaner and more structured approach to asynchronous programming, solving the problem of inversion of control. A promise represents the eventual completion or failure of an asynchronous operation, allowing developers to chain multiple asynchronous tasks together and handle errors more effectively.

function fetchData() {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      resolve("Data received");
    }, 1000);
  });
}

fetchData()
  .then(function (data) {
    console.log(data);
  })
  .catch(function (error) {
    console.error(error);
  });

Event loop

The event loop is the mechanism responsible for managing asynchronous operations in JavaScript. It continuously checks the call stack, task queue and the subtask queue, moving tasks from the queues to the stack when the stack is empty. This ensures that asynchronous tasks are executed in the correct order, maintaining the responsiveness of the application.

Different functions in promises

  • Promise.resolve :-

    It creates a resoPromise.resolvelved promise with the specified value. It's useful for converting non-promise values into promises, or for creating pre-resolved promises.

javascriptCopy codeconst resolvedPromise = Promise.resolve("Resolved value");

resolvedPromise.then(function (value) {
  console.log(value); // Output: Resolved value
});
  • Promise.reject :-

    It creates a rejected promise with the specified reason. It's handy for immediately rejecting a promise with an error or a specific reason.

javascriptCopy codeconst rejectedPromise = Promise.reject(new Error("Something went wrong"));

rejectedPromise.catch(function (error) {
  console.error(error.message); // Output: Something went wrong
});
  • Promise.all :-

    It takes an iterable of promises as input and returns a single promise that resolves when all of the input promises have resolved, or rejects with the reason of the first promise that rejects. It's useful for handling multiple asynchronous operations concurrently.

javascriptCopy codeconst promise1 = new Promise((resolve) => setTimeout(resolve, 1000, "One"));
const promise2 = new Promise((resolve) => setTimeout(resolve, 2000, "Two"));
const promise3 = new Promise((resolve) => setTimeout(resolve, 3000, "Three"));

Promise.all([promise1, promise2, promise3])
  .then((values) => {
    console.log(values); // Output: ['One', 'Two', 'Three'] after 3 seconds
  })
  .catch((error) => {
    console.error(error); // Handle any error that occurred
  });
  • Promise.allSettled :-

    It returns a promise that resolves after all of the provided promises have either resolved or rejected, with an array of objects that each describe the outcome of each promise.

javascriptCopy codeconst promise1 = Promise.resolve("Resolved");
const promise2 = Promise.reject("Rejected");

Promise.allSettled([promise1, promise2])
  .then((results) => {
    console.log(results);
    /* Output:
    [
      { status: 'fulfilled', value: 'Resolved' },
      { status: 'rejected', reason: 'Rejected' }
    ]
    */
  })
  .catch((error) => {
    console.error(error);
  });
  • Promise.any :-

    It returns a promise as soon as any of the provided promises is fulfilled. If all of the provided promises are rejected, it returns a Promise rejected with an AggregateError, a new subclass of Error that groups together individual errors.

javascriptCopy codeconst promise1 = new Promise((resolve) => setTimeout(resolve, 2000, "One"));
const promise2 = new Promise((resolve, reject) => setTimeout(reject, 1000, "Two"));
const promise3 = new Promise((resolve, reject) => setTimeout(reject, 3000, "Three"));

Promise.any([promise1, promise2, promise3])
  .then((value) => {
    console.log(value); // Output: One (resolved first)
  })
  .catch((errors) => {
    console.error(errors); // Handle any errors that occurred
  });
  • Promise.race :-

    It returns a promise that resolves or rejects as soon as one of the promises in the iterable resolves or rejects, with the value or reason from that promise.

javascriptCopy codeconst promise1 = new Promise((resolve) => setTimeout(resolve, 2000, "One"));
const promise2 = new Promise((resolve, reject) => setTimeout(reject, 1000, "Two"));
const promise3 = new Promise((resolve) => setTimeout(resolve, 3000, "Three"));

Promise.race([promise1, promise2, promise3])
  .then((value) => {
    console.log(value); // Output: Two (rejected first)
  })
  .catch((error) => {
    console.error(error); // Handle any errors that occurred
  });

Conclusion

Mastering asynchronous JavaScript is essential for building high-performance and responsive web applications. By understanding the execution model, differences between synchronous and asynchronous code, and leveraging the power of promises, developers can create robust and efficient applications that provide exceptional user experiences.