Asynchronous Programming in JavaScript

In the world of JavaScript, understanding asynchronous programming is essential for building responsive and efficient applications. Let's dive deep into these concepts with clear explanations and practical examples.

How JavaScript Code is Executed

JavaScript is a single-threaded and synchronous programming language. It can perform one task at a time but can manage multiple tasks through asynchronous operations without blocking the main execution thread.

JavaScript can execute code in two ways:
- Synchronously
- Asynchronously

Now let's dive deep into synchronous and asynchronous execution inside JavaScript.

Synchronous Execution in JavaScript

JavaScript is synchronous, blocking, and single-threaded in nature. This means that the JavaScript engine executes our program sequentially, one line at a time from top to bottom in the exact order of the statements.

Example:

console.log("First line");
console.log("Second line");
console.log("Third line");

Output:
First line
Second line
Third line

From the above code, we can see that the code is getting executed sequentially line by line.

Asynchronous Execution in JavaScript

Unlike synchronous operations, an asynchronous operation does not block the next task from getting executed even if the current task isn’t completed yet. A lot of APIs are being provided to JavaScript by the runtime, which helps JavaScript to behave asynchronously. This asynchronous behavior in JavaScript is achieved through the use of callbacks and promises.

Example:

console.log("Start");
setTimeout(function exec() {
  console.log("Timer done");
}, 0);
console.log("End");

Output:
Start
End
Timer done

From the above example, we can see that the `End` is getting printed before `Timer done`.

JavaScript needs Runtime for its execution. Runtime provides a lot of functionalities to JavaScript. Before diving deep into understanding how JavaScript executes code asynchronously, let's first understand some terminologies that we will use further.

Execution Context

An execution context is an environment where JavaScript code runs and executes. When the JavaScript engine scans a script file, it makes an environment called the Execution Context.

There are two types of Execution Contexts:
- Global Execution Context
- Function Execution Context

An Execution Context has two phases:
- Memory creation
- Code Execution

Call Stack

The call stack is a part of the JavaScript engine that helps keep track of function calls. When a function gets invoked, it is pushed to the call stack where its execution begins, and when the execution is complete, the function gets popped off the call stack.

Event Loop

The Event Loop runs indefinitely and it connects the call stack, the microtask queue, and the callback queue. The Event loop keeps on checking whether the call stack is empty and the global piece of code is done. If the call stack gets empty and the global piece of code is also done, then the event loop moves asynchronous tasks from the microtask queue and the callback queue to the call stack for execution.

Callback Queue

Callback queue stores all the callback functions from setTimeout() before they are moved to the call stack for execution.

Microtask Queue

Microtask queue stores all the callback functions from Promises before they are moved to the call stack for execution. Now let's understand how the below code will get executed asynchronously by JavaScript.

Example:

console.log("Start");
setTimeout(function exec() {
  console.log("Timer done");
}, 0);
console.log("End");

Execution Order

  • First of all, there is `console.log("Start")`, so it will log `Start` in the console.

  • After this, there is the `setTimeout()` function, which will pass the callback inside the callback queue.

  • After this, there is `console.log("End")`, so it will log `End` in the console.

  • The global piece of code is done and the call stack is also empty.

  • So, now event loop pushes the callback function from the callback queue inside the call stack.

  • Now, the JavaScript Engine executes this callback function and logs `Timer done` in the console.

Inversion of Control

In simple terms, Inversion of Control (IoC) in JavaScript means giving control of how things happen in your code to an external system or framework, rather than your code directly controlling everything.

Example:

function foo(x, callback) {
  for (let i = 0; i < x; i++) {
    console.log(x);
  }

  callback(x * x);
}

foo(10, function executor(num) {
  console.log(num);
});

In the above example, let's suppose, foo function is written by someone else. foo function takes a callback function as an argument. We are passing executor function as a callback to foo function. Now, executor function is our implementation. So, we are passing the control of calling our callback function to another function foo. This problem is called as the Inversion of control in which the control of calling our callback function is given to some other function.

Callback Hell

Callback Hell in JavaScript refers to a situation where we have lots of nested callback functions inside each other, which makes code look like a tangled mess. It's like a deep, dark pit where the code gets lost and hard to follow.

Example:

const heading1 = document.querySelector(".heading1");
const heading2 = document.querySelector(".heading2");
const heading3 = document.querySelector(".heading3");
const heading4 = document.querySelector(".heading4");
const heading5 = document.querySelector(".heading5");


function changeText(element, text, color, time, onSuccessCallback, onFailureCallback) {
    setTimeout(()=>{
        if(element){
            element.textContent = text;
            element.style.color = color;
            if(onSuccessCallback){
                onSuccessCallback();
            }
        }else{
            if(onFailureCallback){
                onFailureCallback();
            }
        }
    }, time);
}


changeText(heading1, "one", "violet", 1000, ()=>{
    changeText(heading2, "two", "purple", 2000, ()=>{
        changeText(heading3, "three", "red", 1000, ()=>{
            changeText(heading4, "four", "green", 2000, ()=>{
                changeText(heading5, "five", "blue", 1000, ()=>{

                }, ()=>{console.log("Heading5 does not exist")});
            }, ()=>{console.log("Heading4 does not exist")});
        }, ()=>{console.log("Heading3 does not exist")});
    }, ()=>{console.log("Heading2 does not exist")});
}, ()=>{console.log("Heading1 does not exist")});

Promises

Promises in JavaScript are objects that represent the eventual completion or failure of an asynchronous operation. They provide a cleaner and more organized way to work with asynchronous code compared to traditional callback functions. A promise is an object that is used as a placeholder for the eventual results of a deferred (and possibly asynchronous) computation.

There are three states of a Promise object.

  1. Pending

  2. Fulfilled

  3. Rejected

In case of Promises, we are not passing the control of calling our callback function to some other function. In this case, we are having the control of calling our callback function with ourselves.

Creating a Promise

We create a new Promise object by calling the `new Promise()` constructor. This constructor takes a function as an argument, commonly referred to as the executor function. Inside this function, we perform our asynchronous operation, and we resolve the promise when it's successful or reject it if an error occurs.

Example:

function getRandomInt(max) {
  return Math.floor(Math.random() * max);
}

function createPromiseWithTimeout() {
  return new Promise(function executor(resolve, reject) {
    setTimeout(function () {
      let num = getRandomInt(10);
      if (num % 2 == 0) {
        // if the random number is even, we fulfill the Promise
        resolve(num);
      } else {
        // if the random number is odd, we reject the Promise
        reject(num);
      }
    }, 10000);
  });
}

let y = createPromiseWithTimeout();
console.log(y);

Web Browser APIs

(Application Programming Interfaces) are built-in libraries provided by web browsers that allow developers to interact with the browser and its environment.
These APIs enable web applications to perform various functions, such as manipulating the Document Object Model (DOM), handling user input, making network requests, and storing data locally.

Some key Web Browser APIs:

  1. DOM (Document Object Model) API

    • It manipulates and interacts with HTML and XML documents.

    • Example:

    •     document.getElementById('example').textContent = 'Hello, World!';
      
  2. Fetch API

    • It is used for making network requests to send or retrieve data.

    • Example:

    •     fetch('https://api.example.com/data')
            .then(response => response.json())
            .then(data => console.log(data))
            .catch(error => console.error('Error:', error));
      
  3. Local Storage API

    • It is used for storing data locally on the user's browser.

    • Example:

    •     localStorage.setItem('key', 'value');
          const value = localStorage.getItem('key');
          console.log(value);
      

Promises Functions

Promises in JavaScript provide a robust way to handle asynchronous operations. They come with several methods that allow you to work with them efficiently.
Here are the main functions associated with Promises:

  1. Promise.resolve()

    • It creates a Promise that is resolved with a given value.

    • Example:

    •     const resolvedPromise = Promise.resolve('Resolved value');
          resolvedPromise.then(value => console.log(value)); // Output: Resolved value
      
  2. Promise.reject()

    • It creates a Promise that is rejected with a given reason.

    • Example:

    •     const rejectedPromise = Promise.reject('Rejected reason');
          rejectedPromise.catch(reason => console.log(reason)); // Output: Rejected reason
      
  3. Promise.then()

    • It adds fulfillment and rejection handlers to the Promise and returns a new Promise resolving the return value of the handler.

    • Example:

    •     const promise = new Promise((resolve, reject) => {
            resolve('Success');
          });
      
          promise.then(value => {
            console.log(value); // Output: Success
          });
      
  4. Promise.catch()

    • It adds a rejection handler to the Promise and returns a new Promise resolving to the return value of the handler.

    • Example:

    •     const promise = new Promise((resolve, reject) => {
            reject('Error');
          });
      
          promise.catch(error => {
            console.log(error); // Output: Error
          });
      
  5. Promise.finally()

    • It adds a handler to be called when the Promise is settled (either fulfilled or rejected). The handler doesn't receive any arguments and returns a Promise.

    • Example:

    •     const promise = new Promise((resolve, reject) => {
            resolve('Success');
          });
      
          promise.finally(() => {
            console.log('Promise is settled');
          }).then(value => console.log(value)); // Output: Promise is settled followed by Success
      
  6. Promise.all()

    • It takes an iterable object of Promises as input and it returns a Promise that resolves when all of the Promises in the iterable object have resolved or rejects with the reason of the first promise that rejects.

    • Example:

    •     const promise1 = Promise.resolve(3);
          const promise2 = 42;
          const promise3 = new Promise((resolve, reject) => {
            setTimeout(resolve, 100, 'foo');
          });
      
          Promise.all([promise1, promise2, promise3]).then(values => {
            console.log(values); // Output: [3, 42, "foo"]
          });
      
  7. Promise.allSettled()

    • It takes an iterable of Promises and returns a Promise that resolves after all of the given Promises have either resolved or rejected, with an array of objects that each describe the outcome of each Promise.

    • Example:

    •     const promise1 = Promise.resolve(3);
          const promise2 = new Promise((resolve, reject) => setTimeout(reject, 100, 'error'));
      
          Promise.allSettled([promise1, promise2]).then(results => {
            results.forEach(result => console.log(result.status));
          });
          // Output: "fulfilled" followed by "rejected"
      
  8. Promise.race()

    • It takes an iterable of Promises and returns a Promise that resolves or rejects as soon as one of the Promises in the iterable resolves or rejects.

    • Example:

    •     const promise1 = new Promise((resolve, reject) => {
            setTimeout(resolve, 500, 'one');
          });
      
          const promise2 = new Promise((resolve, reject) => {
            setTimeout(resolve, 100, 'two');
          });
      
          Promise.race([promise1, promise2]).then(value => {
            console.log(value); // Output: "two"
          });
      

Error Handling in Promises

Handling errors in Promises is crucial for building robust and reliable JavaScript applications. Here are the main strategies to handle errors while using Promises, along with examples:

  1. Using .catch()

    • The .catch() method is used to handle any errors that occur in the Promise chain. It catches errors thrown during the execution of the Promise, including those thrown in .then() handlers.

    • Example:

    const promise = new Promise((resolve, reject) => {
      const success = false;
      if (success) {
        resolve('Operation succeeded');
      } else {
        reject('Operation failed');
      }
    });

    promise
      .then(result => {
        console.log(result);
      })
      .catch(error => {
        console.error('Error:', error);
      });
  1. Using a .catch() after multiple .then()

    • You can chain multiple .then() methods and use a single .catch() at the end to handle errors from any of the previous .then() methods.

    • Example:

    const promise = new Promise((resolve, reject) => {
      setTimeout(() => resolve('Step 1 complete'), 1000);
    });

    promise
      .then(result => {
        console.log(result);
        return 'Step 2 complete';
      })
      .then(result => {
        console.log(result);
        throw new Error('Something went wrong in Step 3');
      })
      .then(result => {
        console.log(result);
      })
      .catch(error => {
        console.error('Error:', error.message);
      });
  • In this example, the error thrown in the second .then() will be caught by the .catch() method at the end of the chain.
  1. Handling errors in Async/Await

    • When using async/await, you can handle errors using try/catch blocks. This makes the code more readable and allows handling errors similarly to synchronous code.

    • Example:

    async function fetchData() {
      try {
        let response = await fetch('https://api.example.com/data');
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        let data = await response.json();
        console.log(data);
      } catch (error) {
        console.error('Fetch error:', error);
      }
    }

    fetchData();
  • In this example, any error that occurs during the fetching or processing of the data will be caught by the catch block.

Async-Await

Using async and await in JavaScript allows you to write asynchronous code in a more synchronous and readable manner.

  • async: This keyword is used to declare an asynchronous function. An async function always returns a Promise.

  • await: This keyword is used to pause the execution of an async function until the Promise is resolved or rejected. It can only be used inside async functions.

Example:

async function fetchData() {
  try {
    let response = await fetch('https://api.example.com/data');
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    let data = await response.json();
    console.log(data);
  } catch (error) {
    console.error('Fetch error:', error);
  }
}

fetchData();

Handling Errors

Wrap your await statements in a try/catch block to handle errors gracefully.

Example:

async function fetchDataWithErrorHandling() {
  try {
    let response = await fetch('https://api.example.com/data');
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    let data = await response.json();
    console.log(data);
  } catch (error) {
    console.error('Fetch error:', error);
  }
}

fetchDataWithErrorHandling();

Async-Await vs Promises

Both Promises and async/await are used to handle asynchronous operations in JavaScript, but they offer different syntactic approaches and have some differences in usage and readability. Here's a comparison:

  1. Syntax and Readability

Promises:

  • Promises use a chaining method with .then(), .catch(), and .finally() for handling asynchronous operations.

  • This can lead to more verbose and less readable code, especially when chaining multiple asynchronous operations.

Example:

fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => {
    console.log(data);
  })
  .catch(error => {
    console.error('Error:', error);
  });

async/await:

  • async/await uses a more synchronous-looking syntax, making asynchronous code easier to read and write.

  • It involves using the await keyword to wait for a Promise to resolve, within an async function.

Example:

async function fetchData() {
  try {
    let response = await fetch('https://api.example.com/data');
    let data = await response.json();
    console.log(data);
  } catch (error) {
    console.error('Error:', error);
  }
}

fetchData();

2. Error Handling

Promises:

  • Errors are caught using the .catch() method.

  • Error handling can be added at the end of a chain to catch errors from any step in the chain.

Example:

fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => {
    console.log(data);
  })
  .catch(error => {
    console.error('Error:', error);
  });

async/await:

  • Errors are handled using try/catch blocks, similar to synchronous code.

  • This can lead to more straightforward and understandable error handling.

Example:

async function fetchData() {
  try {
    let response = await fetch('https://api.example.com/data');
    let data = await response.json();
    console.log(data);
  } catch (error) {
    console.error('Error:', error);
  }
}

fetchData();

Best Ways to Avoid Nested Promises

Nested promises can make your code hard to read and maintain. Here are some simple strategies to avoid nesting promises and keep your code clean and manageable.

1. Use async and await:

  • The async and await syntax in JavaScript makes working with promises more straightforward by allowing you to write asynchronous code that looks like synchronous code.

  • Example:

Instead of nesting Promises:

fetch('https://api.example.com/data1')
  .then(response1 => {
    return response1.json().then(data1 => {
      return fetch('https://api.example.com/data2').then(response2 => {
        return response2.json().then(data2 => {
          console.log(data1, data2);
        });
      });
    });
  })
  .catch(error => {
    console.error('Error:', error);
  });

Use async and await to flatten the structure:

async function fetchData() {
  try {
    let response1 = await fetch('https://api.example.com/data1');
    let data1 = await response1.json();
    let response2 = await fetch('https://api.example.com/data2');
    let data2 = await response2.json();
    console.log(data1, data2);
  } catch (error) {
    console.error('Error:', error);
  }
}

fetchData();

2. Use Promise.all() for Parallel Promises:

  • If you have multiple independent promises that can be executed in parallel, use Promise.all to run them concurrently and wait for all of them to resolve.

  • Example:

Instead of nesting:

fetch('https://api.example.com/data1')
  .then(response1 => response1.json())
  .then(data1 => {
    fetch('https://api.example.com/data2')
      .then(response2 => response2.json())
      .then(data2 => {
        console.log(data1, data2);
      });
  })
  .catch(error => {
    console.error('Error:', error);
  });

Use Promise.all:

async function fetchData() {
  try {
    let [response1, response2] = await Promise.all([
      fetch('https://api.example.com/data1'),
      fetch('https://api.example.com/data2')
    ]);

    let data1 = await response1.json();
    let data2 = await response2.json();
    console.log(data1, data2);
  } catch (error) {
    console.error('Error:', error);
  }
}

fetchData();

3. Chain Promises Properly

  • When you need to perform sequential asynchronous operations, ensure you chain promises properly without nesting.

  • Example:

Instead of:

fetch('https://api.example.com/data')
  .then(response => {
    return response.json().then(data => {
      return processData(data).then(result => {
        console.log(result);
      });
    });
  })
  .catch(error => {
    console.error('Error:', error);
  });

Chain them properly:

fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => processData(data))
  .then(result => {
    console.log(result);
  })
  .catch(error => {
    console.error('Error:', error);
  });

4. Modularize Your Code

  • Break down your code into smaller, reusable functions that return promises. This helps keep your main logic clean and readable.

  • Example:

Instead of having everything in one place:

fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => {
    return fetch('https://api.example.com/process', {
      method: 'POST',
      body: JSON.stringify(data)
    }).then(response => response.json());
  })
  .then(result => {
    console.log(result);
  })
  .catch(error => {
    console.error('Error:', error);
  });

Create reusable functions:

async function getData(url) {
  let response = await fetch(url);
  return response.json();
}

async function processData(url, data) {
  let response = await fetch(url, {
    method: 'POST',
    body: JSON.stringify(data)
  });
  return response.json();
}

async function main() {
  try {
    let data = await getData('https://api.example.com/data');
    let result = await processData('https://api.example.com/process', data);
    console.log(result);
  } catch (error) {
    console.error('Error:', error);
  }
}

main();

Conclusion

Learning asynchronous programming in JavaScript can be challenging, but understanding the tools at your disposal—such as promises and async/await syntax can significantly simplify the task. By leveraging techniques to avoid nested promises, such as proper chaining, using Promise.all, and modularizing your code, you can write cleaner, more maintainable, and more readable asynchronous code.


Happy Coding!!