Exploring the Concept of Asynchronous JavaScript


How does JavaScript execute the code?

JavaScript is a single-threaded programming language. This means that it can execute only one piece of code at a time. Therefore, if a long-running operation is executing, it will block the execution of any other code until it finishes.

JavaScript has asynchronous capabilities to handle certain operations that can potentially block the main execution thread, such as network requests, file I/O, timers, and event handlers. These asynchronous operations are placed into separate threads or web APIs, allowing the main JavaScript thread to continue executing other code without being blocked.

The call stack is a data structure that tracks the order in which functions are executed in a program. When a script begins running, the JavaScript engine forms a global execution context and adds it to the call stack. The last function added to the stack is the first one to run.

To handle asynchronous operations without blocking the main thread, JavaScript uses an event loop. All the asynchronous calls in the program are placed in a separate queue called the task queue. The event loop continuously checks the task queues and call stack, pushing any completed asynchronous calls from the task queue into the call stack.

What is the difference between Sync & Async?

Synchronous (sync) and asynchronous (async) are two different ways of handling tasks and managing code execution in programming.

  1. Synchronous: In synchronous code execution, tasks are performed one after the other, in sequence. Each task waits for the previous one to finish before starting.

    Synchronous operations block the execution of further code until they are completed.

     function myFunction() {
       console.log("Start");
       console.log("Middle");
       console.log("End");
     }
    
     console.log("Before calling the function");
     myFunction();
     console.log("After calling the function");
    

    In the above code, the program waits for each console statement or function to execute before moving to the next statement.

  2. Asynchronous: Asynchronous operations do not block the main thread. It allows the program to continue executing while waiting for the asynchronous task to complete.

     function myFunction() {
       setTimeout(() => console.log("Inside myFunction"), 3000);
     }
     console.log("Before calling the function");
     myFunction();
     console.log("After calling the function");
    

    In the above code, the program won't block the execution until the function executes; instead, it will proceed to execute further statements. When the asynchronous operations are complete, it will print the result.

How can we make sync code into async?

There are several ways to convert synchronous code into asynchronous code in JavaScript. The main techniques are:

  1. Callbacks:

    Callbacks are the traditional way of handling asynchronous operations in JavaScript. Callbacks are functions passed as arguments. They execute after a certain operation is completed or after a certain amount of time has passed.

     function fetchData(callback) {
         setTimeout(() => {
           const data = "Hello";
           callback(data);
         }, 2000);
       }
    
     fetchData((data) => console.log(data));
    
  2. Promises:

    A promise in JavaScript is an object representing the eventual completion or failure of an asynchronous operation. When a new Promise is created, it can be in one of three states:

    1. Pending: The initial state, when the asynchronous operation is not yet completed.

    2. Fulfilled: The final state, when the asynchronous operation has been completed successfully. The Promise returns a resolved value.

    3. Rejected: The final state, when the asynchronous operation has failed. The Promise returns a rejection reason.

    const myPromise = new Promise((resolve, reject) => {
      setTimeout(() => {
        const data = "Hello";
        resolve(data); 
      }, 2000);
    });

    myPromise
      .then(data => {
        console.log(data);
      })
      .catch(error => {
        console.error(error);
      });

What are callbacks & what are the drawbacks of using callbacks?

Callbacks are the traditional way of handling asynchronous operations in JavaScript. Callbacks are functions passed as arguments. They execute after a certain operation is completed or after a certain amount of time has passed.

function fetchData(callback) {
    setTimeout(() => {
      const data = "Hello";
      callback(data);
    }, 2000);
}

fetchData((data) => console.log(data));

Drawbacks are:

  1. Callback Hell: When multiple asynchronous operations depend on the result of previous operations, callbacks can become deeply nested, leading to code that is difficult to read and maintain. This phenomenon is called "callback hell".

  2. Inversion of control: Using callbacks, the control flow is inverted or reversed. Instead of the caller being in control and waiting for the operation to complete, it hands over control to the callback function, which will be executed at some later point in time.

  3. Error Handling: With callbacks, it becomes difficult to handle errors especially when dealing with multiple nested callbacks. If an error occurs in one of the callbacks, it can be challenging to handle that error appropriately.

How do promises solve the problem of inversion of control?

A promise in JavaScript is an object representing the eventual completion or failure of an asynchronous operation. When a new Promise is created, it can be in one of three states:

  1. Pending: The initial state, when the asynchronous operation is not yet completed.

  2. Fulfilled: The final state, when the asynchronous operation has been completed successfully. The Promise returns a resolved value.

  3. Rejected: The final state, when the asynchronous operation has failed. The Promise returns a rejection reason.

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const data = "Hello";
    resolve(data); 
  }, 2000);
});

myPromise
  .then(data => {
    console.log(data);
  })
  .catch(error => {
    console.error(error);
  });

The 'myPromise' variable represents a promise with an asynchronous operation. Inside the promise executor function, we define the asynchronous task. Depending on its outcome, we either resolve the promise with the fetched data or reject it with an error. We chain the 'then' method to 'myPromise' to handle the resolved data. This method takes a callback function as its argument, which executes when the promise is resolved successfully. Importantly, there's no inversion of control as the program flow remains sequential and easier to understand.

What is an event loop?

To manage asynchronous operations without halting the main thread, JavaScript employs an event loop. When an asynchronous operation begins, it gets assigned to a separate Web API, enabling the main thread to proceed with executing other code. After the asynchronous task finishes, it joins a task queue. The event loop regularly inspects these queues and places any finished asynchronous tasks onto the call stack as soon as it's clear, enabling the main thread to run the associated callback functions.

What are the different functions of promises?

The different functions of promises are:

  1. Promise.all()

    The promise.all method takes an array of promises and returns a single promise that resolves when all of the input promises have been resolved or rejected as soon as one of the input promises is rejected.

     const promise1 = new Promise((resolve, reject) => {
       resolve(30);
     });
     const promise2 = new Promise((resolve, reject) => {
       resolve("rejected");
     });
     const promise3 = new Promise((resolve, reject) => {
       setTimeout(resolve, 2000, "foo");
     });
    
     Promise.all([promise1, promise2, promise3])
       .then((values) => {
         console.log(values);
       })
       .catch((error) => console.log(error));
    

    Output: rejected

  2. Promise.any()

    The promise.any method takes an array of promises and returns a single promise that resolves as soon as one of the input promises resolves, or rejects if all input promises reject.

     const promise1 = new Promise((resolve, reject) => {
       resolve(30);
     });
     const promise2 = new Promise((resolve, reject) => {
       reject("rejected");
     });
     const promise3 = new Promise((resolve, reject) => {
       setTimeout(resolve, 2000, "foo");
     });
    
     Promise.any([promise1, promise2, promise3])
       .then((values) => {
         console.log(values);
       })
       .catch((error) => console.log(error));
    

    Output: 30

  3. Promise. allSettled()

    The promise.allSettled method takes an array of promises and returns a single promise that resolves after all input promises have been settled. It returns an array of objects containing the status and result of each promise.

     const promise1 = new Promise((resolve, reject) => {
       resolve(30);
     });
     const promise2 = new Promise((resolve, reject) => {
       reject("rejected");
     });
     const promise3 = new Promise((resolve, reject) => {
       setTimeout(resolve, 2000, "foo");
     });
    
     Promise.allSettled([promise1, promise2, promise3])
       .then((values) => {
         console.log(values);
       })
       .catch((error) => console.log(error));
    

    Output:

     [
       { status: 'fulfilled', value: 30 },
       { status: 'rejected', reason: 'rejected' },
       { status: 'fulfilled', value: 'foo' }
     ]
    
  4. Promise.race()

    The promise.race method takes an array of promises and returns a single promise that resolves or rejects as soon as one of the input promises resolves or rejects, whichever happens first.

     const promise1 = new Promise((resolve, reject) => {
       resolve(30);
     });
     const promise2 = new Promise((resolve, reject) => {
       reject("rejected");
     });
     const promise3 = new Promise((resolve, reject) => {
       setTimeout(resolve, 2000, "foo");
     });
    
     Promise.race([promise1, promise2, promise3])
       .then((values) => {
         console.log(values);
       })
       .catch((error) => console.log(error));
    

    Output: 30

  5. Promise.resolve()

    It resolves a promise with the value passed to it.

     const promise2 = Promise.resolve("Success");
     promise2
       .then((values) => {
         console.log(values);
       })
       .catch((error) => console.log(error));
    

    Output: Success

  6. Promise.reject()

    It rejects a promise with the error passed to it.

     const promise2 = Promise.reject("Error");
     promise2
       .then((values) => {
         console.log(values);
       })
       .catch((error) => console.log(error));
    

    Output: Error