All About Asynchronous JavaScript

How does JS execute the code?

  • JavaScript uses a single-threaded execution model. This means it can only execute one line of code at a time. However, it can handle asynchronous operations as well

Here's a simplified view of JS execution:

  • Call Stack: A data structure that keeps track of currently executing functions. It follows a Last In First Out (LIFO) principle.

  • Event Loop: Continuously monitors the call stack and a task queue to manage asynchronous operations. whenever the call stack becomes empty then the Event Loop pushes the asynchronous operations to the call stack.

  • Execution Process: -

    • The engine executes code line by line, pushing functions onto the call stack.

    • When a function encounters an asynchronous operation (e.g., network request, SetTimeout, fs), it pauses its execution and adds to a task queue.

    • - The event loop continues executing code on the call stack until empty.

    • - Once the call stack is empty, the event loop checks the task queue for completed asynchronous operations.

    • - Completed asynchronous operations are pushed back onto the call stack to resume execution.

Synchronous vs. Asynchronous Code

  • Synchronous: Code execution happens line by line in the order it's written. The program waits for each line to finish before moving on to the next.
console.log("Line 1");
console.log("Line 2"); // Waits for Line 1 to finish
  • Asynchronous: Code execution doesn't necessarily happen line by line. The program can initiate a long-running task (like a network request) and continue executing other parts of the code without waiting for the task to complete.
console.log("Line 1");
setTimeout(() => {
    console.log("Line 3 (after 2 seconds)");
}, 2000);
console.log("Line 2"); // Continues execution without waiting for setTimeout

How can we make sync code into async?

  • Callbacks: Functions passed as arguments to be invoked when an asynchronous operation completes. Classic approach, but can lead to "callback hell" with nested callbacks.

  • Promises: Objects representing the eventual result (success or failure) of an asynchronous operation. Offers better structure and error handling compared to callbacks.

  • Async/Await (ES6): Syntactic sugar for working with promises, making asynchronous code look more synchronous. Requires using async and await keywords.

  • Web Browser APIs: APIs provided by the browser to interact with the web environment (e.g., network requests, file I/O, DOM manipulation) and often use callbacks or promises for asynchronous interactions.

What are callbacks & drawbacks of using callbacks?

  • Callbacks are a fundamental mechanism in JavaScript and many other programming languages for handling asynchronous operations. They provide a way to execute a function (the callback) after a task is completed.

  • Here's how callbacks work:

    1. Initiating the Asynchronous Operation: You start an asynchronous operation, like making a network request or reading a file from a disk.

    2. Providing the Callback: You pass a function (the callback) as an argument when initiating the asynchronous operation.

    3. Execution Upon Completion: Once the asynchronous operation finishes, the code that initiated it calls the provided callback function. This allows you to handle the result (data) or any errors that might have occurred.

  • Drawbacks of Using Callbacks:

    1. Callback Hell: When you have multiple asynchronous operations nested within each other and each one uses a callback, your code can become deeply indented and difficult to follow. This is often referred to as "callback hell."

       const data = [
         {    "name": "Balaji", "id": 1, "age": 24 },
         {    "name": "Bhanu", "id": 2, "age": 28 },
         {    "name": "Balaji2", "id":3, "age": 44 },
         {    "name": "Bhanu2", "id": 4, "age": 58 }
       ]
      
       function getDatabyId(id,callback) {
           setTimeout(()=>{
               let result = data.find((element) => element.id === id);
               console.log(result);
               if(callback){
                   callback();
               }
           },2000)
       }
       // this is refered as Callback Hell.
       getDatabyId(2,()=>{
           getDatabyId(3,()=>{
               getDatabyId(4,()=>{
                   getDatabyId(1,()=>{
                       getDatabyId(2)
                   })
               })
           })
       })
      
    2. Error Handling: Error handling can become unmanageable with callbacks, especially in nested scenarios. You need to check for errors within each callback, which can clutter the code.

    3. Asynchronous Flow Control: It can be challenging to manage the overall flow of your asynchronous code using callbacks. You might need to use additional logic or flags to maintain the desired sequence of execution.

    4. Testing: Testing code that relies heavily on callbacks can be more complex. You need to mock or stub the callback functions to isolate and test the logic

       function getUserData(userId, callback) {
         // ... Asynchronous operation ...
         callback(data);
       }
      
       function getUserPosts(userId, callback) {
         // ... Asynchronous operation ...
         callback(posts);
       }
      
       function displayUserProfile(userId) {
         getUserData(userId, (userData) => {
           getUserPosts(userId, (posts) => {
             console.log("User:", userData);
             console.log("Posts:", posts);
           });
         });
       }
      

How do promises solve the problem of inversion of control?

  1. Returning Object: When you initiate an asynchronous operation, you return a Promise object instead of relying on a callback. This decouples your code from the actual execution timeline of the operation.

  2. Chaining Asynchronous Operations: Promises enable chaining using .then() and .catch() methods. You can define actions to be taken upon successful completion (.then()) or any errors (.catch()) in a more readable sequence.

  3. Centralized Error Handling: You can handle errors at a specific point in the promise chain using .catch(), eliminating the need to check for errors in each callback.

     const data = [
       {    "name": "Balaji", "id": 1, "age": 24 },
       {    "name": "Bhanu", "id": 2, "age": 28 },
       {    "name": "Balaji2", "id":3, "age": 44 },
       {    "name": "Bhanu2", "id": 4, "age": 58 }
     ]
     function getDatabyId(id) {
         return new Promise((resolve,reject)=>{
             setTimeout(()=>{
                 let result = data.find((cv)=>cv.id==id);
                 if(result === undefined){
                     reject('Invalid Id/data with given ID is not found');
                 }
                 resolve(result);
             },id * 1000)
         })
     }
    
     getData(1)
     .then((data)=>{
         console.log(data);
         return getData(2)
     })
     .then((data)=>{
         console.log(data);
         return getData(3)
     })
     .then((data)=>{
         console.log(data);
         return getData(4);
    
     })
     .then((data)=>{
         console.log(data)
     })
     .catch((err)=>{
         console.log(err);
     })
    

What is an Event loop?

  1. Call Stack: The call stack keeps track of the currently executing functions. JavaScript executes code line by line, pushing function calls onto the stack and popping them off when the function finishes.

  2. Callback Queue: The event queue is where asynchronous operations are placed. When an asynchronous operation is initiated, it's added to the Callback queue.

  3. Event Loop Cycle: The event loop continuously monitors the call stack and the callback queue.

    • If the call stack is empty (no function is currently executing), the event loop will:

      • Remove the next callback from the callback queue.

      • Create a new context for the callback (to store variables specific to the callback function).

      • Push the callback function (the function that handles the event) onto the call stack.

    • Once the event handler function finishes execution, it's popped off the call stack, and the event loop continues the cycle.

    console.log("Start"); // 1. Executed first

    setTimeout(() => {
      console.log("After 2 seconds"); // 3. Added to callback queue, executed later
    }, 2000);

    console.log("End"); // 2. Executed after line 1

What are different functions in promises?

Let us understand this section with the following example of code :

const data = [
  {    "name": "Balaji", "id": 1, "age": 24 },
  {    "name": "Bhanu", "id": 2, "age": 28 },
  {    "name": "Balaji2", "id":3, "age": 44 },
  {    "name": "Bhanu2", "id": 4, "age": 58 }
]
function getDatabyId(id) {
    return new Promise((resolve,reject)=>{
        setTimeout(()=>{
            let result = data.find((cv)=>cv.id==id);
            if(result === undefined){
                reject('Invalid Id/data with given ID is not found');
            }
            resolve(result);
        },id * 1000)
    })
}
  1. Promise.resolve(value):

    • This function creates a new Promise object that is immediately resolved with the provided value.

    • Use this when you have a synchronous result to return from a Promise.

  2. Promise.reject(reason):

    • This function creates a new Promise object that is immediately rejected with the provided reason (an error object or any value).

    • Use this to indicate an error or failure in an asynchronous operation.

  3. then(onFulfilled, onRejected):

    • This is a method attached to a Promise object. It allows you to define callbacks to handle the successful resolution (onFulfilled) or rejection (onRejected) of the Promise.

    • onFulfilled receives the resolved value of the Promise as its argument.

    • onRejected receives the reason for rejection (the error object) as its argument.

    • You can chain multiple .then() calls to perform further actions based on the results of the Promise.

        getDatabyId(2)        // rturns a promise
        .then((data) => {    // we get data when the promise is resolved
            console.log(data);
        });
      
  4. catch(onRejected):

    • This is a method attached to a Promise object. It's a shortcut for specifying an error-handling callback (onRejected) for the Promise.

    • It's equivalent to .then(undefined, onRejected).

    • You can only have one .catch() per Promise chain.

        getDatabyId(2)        // rturns a promise
        .then((data) => {    // we get data when the promise is resolved
            console.log(data);
        })
        .catch((error)=>{    // if the promise get rejected.
            console.log(error)
        });
      
  5. Promise.all :

    • Promise.all takes an array of promises if all the promises all resolved/ fulfilled. then it returns a new promise. On resolving this newly returned promise we get an array of values of all the promises which passed to the Promise.all function. But if any of the passed promises are failed then the Promise.all immediately gets rejected.

        const idArray = [1,2,3,4];
      
        let res = Promise.all(idArray.map((element)=>getDatabyId(element)));
        console.log(res);    // res has a promise
      
        res
        .then((data)=>{    
            console.log(data); // [{...},{...},{...},{...}]
        })
        .catch((err)=>{
            console.log(err);
        })
      
  6. Promise.allSettled :

    • Works similarly to Promise.all but it returns the status of all the promises passed to it along with its value. it doesn't care if any of the passed promises get rejected. it returns the array result of the passed array of promises. if the promise gets resolved it returns its data/ value. if it is rejected it returns its reason for the rejection.

        const idArray = [1,2,3,4,5];
      
        let result = Promise.allSettled(idArray.map((element)=>getDatabyId(element)));
        console.log(result);    // result has a promise
      
        result
        .then((data)=>{    
            console.log(data); // [{...},{...},{...},{...},errVal]
        })
        .catch((err)=>{
            console.log(err);
        })
      
  7. Promise.race :

    • It takes the array of promises and returns the value of the first settled promise. whether it is resolved or rejected.

        const idArray = [1,2,3,4,0];
        let res = Promise.race(idArray.map((element)=>getDatabyId(element)));
        console.log(res);
        // let assume in this case the time for the promise to get settled 
        // is depends on it's id. so the getDatabyId(1) settles second and 
        // getData(0) settles first.
        res
        .then((data)=>{
            console.log(data);            
        })
        .catch((error)=>{
            console.log(error)
         // Output: Invalid Id/data with given ID is not found
        })
      
  8. Promise.any :

    • it is similar to Promise.race but it returns the value of the first promise that is resolved.

        const idArray = [1,2,3,4,0];
        let res = Promise.race(idArray.map((element)=>getDatabyId(element)));
        console.log(res);
        // let assume in this case the time for the promise to get settled 
        // is depends on it's id. so the getDatabyId(1) settles second and 
        // getData(0) settles first.
        res
        .then((data)=>{
            console.log(data);   
            // Output: {"name": "Balaji", "id": 1, "age": 24}          
        })
        .catch((error)=>{
            console.log(error)
        })