"A Deep Dive into JavaScript Asynchronous Programming: Promises, Async/Await, and Callbacks"

Javascript

JavaScript is a high-level programming language used for web development. It is single-threaded, meaning it can only execute one command at a time, and is interpreted, meaning it is not compiled before execution.

V8 is a high-performance JavaScript engine developed by Google and used in their Chrome browser, as well as other applications. It compiles JavaScript code to machine code, improving its performance.

JS code Execution.

When the JavaScript Engine starts execution, it makes an environment called execution context.

There are two types of execution contexts: global and function. The global execution context is created when a JavaScript script first starts to run, and it represents the global scope in JavaScript. A function execution context is created whenever a function is called, representing the function's local scope.

Phases of Execution context.

There are 2 phases of the javascript execution context.

  1. Creation phase: The JavaScript engine sets up the environment, creating a global object (like window in browsers), allocating memory for variables (initially set to undefined), and storing function references.

  2. Execution phase: The engine processes the code line by line, assigning actual values to variables and executing functions, creating new execution contexts for function calls. After execution, the values of square1 and square2 are determined and logged, and the execution context is destroyed.

let's take a simple example.

var n = 5;

function square(n) {
  var ans = n * n;
  return ans;
}

var square1 = square(n);
var square2 = square(8);  

console.log(square1)
console.log(square2)

In the creation phase

// In the creation phase
var n = undefined
square = function expression {...}
var square1 = undefined
var square2 = undefined

In the execution phase

var n = 5
square = function expression {...}
var square1 = 25
var square2 = 64

Synchronous vs Asynchronous

Synchronous Code: Executes sequentially, blocking further execution until the current operation completes. For example:

function sumFunction(x,y){
    const sum = x+y
    return sum
}

const a = 10
const b = 20
console.log("value of a",a)
console.log("value of b",b)
const sum = sumFunction(a,b)
console.log("sum of a and b is ",sum)
console.log("Done")

/*output is :

value of a 10
value of b 20
sum of a and b is  30
Done
*/

Asynchronous Code: Allows other operations to execute before the current one completes. Common asynchronous operations include network requests, file reading, and timers. For example:

console.log('Start');
setTimeout(() => {
  console.log('This is async');
}, 1000);
console.log('End');
/* output is :
Start
End
This is async
*/

What is a Call stack?

To keep track of all the contexts, including global and functional, the JavaScript engine uses a call stack. A call stack is also known as an 'Execution Context Stack', 'Runtime Stack', or 'Machine Stack'.

It uses the LIFO principle (Last-In-First-Out). When the engine first starts executing the script, it creates a global context and pushes it on the stack. Whenever a function is invoked, similarly, the JS engine creates a function stack context for the function and pushes it to the top of the call stack, and starts executing it.

When execution of the current function is complete, then the JavaScript engine will automatically remove the context from the call stack and it goes back to its parent.

What is Event Loop?

An event loop is a looping algorithm or we can say a job scheduling algorithm that schedules the events based on the priorities of the task and then executes it.

This algorithm makes use of a queue data structure for scheduling the tasks and then it uses a stack data structure called Call stack for executing the tasks.

The event loop continuously observes the call stack and when it is empty it transfers the task to the call stack.

It has two queues namely - Task Queue and Microtask queue, both of them are similar the only difference is that all the microtasks in the microtask queue get executed before tasks in the task queue.

In JavaScript, the microtask queue is used for tasks that need to be executed as soon as possible. These tasks include:

  • Promise resolutions

  • Mutations to the DOM

  • Promise callbacks

Tasks can be scheduled using various mechanisms, such as setTimeout, setInterval, DOM events, and more.

Here's an overview of how tasks are scheduled in JavaScript:

1. setTimeout and setInterval:

  • setTimeout and setInterval are functions provided by the browser environment (Web APIs) in web applications.

  • They allow you to schedule the execution of a function after a specified delay or at regular intervals.

  • When you call setTimeout or setInterval, a timer is set in the background. When the timer expires, the associated function is added to the Task Queue.

2. DOM Events:

  • Events in the Document Object Model (DOM), such as user interactions or element-related events, can trigger the execution of callback functions.

  • When an event occurs, the associated callback is added to the Task Queue.

Example:

document.getElementById("myButton").addEventListener("click", () => {

     console.log("Button clicked!");

});

3. Promises and Microtasks:

  • Promises are a way to handle asynchronous operations in a more structured manner.

  • When a Promise is resolved or rejected, the associated then or catch callbacks are added to the Microtask Queue.

Example:

 Promise.resolve().then(() => {
     console.log("This will be executed as a microtask.");
 });

4. Asynchronous Functions:

  • Modern JavaScript also supports async/await syntax for working with asynchronous code.

  • async functions return a Promise, and when using await within them, the function is paused until the Promise is resolved, avoiding blocking the main thread.

async function fetchData() {
     const response = await fetch("https://api.example.com/data");
     const data = await response.json();
     console.log(data);
}

Inversion of control :

Inversion of control refers to handing control over parts of your code to a third-party function or library. This can lead to problems such as callback hell, where multiple nested callbacks make code difficult to read, maintain, and debug.

We can avoid callback hell using:

  • Promises: Provide a more structured way of handling asynchronous operations.

  • Async/Await: Syntactic sugar over promises for cleaner, more readable asynchronous code.

Promise:

Definition: A Promise is an object representing the eventual completion (or failure) of an asynchronous operation and its resulting value. It provides a way to handle asynchronous operations in JavaScript, making the code easier to write, read, and manage.

States of a Promise

A Promise can be in one of three states:

  1. Pending: The initial state, neither fulfilled nor rejected.

  2. Fulfilled: The operation is completed successfully, and the promise has a resulting value.

  3. Rejected: The operation failed, and the promise has a reason for the failure.

How to create Promise:

To create a Promise in JavaScript, you use the Promise constructor, which takes a single function as an argument. This function is called the executor, and it has two parameters: resolve and reject. These parameters are functions that you call to either resolve the promise or reject it, respectively.

Basic Syntax

const promiseValue = new Promise((resolve, reject) => {

  resolve('Successfully our promise resolved!');

  // If the operation fails
  // reject('Failure!');
});

Example

Let's create a simple promise that resolves after 2 seconds:

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('Promise resolved after 2 seconds');
  }, 2000);
});

// Using the promise
myPromise
  .then((message) => {
    console.log(message); // "Promise resolved after 2 seconds"
  })
  .catch((error) => {
    console.error(error);
  });

Key Methods

  1. then(onFulfilled, onRejected): Attaches callbacks for the fulfillment and rejection cases of the promise.

  2. catch(onRejected): Attaches a callback for only the rejection case.

  3. finally(onFinally): Attaches a callback that is invoked when the promise is settled (fulfilled or rejected), allowing for cleanup actions.

Example

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve('Operation successful!');
    } else {
      reject('Operation failed.');
    }
  }, 1000);
});

myPromise
  .then(result => {
    console.log(result); // "Operation successful!"
  })
  .catch(error => {
    console.error(error); // "Operation failed."
  })
  .finally(() => {
    console.log('Operation completed.');
  });

Callback Hell problem solved by Promises:

Promises address the issue of callback hell (nested callbacks), making the code more readable and maintainable.

  1. Callback Hell Example:

     asyncOperation1(function(result1) {
       asyncOperation2(result1, function(result2) {
         asyncOperation3(result2, function(result3) {
           console.log(result3);
         });
       });
     });
    

    Using Promises:

     asyncOperation1()
       .then(result1 => asyncOperation2(result1))
       .then(result2 => asyncOperation3(result2))
       .then(result3 => console.log(result3))
       .catch(error => console.error(error));
    

Some Functions of promise:

  • Promise.all: Executes multiple promises in parallel and waits for all of them to resolve.

      Promise.all([promise1, promise2, promise3])
        .then(results => {
          // resolved values
        })
        .catch(error => {
          //first rejected promise
        });
    
  • Promise.race: Resolves or rejects as soon as one of the promises in the array resolves or rejects.

      Promise.race([promise1, promise2, promise3])
        .then(result => {
          // first resolved promise
        })
        .catch(error => {
          // first rejected promise
        });
    
  • Promise.any() : Takes an iterable of Promise objects and returns a single Promise that resolves as soon as any of the promises in the iterable resolves. If none of the promises resolve (i.e., all of them reject), it rejects with an AggregateError.

      const promise1 = new Promise((resolve, reject) => setTimeout(reject, 100, 'Error 1'));
      const promise2 = new Promise((resolve) => setTimeout(resolve, 200, 'Result 2'));
      const promise3 = new Promise((resolve, reject) => setTimeout(reject, 300, 'Error 3'));
    
      Promise.any([promise1, promise2, promise3])
        .then(result => {
          console.log(result); // "Result 2"
        })
        .catch(error => {
          console.error(error); // Only if all promises reject, with an AggregateError
        });
    
  • Promise.allSettled(): Takes an iterable of Promise objects and returns a single Promise that resolves after all of the given promises have either resolved or rejected. The resulting array contains objects that each describe the outcome of each promise.

      const promise1 = new Promise((resolve) => setTimeout(resolve, 100, 'Result 1'));
      const promise2 = new Promise((resolve, reject) => setTimeout(reject, 200, 'Error 2'));
      const promise3 = new Promise((resolve) => setTimeout(resolve, 300, 'Result 3'));
    
      Promise.allSettled([promise1, promise2, promise3])
        .then(results => {
          results.forEach((result, index) => {
            if (result.status === 'fulfilled') {
              console.log(`Promise ${index + 1} fulfilled with ${result.value}`);
            } else {
              console.log(`Promise ${index + 1} rejected with ${result.reason}`);
            }
          });
        });
    

Handling Errors in Promises

Error handling in Promises can be done using .catch(), .then()'s second parameter.Using .catch():

    • The .catch() method handles any error that occurs in the Promise chain.

         someAsyncOperation()
           .then(result => {
             return anotherAsyncOperation(result);
           })
           .catch(error => {
             console.error('Error:', error);
           });
      
  1. Using .then()'s second parameter:

    • You can pass an error handler as the second argument to .then().

        someAsyncOperation()
          .then(result => {
          }, error => {
            console.error('Error:', error);
          });
      
    • Web Browser APIs

      Web Browser APIs (Application Programming Interfaces) are built-in functions and interfaces provided by web browsers that allow developers to access web browser environment.

    • Common Web Browser APIs

      1. DOM (Document Object Model) API:

        • Allows manipulation of HTML and CSS.

            const element = document.getElementById('myElement');
            element.textContent = 'Hello, World!';
          
      2. Fetch API:

        • Enables making network requests similar to XMLHttpRequest.

            fetch('https://github.com/yatikpatidar')
              .then(data => console.log(data))
              .catch(error => console.error('Error:', error));
          

Async-Await in JavaScript

async and await are syntactic features in JavaScript that make working with asynchronous operations more readable and easier to manage. They provide a way to write asynchronous code that looks and behaves like synchronous code.

Key Concepts

  1. async Function:

    • Declares an asynchronous function.

    • Automatically returns a Promise.

    • Can contain await expressions.

  2. await Expression:

    • Can only be used inside an async function.

    • Pauses the execution of the async function, waiting for the Promise to resolve or reject.

    • Resumes execution and returns the resolved value when the Promise resolves, or throws an error if the Promise is rejected.

Example

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function example() {
  console.log('Start');

  await delay(2000); // Waits for 2 seconds

  console.log('End');
}

example();
// Output:
// Start
// (waits for 2 seconds)
// End

async and await provide a powerful and cleaner way to handle asynchronous operations in JavaScript, making code more readable and easier to manage compared to traditional methods like callbacks and Promise chains.