Exploring the World of Asynchronous JavaScript.

Shivani GuptaShivani Gupta
21 min read

Execution of JavaScript Code:

  1. Parsing: The JavaScript engine parses the code, breaking it down into tokens and constructing an Abstract Syntax Tree (AST).

  2. Compilation and Execution: After parsing, the code is compiled into executable instructions, which are then executed line by line.

  3. Execution Context: Each function call creates an execution context, including scope chain, variable environment, and this value.

  4. Call Stack: JavaScript is single-threaded, managing function calls through a call stack. Functions are pushed onto the stack when called and popped off when completed.

  5. Event Loop and Event Queue: Asynchronous operations are handled through the event loop and event queue.

    • Event Loop: Continuously checks the call stack and event queue. If the stack is empty, it dequeues tasks from the event queue and pushes them onto the stack.

    • Event Queue: Holds asynchronous tasks until they're ready for execution. Tasks are processed by the event loop when the call stack is empty.

        1.    function add(a, b) {
        2.        return a+b;
        3.    }
        4.    
        5.    function multiplyBy3(a) {
        6.        return a*3;
        7.    }
        8.    
        9.    function operation(a,b) {
        10.        const sum = add(a,b);
        11.        const prod = multiplyBy3(sum);
        12.        return prod;
        13.    }
        14.    
        15.    const result = operation(2,3);
      

Synchronous vs. Asynchronous JavaScript:

  1. Synchronous Execution: In synchronous JavaScript, code is executed sequentially, one statement at a time. Each statement must complete before the next one can begin, leading to a blocking behavior where subsequent code execution halts until the current task finishes.

     console.log('Start');
    
     function syncTask() {
       // Simulate a long-running task
       for (let i = 0; i < 1e9; i++) {}
       console.log('Synchronous Task Complete');
     }
    
     syncTask();
     console.log('End');
    

    Output:

     Start
     Synchronous Task Complete
     End
    
  2. Asynchronous Execution: On the other hand, asynchronous JavaScript allows tasks to be executed independently, without waiting for previous operations to finish. Asynchronous operations are initiated and continue in the background, while the main thread remains free to execute other tasks.

     console.log('Start');
    
     function asyncTask(callback) {
       setTimeout(() => {
         console.log('Asynchronous Task Complete');
         callback();
       }, 2000);
     }
    
     asyncTask(() => {
       console.log('End');
     });
    

    Output:

     Start
     Asynchronous Task Complete
     End
    

The Callback Queue:

In JavaScript, there are two types of task queues: the callback queue and the microtask queue. Both queues hold tasks that are executed asynchronously, but there are some differences in how they work.

No alt text provided for this image

The Callback Queue (Macrostack Queue), also known as the task queue, holds tasks that are pushed to the queue by Web APIs, such as setTimeout, setInterval, XMLHttpRequest, or events like mouse clicks and keyboard inputs. When the call stack is empty, the event loop moves the first task from the callback queue to the call stack for execution.

For example, consider the following code:

console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');

In this code, the console.log('1') statement is executed first, followed by the setTimeout function. The setTimeout function schedules the anonymous function passed to it to be executed after 0 milliseconds, which means that it will be added to the callback queue after the current call stack is empty. The console.log('3') statement is executed next.

When the current call stack is empty, the event loop moves the anonymous function from the callback queue to the call stack for execution. Therefore, the output of the above code will be:

1
3
2

On the other hand, the Microtask Queue, also known as the Job queue or Promise queue, holds tasks that are pushed to the queue by microtasks, such as Promise.resolve, Promise.reject, or queueMicrotask. When the call stack is empty and there are no pending tasks in the callback queue, the event loop moves the first task from the microtask queue to the call stack for execution.

For example, consider the following code:

console.log('1);
Promise.resolve().then(() => console.log('2'));
console.log('3');

In this code, the console.log('1') statement is executed first, followed by the Promise.resolve() function. The .then method schedules the anonymous function passed to it to be executed after the current call stack is empty, which means that it will be added to the microtask queue. The console.log('3') statement is executed next.

When the current call stack is empty and there are no pending tasks in the callback queue, the event loop moves the anonymous function from the microtask queue to the call stack for execution. Therefore, the output of the above code will be:

1
3
2

So, the main difference between the Callback Queue and the Microtask Queue is the order in which they are processed by the event loop. The Callback Queue holds tasks that are executed after the current call stack is complete, while the Microtask Queue holds tasks that are executed before the next task is executed.

What is the Event Loop?

The event loop is a mechanism that enables JavaScript to handle asynchronous events. It is the core of JavaScript's non-blocking I/O model, which means that JavaScript can handle multiple operations at the same time without blocking the execution of other operations.

The event loop works by constantly checking the message queue for pending events. When an event occurs, it is added to the message queue, and the event loop executes the corresponding event handler. Once the event handler is executed, the event is removed from the message queue, and the event loop continues to check for pending events.

The message queue is a simple data structure that stores a list of messages or events waiting to be processed. Messages in the queue are processed in the order in which they were added to the queue.

How Does the Event Loop Work?

The event loop has two essential components: the call stack and the message queue. The call stack is a data structure that stores the currently executing function. When a function is called, it is added to the call stack, and when it returns, it is removed from the call stack.

The message queue is a data structure that stores messages or events that are waiting to be processed. The event loop continuously checks the message queue for pending events. When an event is found, the event loop adds it to the call stack, and the corresponding event handler is executed. Once the event handler returns, the event is removed from the message queue, and the event loop continues to check for pending events.

This process of adding events to the call stack and executing event handlers is repeated until there are no more events in the message queue.

Understanding the Event Loop in JavaScript

Inversion of Control (IoC):

Inversion of Control means that instead of your code controlling how objects are created and managed, a framework or container takes over this responsibility. This "inversion" of control makes it easier to change parts of your code without affecting others.

Example:

Consider a scenario where a class needs to fetch data from a database. In traditional procedural programming, the class would directly call the database to retrieve the data. However, with IoC, the class would delegate this responsibility to a framework or container, which manages the database access and provides the data to the class.

Problem with Inversion of Control:

While IoC can lead to more modular and maintainable code, it can also introduce complexity and make code harder to understand and debug. Developers may struggle to trace the flow of control, leading to challenges in troubleshooting and maintenance.

Example:

In a large application with numerous dependencies managed by an IoC container, understanding the flow of control and diagnosing issues can become challenging. Additionally, changes made to the IoC configuration or dependencies may have unintended consequences throughout the application, leading to unexpected behavior.

Callback Hell

Callback hell, also known as the pyramid of doom, occurs when multiple asynchronous operations are nested within each other, resulting in deeply nested and hard-to-read code. One way to avoid callback hell is by using techniques like modularization, named functions, and control flow libraries like Promises or async/await. Let's see an example of how to refactor code with callback hell into more manageable code using Promises:

// Nested callbacks (callback hell)
function fetchData(callback) {
  setTimeout(() => {
    callback('Data fetched successfully');
  }, 1000);
}

function processData(data, callback) {
  setTimeout(() => {
    callback(`Processed data: ${data}`);
  }, 1000);
}

function displayData(processedData, callback) {
  setTimeout(() => {
    callback(`Displaying data: ${processedData}`);
  }, 1000);
}

fetchData((data) => {
  processData(data, (processedData) => {
    displayData(processedData, (displayedData) => {
      console.log(displayedData);
    });
  });
});

Refactored using Promises:

function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('Data fetched successfully');
    }, 1000);
  });
}

function processData(data) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(`Processed data: ${data}`);
    }, 1000);
  });
}

function displayData(processedData) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(`Displaying data: ${processedData}`);
    }, 1000);
  });
}

fetchData()
  .then(processData)
  .then(displayData)
  .then((displayedData) => {
    console.log(displayedData);
  })
  .catch((error) => {
    console.error('Error:', error);
  });

In the refactored code:

  • Each asynchronous operation is wrapped in a Promise.

  • Instead of nested callbacks, we chain promises using .then().

  • The final .catch() block catches any errors that occur during the Promise chain.

This approach makes the code more readable, maintainable, and easier to reason about compared to the nested callback version.

Promises

A Promise is an object representing the eventual completion or failure of an asynchronous operation. It allows you to handle asynchronous operations in a more elegant and manageable way, especially when dealing with multiple asynchronous tasks or avoiding callback hell.

Example:

Consider a scenario where you need to fetch data from an API asynchronously. Using traditional callback-based asynchronous code, it might look like this:

function fetchData(callback) {
  setTimeout(() => {
    const data = { id: 1, name: 'John Doe' };
    callback(data);
  }, 1000);
}

fetchData((data) => {
  console.log('Data fetched:', data);
});

While this works, it can lead to callback hell when dealing with multiple asynchronous operations. Here's how you can refactor it using Promises:

function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      const data = { id: 1, name: 'John Doe' };
      resolve(data);
    }, 1000);
  });
}

fetchData()
  .then((data) => {
    console.log('Data fetched:', data);
  })
  .catch((error) => {
    console.error('Error fetching data:', error);
  });

In this refactored code:

  • The fetchData function returns a Promise object.

  • Inside the Promise constructor, the asynchronous operation is performed, and resolve is called when the operation completes successfully.

  • The .then() method is used to handle the resolved value (the fetched data), and the .catch() method is used to handle any errors that occur during the asynchronous operation.

Promises provide a cleaner and more readable way to handle asynchronous operations compared to traditional callback-based code. They also help in avoiding callback hell by allowing you to chain multiple asynchronous operations in a more structured manner.

Lightbox

How Promises are created

To create a Promise in JavaScript, you use the Promise constructor, which takes a single function argument. This function, often called the executor function, has two parameters: resolve and reject. Inside the executor function, you perform the asynchronous operation, and then call resolve when the operation succeeds, or reject if it fails.

Here's the basic syntax for creating a Promise:

const myPromise = new Promise((resolve, reject) => {
  // Perform asynchronous operation
  // If operation succeeds, call resolve with the result
  // If operation fails, call reject with an error
});

Here's a simple example of creating a Promise that resolves after a delay:

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('Operation completed successfully');
  }, 1000);
});

In this example:

  • The Promise is created using the new Promise() constructor.

  • Inside the executor function, setTimeout is used to simulate an asynchronous operation.

  • After 1000 milliseconds, resolve is called with the message 'Operation completed successfully', indicating that the operation succeeded.

Once the Promise is created, you can use methods like .then() and .catch() to handle the resolved value or any errors that occur during the asynchronous operation.

Creating Promises allows you to encapsulate asynchronous operations in a more structured and manageable way, making your code easier to read and maintain.

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.

Here are some key Web Browser APIs:

1. DOM (Document Object Model) API

  • Purpose: Manipulate and interact with HTML and XML documents.

  • Example:

      document.getElementById('example').textContent = 'Hello, World!';
    

2. Fetch API

  • Purpose: Make network requests to retrieve or send data.

  • Example:

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

3. Geolocation API

  • Purpose: Retrieve geographical location information of the user's device.

  • Example:

      navigator.geolocation.getCurrentPosition((position) => {
        console.log('Latitude:', position.coords.latitude);
        console.log('Longitude:', position.coords.longitude);
      });
    

4. Local Storage API

  • Purpose: Store data locally on the user's browser.

  • Example:

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

Promises function:

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()

Purpose: 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()

    • Purpose: 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()

  • Purpose: Adds fulfillment and rejection handlers to the Promise, and returns a new Promise resolving to 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()

  • Purpose: 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()

  • Purpose: 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()

  • Purpose: Takes an iterable of Promises and returns a Promise that resolves when all of the Promises in the iterable 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()

  • Purpose: 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()

  • Purpose: 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"
      });
    

9. Promise.any()

  • Purpose: Takes an iterable of Promises and returns a Promise that resolves as soon as one of the Promises in the iterable resolves. If all Promises reject, it rejects with an AggregateError.

  • Example:

      const promise1 = Promise.reject('Error 1');
      const promise2 = Promise.resolve('Success 2');
      const promise3 = Promise.resolve('Success 3');
    
      Promise.any([promise1, promise2, promise3]).then(value => {
        console.log(value); // Output: "Success 2"
      }).catch(error => {
        console.error('All promises were rejected', error);
      });
    

These methods make working with asynchronous code in JavaScript more manageable and help avoid common pitfalls like callback hell, improving code readability and maintainability.

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);
  });

In this example, if the success variable is false, the Promise will be rejected and the error will be caught by the .catch() method.

2. 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.

3. Using .finally()

The .finally() method allows you to run cleanup or final steps regardless of whether the Promise was fulfilled or rejected. It doesn't receive any arguments and cannot modify the final value or reason.

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);
  })
  .finally(() => {
    console.log('Cleanup or final steps');
  });

In this example, finally will run regardless of whether the Promise was resolved or rejected.

4. 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. Here’s a step-by-step guide on how to use async and await along with examples.

1. Basic Syntax

  • 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.

2. Creating an Async Function

To create an asynchronous function, prefix the function declaration with the async keyword.

async function fetchData() {
  // Function body
}

3. Usingawaitto Handle Promises

Use the await keyword to wait for a Promise to resolve. When the Promise resolves, await returns the resolved value. If the Promise is rejected, await throws an error.

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();

4. MultipleawaitStatements

You can use multiple await statements within the same async function to handle multiple asynchronous operations.

Example:

async function fetchMultipleData() {
  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('Data 1:', data1);
    console.log('Data 2:', data2);
  } catch (error) {
    console.error('Fetch error:', error);
  }
}

fetchMultipleData();

5. Parallel Execution withPromise.all()

When you have multiple independent asynchronous operations, you can execute them in parallel using Promise.all().

Example:

async function fetchInParallel() {
  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('Data 1:', data1);
    console.log('Data 2:', data2);
  } catch (error) {
    console.error('Fetch error:', error);
  }
}

fetchInParallel();

6. 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();

7. Returning Values from Async Functions

An async function returns a Promise, so you can use .then() to handle the resolved value outside the function.

Example:

async function fetchData() {
  let response = await fetch('https://api.example.com/data');
  let data = await response.json();
  return data;
}

fetchData().then(data => {
  console.log(data);
}).catch(error => {
  console.error('Fetch error:', error);
});
  • Return values from async functions and handle them using .then() and .catch().

Using async and await makes it easier to work with asynchronous code, improving readability and maintainability.

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();

3. Control Flow

Promises:

  • Promises provide methods like Promise.all(), Promise.race(), Promise.allSettled(), and Promise.any() to handle multiple asynchronous operations.

  • These methods enable parallel execution and handling of multiple Promises.

Example:

Promise.all([
  fetch('https://api.example.com/data1'),
  fetch('https://api.example.com/data2')
])
  .then(responses => Promise.all(responses.map(response => response.json())))
  .then(data => {
    console.log('Data1:', data[0]);
    console.log('Data2:', data[1]);
  })
  .catch(error => {
    console.error('Error:', error);
  });

async/await:

  • async/await can also use Promise.all() for parallel execution, but the syntax remains cleaner and more linear.

Example:

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:', data1);
    console.log('Data2:', data2);
  } catch (error) {
    console.error('Error:', error);
  }
}

fetchData();

4. Return Value

Promises:

  • A Promise is an object that represents the eventual completion or failure of an asynchronous operation.

  • Methods like .then() and .catch() are used to handle the resolved value or error.

async/await:

  • An async function always returns a Promise.

  • Inside an async function, the await keyword is used to pause execution until a Promise is resolved.

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. Useasyncandawait

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. UsePromise.allfor 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

Navigating the world of asynchronous programming in JavaScript can be challenging, but understanding the tools at your disposal—such as promises and the 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!

10
Subscribe to my newsletter

Read articles from Shivani Gupta directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Shivani Gupta
Shivani Gupta