About Asynchronous JavaScript

JavaScript executes code in a single-threaded, synchronous manner,which means it process one task at a time in sequential order.

Call Stack

The core of JavaScript execution model is the call stack. The call stack is a data structure that records where in the program we are. Every time a function is invoked,a new frame is pushed into the stack, representing that function execution context. When the function completes, its frame is popped off the stack, allowing the program to move on to the next task.

function first(){
console.log("inside firstFunction");
}
function second(){
firstFunction();
console.log("Inside SecondFunction");
}
second();
outPut:
Inside firstFunction 
Inside secondFunction

In this example, when second() is called, its frame is pushed into the call stack. Inside second(), first() is called, pushing its frame into the stack as well. Once first() completes, its frame is popped off, followed by second()

Asynchronous JavaScript

In JavaScript, asynchronous operations allow the execution of code to continue without waiting for these operations to complete. Common asynchronous operations include timers, network requests, and event handlers.

console.log("Start");

setTimeout(() => {
  console.log("Inside setTimeout");
}, 0);

console.log("End");
output:
Start
End
Inside setTimeout

When you run this code, even though setTimeout() is set to 0 milliseconds, it doesn't execute immediately. Instead, it's pushed to the Web APIs and the callback is queued to the Event Queue once the timer expires.

This demonstrates that even though JavaScript is single-threaded, it can handle asynchronous operations by using event loops. The event loop constantly checks the call stack and the event queue. If the stack is empty, it pushes events from the queue onto the stack for execution. This allows JavaScript to handle multiple operations concurrently without blocking the main thread.

Difference Between Synchronous and Asynchronous in JavaScript

Synchronous Code: In synchronous code, tasks are executed one after another, and each task must wait for the previous one to finish before it starts. This means that if one task takes a long time to complete, it can block subsequent tasks from running.

console.log("Start");
function syncTask() {
    for (let index = 0; index < 10; index++) {
       console.log(index)
 }
}
syncTask();
console.log("End");
output:
Start
0
1
2
3
4
5
6
7
8
9
End

In above example, "Start" is printed, then syncTask() runs, which takes a long time to complete, and finally "End" is printed after syncTask() completes.

Asynchronous Code: Asynchronous code allows tasks to be executed independently from one another. Instead of waiting for each task to finish before moving on to the next one, asynchronous tasks can run simultaneously. This is particularly useful for handling time-consuming operations like fetching data from a server or reading files.

console.log("Start");
setTimeout(function() {
    console.log("Async task complete");
}, 2000);

console.log("End");
output:
Start
End 
// after two seconds 
Asyc task complete

In above example, "Start" is printed, then setTimeout() is called, which schedules a function to run after 2000 milliseconds. While waiting for the timeout, "End" is printed. After 2 seconds, "Async task complete" is printed.

How to convert Synchronous to Asynchronous

To convert synchronous code into asynchronous code in JavaScript, you typically use asynchronous APIs provided by the language or its libraries. Here are a few common techniques:

Using Callbacks: You can convert synchronous code into asynchronous code using callback functions. Instead of waiting for a task to complete, you pass a callback function to be executed once the task is finished.

console.log("Start");
function asyncTask(callback) {
    setTimeout(function() {
        console.log("Async task complete");
        callback(); 
    }, 2000);
}
asyncTask(function() {
    console.log("End");
});

Using Promises: Promises provide a more structured way to work with asynchronous code. You can create a promise that resolves when the asynchronous task completes successfully or rejects if it encounters an error.

console.log("Start");
function asyncTask() {
    return new Promise(function(resolve, reject) {
        setTimeout(function() {
            console.log("Async task complete");
            resolve(); 
        }, 2000);
    });
}
asyncTask().then(function() {
    console.log("End");
});

Using Async/Await: Async/await is a syntactic sugar built on top of promises, making asynchronous code look synchronous. You mark a function as async and use the await keyword to wait for the completion of asynchronous tasks.

console.log("Start");
async function asyncTask() {
    await new Promise(function(resolve) {
        setTimeout(function() {
            console.log("Async task complete");
            resolve(); 
        }, 2000);
    });
}
async function main() {
    await asyncTask();
    console.log("End");
}

main();

Callbacks and it's drawbacks

Callbacks are functions that are passed as arguments to other functions and are executed at a later time, typically after the completion of an asynchronous operation. They are commonly used in JavaScript to handle asynchronous tasks, such as making HTTP requests or reading files.

function fetchData(callback) {
    setTimeout(function() {
        const data = { name: "John", age: 30 };
        callback(data);
    }, 2000);
}
fetchData(function(data) {
    console.log("Data received:", data);
});

In above example, the fetchData() function takes a callback function as an argument. After simulating an asynchronous operation with setTimeout(), it invokes the callback function with the fetched data.

Drawbacks

Callback Hell: Asynchronous operations often require multiple nested callbacks, leading to deeply nested and hard-to-read code. This issue is commonly known as "callback hell" or "pyramid of doom." As more asynchronous operations are added, the code becomes increasingly complex and difficult to maintain

fetchData(function(data) {
    process1(data, function(result1) {
        process2(result1, function(result2) {
            process3(result2, function(result3) {
                // More nested callbacks...
            });
        });
    });
});

Loss of Readability: Callback-based code can be challenging to understand, especially for developers who are not familiar with the asynchronous programming paradigm. The flow of execution is not linear, making it harder to follow the logic of the code.

Error Handling: Error handling in callback-based code can be cumbersome. Each callback must handle its own errors, leading to repetitive error-checking code and potential code duplication.

Difficulty in Debugging: Debugging callback-based code can be challenging due to its nested structure and the lack of clear visibility into the flow of execution. Identifying and tracing errors becomes more difficult as the complexity of the code increases.

How Promises solve the problem of invention of control

In JavaScript, promises help manage asynchronous tasks in a more organized and readable way. Here's how they solve the problem of inversion of control:

Promises give a clear structure to handle asynchronous operations. You initiate the operation and get back a promise, which represents the result (whether it succeeds or fails).

Promises allow you to chain multiple asynchronous operations one after the other. This chaining makes the code look more like a sequence of steps rather than nested callbacks.

Promises separate the initiation of an operation from its handling. You don't need to worry about managing the flow of execution or handling errors at the point of initiation; you can handle them later in the chain.


function fetchData() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const data = { name: "John", age: 30 };
                     resolve(data);
        }, 2000);
    });
}
fetchData()
    .then((data) => {
        console.log("Data received:", data);
          })
    .catch((error) => {
        console.error("Error occurred:", error);
    });
  • fetchData initiates an asynchronous operation and returns a promise.

  • .then() is used to handle the successful completion of the operation. You pass a function to .then() which will be executed when the promise resolves (when the data is received).

  • .catch() is used to handle any errors that may occur during the operation.

What is eventLoop

The event loop is a fundamental concept in JavaScript's concurrency model that allows the language to handle asynchronous operations efficiently. It's responsible for managing the execution of code, including handling asynchronous tasks such as callbacks, promises, and DOM events.

When you run a JavaScript program, it starts with a main execution context. This context includes the global scope and any synchronous code that needs to be executed.

Asynchronous operations, such as timers (setTimeout, setInterval), AJAX requests, and DOM events, are initiated during the execution of the main context. These operations are handled separately from the main execution thread and are queued for later execution.

Asynchronous tasks are placed in the event queue once they are completed or ready to be executed. The event queue follows a first-in, first-out (FIFO) order.

The event loop constantly monitors the call stack and the event queue. When the call stack is empty (i.e., there's no more synchronous code to execute), the event loop takes the first task from the event queue and pushes it onto the call stack for execution.

The task taken from the event queue is executed within its own execution context, which may include callbacks or other asynchronous operations. Once the task is complete, its execution context is popped off the call stack.

console.log("Start");

setTimeout(function() {
    console.log("Async operation 1");
}, 1000);

setTimeout(function() {
    console.log("Async operation 2");
}, 500);

console.log("End");
  • The synchronous code (console.log statements) executes first, printing "Start" and "End" to the console.

  • Two setTimeout functions are called, each scheduling an asynchronous task to be executed after a certain delay.

  • Since setTimeout is asynchronous, it doesn't block the main thread. Instead, it schedules the provided function to be executed after the specified delay.

  • After the synchronous code completes, the event loop starts processing the event queue.

  • When the specified delay for each setTimeout function elapses, their respective callback functions are moved from the event queue to the call stack and executed. This prints "Async operation 2" first and then "Async operation 1" to the console.

Different Functions in Promises

Promises in JavaScript offer several methods to handle multiple asynchronous operations in different ways. Let's cover some of the commonly used methods:

Promise.all(): This method takes an array of promises as input and returns a single promise. The returned promise resolves when all input promises have resolved or rejects when any of the input promises rejects. The resolved value is an array containing the resolved values of the input promises, in the same order.

const promise1 = new Promise((resolve) => setTimeout(() => resolve(1), 1000));
const promise2 = new Promise((resolve) => setTimeout(() => resolve(2), 2000));
const promise3 = new Promise((resolve) => setTimeout(() => resolve(3), 3000));

Promise.all([promise1, promise2, promise3])
    .then((values) => {
        console.log(values); // Output: [1, 2, 3]
    })
    .catch((error) => {
        console.error(error);
    });

Promise.race(): This method takes an array of promises as input and returns a single promise. The returned promise resolves or rejects as soon as one of the input promises resolves or rejects, with the value or reason of that promise.

const promise1 = new Promise((resolve) => setTimeout(() => resolve(1), 1000));
const promise2 = new Promise((resolve, reject) => setTimeout(() => reject("Timeout"), 500));

Promise.race([promise1, promise2])
    .then((value) => {
        console.log(value); // Output: 1 (from promise1)
    })
    .catch((error) => {
        console.error(error); // Output: Timeout (from promise2)
    });

Promise.allSettled(): This method takes an array of promises as input and returns a single promise. The returned promise resolves after all input promises have settled (either resolved or rejected), with an array of objects describing the outcome of each promise.

const promise1 = new Promise((resolve) => setTimeout(() => resolve(1), 1000));
const promise2 = new Promise((resolve, reject) => setTimeout(() => reject("Error"), 500));

Promise.allSettled([promise1, promise2])
    .then((results) => {
        console.log(results);
    });
  • Promise.resolve() creates a new Promise object that is resolved with a given value or another promise.

  • If the provided value is a promise, Promise.resolve() returns the same promise. If it's a non-promise value, Promise.resolve() returns a new promise that resolves with that value.

  • This method is commonly used when you want to convert a non-promise value into a promise or when you want to ensure that a function always returns a promise.

    
      const resolvedPromise = Promise.resolve("Resolved value");
    
      resolvedPromise.then((value) => {
          console.log(value); 
      });
      const innerPromise = new Promise((resolve) => setTimeout(() => resolve("Inner resolved value"), 1000));
    
      const outerPromise = Promise.resolve(innerPromise);
    
      outerPromise.then((value) => {
          console.log(value); 
      });
    
    • Promise.reject() creates a new Promise object that is rejected with a given reason (usually an error).

    • This method is useful when you want to explicitly reject a promise, such as in error handling or when you need to return a rejected promise from a function.


const rejectedPromise = Promise.reject("Error occurred");

rejectedPromise.catch((reason) => {
    console.error(reason); 
});

Promise.any() is a relatively new addition to the JavaScript Promise API, introduced in ECMAScript 2021 (ES12). It's designed to handle multiple promises and returns a single promise that resolves as soon as any of the input promises resolve. If all input promises are rejected, it rejects with an AggregateError containing the reasons for all rejections. This is particularly useful when you have multiple asynchronous operations and you're interested in the first one to complete, regardless of whether it succeeded or failed.

const promise1 = new Promise((resolve, reject) => {
    setTimeout(() => resolve("Promise 1 resolved"), 1500);
});

const promise2 = new Promise((resolve, reject) => {
    setTimeout(() => reject("Promise 2 rejected"), 1000);
});

const promise3 = new Promise((resolve, reject) => {
    setTimeout(() => resolve("Promise 3 resolved"), 2000);
});

Promise.any([promise1, promise2, promise3])
    .then((value) => {
        console.log("First promise to resolve:", value);
    })
    .catch((error) => {
        console.error("All promises rejected:", error);
    });