Understanding Asynchronous JavaScript: From Callbacks to promises and async/await

Understanding Asynchronous JavaScript: From Callbacks to promises and async/await

Introduction

JavaScript is inherently synchronous and single-threaded, executing code line by line. However, when dealing with time-consuming tasks like fetching data or making requests, this synchronous nature can lead to delays. Asynchronous programming comes to the rescue, allowing your program to handle tasks in the background while staying responsive. In this blog, we'll explore different ways of handling asynchronous operations in JavaScript, starting with callbacks and progressing to more modern approaches like Promises and async/await.

Synchronous vs. Asynchronous Programming

Consider the following synchronous code:

 //synchronous programming
  console.log('one');
  console.log('two');
  console.log('three');

Here, the statements execute one after another. The output will be:

one
two
three

The code above is quite simple and will run in no time. JavaScript, by default, would wait and halt all executions until the fetch request completes. In such cases, asynchronous programming proves beneficial for enhancing performance and responsiveness.

Asynchronous programming in JavaScript allows your program to start time-consuming tasks without waiting for them to finish. It ensures responsiveness to other events by letting the program continue running while the task is completed in the background. This is crucial for actions like fetching data from a server, and enhancing user experience.

Techniques like callbacks, Promises, and async/await syntax make it possible to manage asynchronous operations effectively without blocking the main thread.

Consider the following asynchronous code:

//Asynchronous programming
  console.log('one');
  setTimeout(() => {
    console.log('two');
  }, 2000);
  console.log('three');

In this example, the console.log('one') statement is executed first, followed by the asynchronous operation setTimeout, which delays the execution of console.log('two') for 2 seconds. Meanwhile, the program proceeds to the next statement console.log('three'). When the delay expires, the asynchronous task prints 'two'. The output illustrates the non-blocking nature of asynchronous programming in JavaScript.

//Output
one
three
two

Callbacks

Callbacks are functions passed as arguments to other functions and executed later, often after an asynchronous operation completes.

For simple use cases, callbacks are straightforward and easy to understand.

Simple Callback Example

    setTimeout(() => {
      console.log('one');
    }, 2000);

The above example is a simple case of callback function, where setTimeout function contains a callback function (an anonymous arrow function) that logs the string 'one' to the console after a delay of 2000 milliseconds.

Callbacks are simple but can lead to "Callback Hell" in complex scenarios, making the code hard to read and maintain.

Callback Hell

function fetchData(data, callback) {
  setTimeout(() => {
    console.log('Data:', data);
    callback(null, data);
  }, 2000);
}

// Callback hell example with deeply nested callbacks
fetchData(1, (error, result1) => {
  if (error) return console.error(error);
  console.log('Operation 1 completed with result:', result1);
  fetchData(2, (error, result2) => {
    if (error) return console.error(error);
    console.log('Operation 2 completed with result:', result2);
    fetchData(3, (error, result3) => {
      if (error) return console.error(error);
      console.log('Operation 3 completed with result:', result3);
      fetchData(4, (error, result4) => {
        if (error) return console.error(error);
        console.log('Operation 4 completed with result:', result4);
        // More code if needed
      });
    });
  });
});

The nesting of callbacks can become hard to manage in complex scenarios.

Promises: Escaping Callback Hell

Promises are a JavaScript feature introduced to handle asynchronous operations more effectively. They provide a cleaner and more organized way to work with asynchronous code compared to traditional callbacks. A Promise can be in one of three states: pending, fulfilled, or rejected.

Here's a basic example to help you understand how Promises work:

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve('Data fetched successfully');
    } else {
      reject('Error fetching data');
    }
  }, 1000);
}); 

promise.then((result) => {
  console.log(result); // Output: Success!
}).catch((error) => {
  console.log(error);
});

In this example:

  • We created a Promise using the Promise constructor, which takes a function with two parameters: resolve and reject.

  • Inside the function, we simulate an asynchronous operation using setTimeout. If the operation is successful, we call resolve with the result; otherwise, we call reject with an error message.

  • We consume the Promise using the .then() callback, which is executed when the Promise is fulfilled, and the .catch() callback is executed when the Promise is rejected.

Promise Chaining

Additionally, Promises allow chaining, making it easier to handle multiple asynchronous operations sequentially. Here's an example with chaining, where each .then() block receives the result from the previous one, allowing for a more sequential and readable flow of asynchronous operations. If an error occurs at any point, it will be caught by the catch block.

First, checkout the example of Callback Hell, this is the improved version using Sequential Promise Chaining.

function fetchData(data) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('Data:', data);
      resolve(data);
    }, 2000);
  });
}

// Using Promises to avoid callback hell
fetchData(1)
  .then((result1) => {
    console.log('Operation 1 completed with result:', result1);
    return fetchData(2);
  })
  .then((result2) => {
    console.log('Operation 2 completed with result:', result2);
    return fetchData(3);
  })
  .then((result3) => {
    console.log('Operation 3 completed with result:', result3);
    return fetchData(4);
  })
  .then((result4) => {
    console.log('Operation 4 completed with result:', result4);
    // More code if needed
  })
  .catch((error) => console.error(error));

This example demonstrates how chaining promises simplifies complex asynchronous flows.

Using Promises with Fetch API

Promises can be especially beneficial when working with asynchronous operations like fetching data from a server.

This code uses the FetchAPI, which returns a Promise, to retrieve data from a web server.

const fetchDataPromise = fetch("https://api.example.com/data");

fetchDataPromise
  .then((response) => response.json())
  .then((data) => {
    console.log(data);
  })
  .catch((error) => {
    console.error(`Failed to fetch data: ${error}`);
  });

Async/Await

Async/await is a more recent addition to JavaScript, providing syntactic sugar on top of Promises for improved readability. The async/await syntax makes the code look more synchronous, enhancing readability and simplifying error handling. The async keyword is used to define an asynchronous function that returns a Promise, and the await keyword is used to wait for the Promise to resolve. The try/catch block is used for error handling.

Note: await keyword works only inside async functions.

To use async/await, you need to declare a function as async and then use the await keyword in front of any asynchronous operation. The try/catch block facilitates error handling within async functions.

function fetchData(data) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('Data:', data);
      resolve(data);
    }, 2000);
  });
}

async function fetchDataAsync() {
  try {
    const result = await fetchData(1);
    console.log('Operation completed with result:', result);
  } catch (error) {
    console.error(error);
  }
}

fetchDataAsync();

Inside fetchDataAsync function, the await keyword is used to pause the execution of the function until the promise returned by fetchData function is resolved.

Async/Await for multiple asynchronous operations

Using async/await for sequential execution simplifies the handling of multiple asynchronous operations. In this example, the fetchDataAsync function employs async/await, providing a more linear and readable flow compared to callback-based or promise-chained approaches (check the same example using callbacks and promises above). Each asynchronous operation is awaited before proceeding to the next, and errors are caught and handled in the try/catch block. This pattern enhances code clarity and maintainability, especially in scenarios involving complex asynchronous workflows.

function fetchData(data) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('Data:', data);
      resolve(data);
    }, 2000);
  });
}

async function fetchDataAsync() {
  try {
    let result1 = await fetchData(1);
    console.log('Operation 1 completed with result:', result1);
    let result2 = await fetchData(2);
    console.log('Operation 2 completed with result:', result2);
    let result3 = await fetchData(3);
    console.log('Operation 3 completed with result:', result3);
    let result4 = await fetchData(4);
    console.log('Operation 4 completed with result:', result4);
    // More code if needed
  } catch (error) {
    console.error(error);
  }
}

fetchDataAsync();

Async/Await with Fetch API

async function fetchDataAsyncAwait() {
  try {
    const response = await fetch("https://api.example.com/data");
    const data = await response.json();
   console.log(data);
  } catch (error) {
    console.error(`Failed to fetch data: ${error}`);
  }
}

fetchDataAsyncAwait();
  1. First await keyword is used to pause the execution until the fetch operation is complete. The fetch function is used to make an HTTP request to the specified URL, and it returns a Promise that resolves to the Response object.

  2. After obtaining the response, await is used again to pause the execution until the JSON parsing of the response is complete.

Recommendations and Best Practices

While callbacks may be appropriate for simple scenarios, they can lead to callback hell in complex flows. Promises and async/await are generally preferred for their improved readability, error-handling capabilities, and ease of composing asynchronous operations.

Conclusion

Asynchronous programming is crucial for building responsive applications. By understanding callbacks, Promises, and async/await, even beginners can choose the most suitable approach for handling asynchronous operations. Whether it's chaining Promises for structured code or leveraging the simplicity of async/await, these techniques enhance the readability and maintainability of your JavaScript code.

Thank you for reading! Happy coding!