Understand the Asynchronous JavaScript: Callbacks, Promises, and Async/Await

javascript, js, logo

What is Synchronous Programming?

Synchronous programming is a programming paradigm where operations or tasks are executed sequentially, one after the other. In this model, a task must complete its execution before the next task can start. The flow of control follows a linear path, and the program execution is blocked until the current task is finished.

Synchronous programming is straightforward and easy to understand since the code executes in the order it is written. However, it can lead to performance issues in cases where a task takes a long time to complete, such as waiting for a server response or reading a large file. While the program is waiting for the task to complete, it is blocked and unable to perform any other tasks.

How to Create a Sync Programming in JavaScript

Here is a simple example of synchronous programming in JavaScript:

function task1() {
  console.log('Task 1');
}

function task2() {
  console.log('Task 2');
}

function task3() {
  console.log('Task 3');
}

task1();
task2();
task3();

In this example, the functions task1, task2, and task3 are executed in the order they are called. The output will be:

Task 1
Task 2
Task 3

Although synchronous programming can be suitable for simple tasks and small programs, it may not be efficient for more complex applications where tasks can be time-consuming or depend on external resources. In such cases, asynchronous programming can be used to improve performance and responsiveness.

What is Asynchronous Programming?

Asynchronous programming is a programming paradigm that allows multiple tasks to be executed concurrently without blocking the flow of program execution. In this model, tasks can start, run, and complete in overlapping time periods, enabling the program to continue processing other tasks while waiting for a long-running task to complete.

Asynchronous programming is particularly useful in situations where a task may take a long time to complete, such as waiting for a server response, reading a large file, or performing a complex computation. By allowing the program to continue executing other tasks while waiting for these time-consuming operations, asynchronous programming can lead to more efficient and responsive applications.

In JavaScript, asynchronous programming is commonly achieved through the use of callbacks, Promises, and the async/await syntax. These techniques allow the program to perform tasks concurrently without blocking the main thread, which is responsible for managing the user interface and other tasks.

How to Create an Async Programming in JavaScript

Here’s a simple example of asynchronous programming in JavaScript using the setTimeout function:

function task1() {
  console.log('Task 1');
}

function task2() {
  console.log('Task 2');
}

function task3() {
  console.log('Task 3');
}

task1();
setTimeout(task2, 1000); // Execute task2 after a 1000ms delay
task3();

In this example, task1 and task3 are executed synchronously, while task2 is executed asynchronously after a delay of 1000 milliseconds. The output will be:

Task 1
Task 3
Task 2

Notice that task3 is executed before task2, even though task2 is called before task3 in the code. This is because task2 is scheduled for execution after a delay, allowing the program to continue executing other tasks without waiting for task2 to complete.

How Callbacks Work in JavaScript

Callback functions are functions that are passed as arguments to another function and are executed at a later time. They are particularly useful in asynchronous programming, as they allow us to specify what should happen when an asynchronous operation is completed.

Callbacks are a way to manage the flow of your program when dealing with asynchronous events. By passing a function as an argument, you can ensure that the function is only called when the asynchronous operation is finished.

Create an Example with a Callback Function

Let’s create a simple example to demonstrate how callback functions work. We will create a getUser function that takes a user ID and a callback function as arguments. The getUser function will simulate an asynchronous operation using setTimeout and execute the callback function with the user data after a delay.

// Define the getUser function, which takes a userId and a callback function as arguments
function getUser(userId, callback) {
  // Simulate an asynchronous operation using setTimeout
  setTimeout(() => {
    // Create a mock user object with the provided userId
    const user = {
      id: userId,
      name: 'John Doe',
    };

    // Call the callback function with the user object after a delay
    callback(user);
  }, 1000);
}

// Call the getUser function with a user ID and a callback function to handle the user data
getUser(1, user => {
  // This function will be executed after the getUser function completes its asynchronous operation
  console.log(`User ID: ${user.id}, User Name: ${user.name}`);
});

In this example, we use the setTimeout function to simulate an asynchronous operation. The getUser function takes a userId and a callback function as arguments. After a 1-second delay, the callback function is executed with the user data.

When we call the getUser function, we pass it a user ID and a callback function that will handle the user data. In this case, the callback function simply logs the user’s ID and name to the console.

The key takeaway from this example is that the callback function allows us to manage the flow of our program when dealing with asynchronous operations, such as fetching data from an API or reading data from a file.

Callbacks – Revisiting setTimeout

We will revisit the setTimeout function and show how it uses callback functions to execute code after a specified delay.

How setTimeout Uses Callback Functions

The setTimeout function is a built-in JavaScript function that allows you to execute a function after a specified delay. It takes two arguments:

  1. A callback function that will be executed after the delay

  2. The delay time in milliseconds

Here’s a simple example of using setTimeout with a callback function:

// Define a callback function to be executed after a delay
function delayedFunction() {
  console.log('This message is displayed after a 2-second delay');
}

// Use setTimeout to execute the delayedFunction after 2000 milliseconds (2 seconds)
setTimeout(delayedFunction, 2000);

In this example, we define a delayedFunction that simply logs a message to the console. We then use setTimeout to execute this function after a 2-second delay.

The key takeaway here is that setTimeout uses a callback function to allow you to specify what code should be executed after the delay. This is a simple but powerful concept that enables you to control the flow of your program when dealing with asynchronous operations.

You can also use anonymous functions as callback functions with setTimeout. Here’s an example of using an anonymous function with setTimeout:

// Use setTimeout to execute an anonymous function after 3000 milliseconds (3 seconds)
setTimeout(function() {
  console.log('This message is displayed after a 3-second delay');
}, 3000);

In this case, we provide an anonymous function as the callback for setTimeout. This function is executed after a 3-second delay, and it logs a message to the console.

Callbacks – Revisiting array.filter

Now we will revisit the array.filter method and explain how it uses callback functions to filter array elements based on a specified condition.

How array.filter Uses Callback Functions

The filter method is an array method in JavaScript that allows you to create a new array with elements that pass a specified test (a callback function). The callback function takes an array element as an argument and returns a boolean value. If the callback function returns true, the element is included in the new array; otherwise, it is excluded.

Here’s an example of using array.filter with a callback function:

const numbers = [1, 2, 3, 4, 5];

// Define a function that will be used as callback function to returns true if the number is even
function isEven(number) {
  return number % 2 === 0;
}

// Use array.filter to create a new array with only the even numbers
const evenNumbers = numbers.filter(isEven);

console.log(evenNumbers); // Output: [2, 4]

Make your Own filterArray Function

Now let’s create a custom filterArray function that uses callback functions to filter elements in an array.

// Define a custom filterArray function that takes an array and a callback function as arguments
function filterArray(array, callback) {
  const newArray = [];

  for (const element of array) {
    if (callback(element)) {
      newArray.push(element);
    }
  }

  return newArray;
}

Put the Custom filterArray Function to Use

Now that we have our custom filterArray function, let’s apply it to an array to demonstrate its functionality.

const numbers = [1, 2, 3, 4, 5];

// Define a callback function that returns true if the number is greater than 3
function greaterThanThree(number) {
  return number > 3;
}

// Use our custom filterArray function to create a new array with numbers greater than 3
const numbersGreaterThanThree = filterArray(numbers, greaterThanThree);

console.log(numbersGreaterThanThree); // Output: [4, 5]

See, we use our custom filterArray function to create a new array with numbers greater than 3. We define a greaterThanThree callback function that takes a number and returns true if the number is greater than 3. We then pass this callback function to our filterArray function, along with the original numbers array.

Experiment: What if fetch Used Callbacks?

We will discuss the challenges of using callbacks with the fetch function. We’ll consider an alternative scenario where fetch uses callback functions instead of Promises, which are used by the actual fetch implementation.

The Cddddhallenges of Using Callbacks with fetch

The fetch function is a modern, built-in JavaScript function for making HTTP requests. It returns a Promise that resolves to the Response object representing the response to the request.

Let’s imagine a scenario where fetch uses callback functions instead of Promises:

function fetchWithCallback(url, successCallback, errorCallback) {
  // Simulate the fetch functionality using callbacks
  // This example is for illustration purposes only
}

fetchWithCallback(
  'https://api.example.com/data',
  function (data) {
    console.log('Success:', data);
  },
  function (error) {
    console.error('Error:', error);
  }
);

In this example, we create a hypothetical fetchWithCallback function that takes a URL and two callback functions: a successCallback that is called when the request succeeds, and an errorCallback that is called when the request fails.

The main challenge with using callbacks in this scenario is handling complex asynchronous flows, such as making multiple requests in a sequence or handling errors. Callbacks can lead to a situation known as “callback hell,” where nested callbacks become hard to read, understand, and maintain.

For example, imagine that you need to make three sequential requests using the fetchWithCallback function:

fetchWithCallback(
  'https://api.example.com/data1',
  function (data1) {
    // Process data1
    fetchWithCallback(
      'https://api.example.com/data2',
      function (data2) {
        // Process data2
        fetchWithCallback(
          'https://api.example.com/data3',
          function (data3) {
            // Process data3
          },
          function (error3) {
            console.error('Error fetching data3:', error3);
          }
        );
      },
      function (error2) {
        console.error('Error fetching data2:', error2);
      }
    );
  },
  function (error1) {
    console.error('Error fetching data1:', error1);
  }
);

As you can see, the code quickly becomes difficult to read and understand due to the deeply nested callbacks. This is one of the main reasons why Promises were introduced as a way to handle asynchronous operations in JavaScript. While this example works, it highlights some challenges of using callbacks with fetch, these challenges are:

  1. Callback Hell: As the number of asynchronous operations increases, the code can become deeply nested, making it difficult to read and maintain. This is known as “callback hell.”

  2. Error Handling: With callbacks, error handling becomes more complex, as each callback function needs to handle errors separately. In contrast, Promises allow for centralized error handling using .catch() or .finally() methods.

  3. Composition: Promises make it easy to compose multiple asynchronous operations using methods like Promise.all(), Promise.race(), and Promise.allSettled(). Achieving the same level of composition with callbacks can be cumbersome and challenging.

In the actual fetch implementation and real-world scenario, which uses Promises, the same example would look like this:

fetch('https://api.example.com/data1')
  .then((response1) => response.json())
  .then((data1) => {
    console.log('Data from the first request:', data1);

    return fetch('https://api.example.com/data2');
  })
  .then((response2) => response.json())
  .then((data2) => {
    console.log('Data from the second request:', data2);

    return fetch('https://api.example.com/data3');
  })
  .then((response3) => response.json())
  .then((data3) => {
    console.log('Data from the third request:', data3);
  })
  .catch((error) => {
    console.error('Error fetching data:', error);
  });

So we first fetch data from ‘https://api.example.com/data1‘. After the first request is successful, we log the data and proceed to make a second request to ‘https://api.example.com/data2‘. Similarly, when the second request is successful, we log the data and make a third request to ‘https://api.example.com/data3‘.

Finally, after the third request is successful, we log the data. If any error occurs during any of the requests, we handle it in the .catch() block.

While it’s possible to use callbacks with fetch, Promises offer a more elegant, readable, and maintainable approach to handling asynchronous operations like fetching data from an API.

What Are Promises and How Promises Work in JavaScript

Promises are a powerful feature in JavaScript that helps manage asynchronous operations more effectively. A Promise represents the eventual completion (or failure) of an asynchronous operation and its resulting value. A Promise is in one of three states:

  1. Pending: The initial state; the Promise is neither fulfilled nor rejected.

  2. Fulfilled: The operation was completed successfully, and the Promise has a resulting value.

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

Promises help to avoid the “callback hell” problem by providing a cleaner and more maintainable way to handle asynchronous code.

How they improve asynchronous programming

Promises improve asynchronous programming by:

  • Simplifying error handling through chaining and centralized error management.

  • Making it easier to compose and manage multiple asynchronous operations.

  • Improving code readability and maintainability by avoiding nested callbacks.

Promises and .then() chaining:

Promises allow you to chain .then() methods for handling the results of asynchronous operations. When a Promise is fulfilled, the next .then() method gets in.

Let’s explore a deeper example of chaining .then() methods with Promises using the fetch function to make API requests.

Consider a scenario where we need to fetch data from two different API endpoints. The second API call depends on the data received from the first API call. We can use Promises and .then() chaining to achieve this.

In this example, we’ll use the JSONPlaceholder API to fetch a user’s information and then fetch the user’s posts.

const fetch = require("node-fetch");
// Fetch user data from the JSONPlaceholder API
fetch('https://jsonplaceholder.typicode.com/users/1')
  .then((response) => {
    // Check if the request was successful
    if (!response.ok) {
      throw new Error('Failed to fetch user data');
    }
    // Parse the JSON data from the response
    return response.json();
  })
  .then((user) => {
    console.log('User data:', user);

    // Fetch the user's posts using their ID
    return fetch(`https://jsonplaceholder.typicode.com/posts?userId=${user.id}`);
  })
  .then((response) => {
    // Check if the request was successful
    if (!response.ok) {
      throw new Error('Failed to fetch user posts');
    }
    // Parse the JSON data from the response
    return response.json();
  })
  .then((posts) => {
    console.log('User posts:', posts);
  })
  .catch((error) => {
    // Handle any errors during the fetch operations
    console.error('Error:', error);
  });

We first fetch the user data from the JSONPlaceholder API. When the first request is successful, we parse the JSON data and log the user information. Next, we use the user’s ID to fetch their posts. When the second request is successful, we parse the JSON data and log the user’s posts. If an error occurs during any of the fetch operations, we handle it in the .catch() block.

How to Use Promise.all() and Handle Rejected Promises

Promise.all() is a method that takes an array (or any iterable) of Promises and returns a new Promise that is fulfilled with an array of the fulfilled values of the input Promises, in the same order as the input Promises. The returned Promise is fulfilled only when all input Promises are fulfilled, and it is rejected if any of the input Promises are rejected.

Here’s how you can use Promise.all():

  1. Create an array (or any iterable) of Promises.

  2. Pass the iterable to Promise.all().

  3. Use .then() to handle the array of fulfilled values, or .catch() to handle rejection.

Let’s look at an example using Promise.all() with the fetch function to make multiple API requests:

const fetch = require("node-fetch");
// An array of URLs to fetch
const urls = [
  'https://jsonplaceholder.typicode.com/users/1',
  'https://jsonplaceholder.typicode.com/users/2',
  'https://jsonplaceholder.typicode.com/users/3',
];

// Create an array of Promises using fetch()
const fetchPromises = urls.map((url) => fetch(url));

// Use Promise.all() to wait for all fetch() Promises to be fulfilled
Promise.all(fetchPromises)
  .then((responses) => {
    // Map the responses to Promises that resolve with the parsed JSON data
    return Promise.all(responses.map((response) => response.json()));
  })
  .then((users) => {
    console.log('Fetched users:', users);
  })
  .catch((error) => {
    console.error('Error fetching users:', error);
  });

In this example, we have an array of URLs to fetch user data. We create an array of Promises using fetch() for each URL. Let’s now take a look at more advanced techniques to handle asynchronous code in Javascript.

The Async/await keywords?

Async/await is a more modern and syntactically cleaner way of working with asynchronous code in JavaScript. It is built on top of Promises and makes your asynchronous code look and behaves more like synchronous code.

Async:

The async keyword is used to declare an asynchronous function. When a function is marked with async, it automatically returns a Promise. If the function returns a value, the Promise resolves with that value. If the function throws an error, the Promise is rejected with that error.

const fetch = require("node-fetch");
async function asyncFunction() {
  return 'Hello, async!';
}

asyncFunction()
  .then((result) => console.log(result)) // "Hello, async!"
  .catch((error) => console.error(error));

Await:

The await keyword can only be used inside an async function. It pauses the execution of the function until the Promise is resolved or rejected, and then resumes the execution of the function with the resolved value or throws the rejection reason.

Using await allows you to write asynchronous code that looks and behaves more like synchronous code, making it easier to read and understand.

Here’s an example of using async/await with the fetch function to make an API request:

const fetch = require("node-fetch");
async function fetchUserData() {
  try {
    // Fetch user data from the JSONPlaceholder API
    const response = await fetch('https://jsonplaceholder.typicode.com/users/1');

    // Check if the request was successful
    if (!response.ok) {
      throw new Error('Failed to fetch user data');
    }

    // Parse the JSON data from the response
    const user = await response.json();
    console.log('User data:', user);
 
} catch (error) {
    // Handle any errors during the fetch operation
    console.error('Error:', error);
    }
}

// Call the fetchUserData function
fetchUserData();

We declare an `async` function called `fetchUserData()`. Inside this function, we use the `await` keyword to wait for the `fetch` request to resolve before continuing. We then check if the request was successful and parse the JSON data from the response using `await` as well. If an error occurs during any of the fetch operations, we handle it in the `catch` block. The async/await pattern makes the code easier to read and understand by removing the need for `.then()` and `.catch()` chaining. It allows you to write more linear, synchronous-looking code while still maintaining the benefits of asynchronous operations.

Note that although async/await makes your code look synchronous, it is still non-blocking and asynchronous under the hood. The JavaScript event loop continues to run, and other tasks can still be executed while the async function is waiting for Promises to resolve.

Async/Await in Loops

It’s a common scenario where you need to execute multiple asynchronous operations sequentially, but you also want to use loops to simplify the code. Using async/await in synchronous loops can be a bit tricky, as using await within a regular for or forEach loop would not work as expected.

We’ll discuss how to properly use async/await with loops and provide examples to demonstrate the correct and incorrect approaches.

Incorrect Approach with forEach:

const fetch = require("node-fetch");
async function fetchAllUsers(users) {
  users.forEach(async (userId) => {
    const user = await fetchUserData(userId);
    console.log('User data:', user);
  });

  console.log('Finished fetching all users');
}

fetchAllUsers([1, 2, 3, 4]);

The forEach loop will execute all async functions concurrently, and you’ll see the “Finished fetching all users” message before all the user data is fetched and logged.

Correct Approach with for…of loop:

const fetch = require("node-fetch");
async function fetchUserData(userId) {
  const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);

  if (!response.ok) {
    throw new Error('Failed to fetch user data');
  }

  return await response.json();
}

async function fetchAllUsers(users) {
  for (const userId of users) {
    const user = await fetchUserData(userId);
    console.log('User data:', user);
  }

  console.log('Finished fetching all users');
}

fetchAllUsers([1, 2, 3, 4]);

We use a for…of loop to execute the async fetchUserData() function sequentially for each user ID. The “Finished fetching all users” message will be logged only after all the user data has been fetched and logged.

Using a for…of loop or a regular for loop allows you to properly use await within the loop body, ensuring that each iteration of the loop waits for the previous asynchronous operation to complete before moving to the next. This brings that question to mind.

Correct Approach with map() and Promise.all():

A common use case for combining async/await with map() is when you want to make multiple asynchronous requests and wait for all of them to finish before processing the results. In such cases, you can use Promise.all() along with map() and async/await.

Here’s an example of using async/await with map() and Promise.all():

const fetch = require("node-fetch");

async function fetchUser(userId) {
  const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
  const user = await response.json();
  return user;
}

async function fetchMultipleUsers() {
  const userIds = [1, 2, 3, 4, 5];

  const userPromises = userIds.map(async (userId) => {
    const user = await fetchUser(userId);
    return user;
  });

  const users = await Promise.all(userPromises);
  console.log(users);
}

fetchMultipleUsers();

In this example, we have an async function fetchUser() that fetches a user from the JSONPlaceholder API. We also have an async function fetchMultipleUsers() that fetches multiple users using the fetchUser() function.

Inside fetchMultipleUsers(), we use the map() function to create an array of Promises by calling fetchUser() for each user ID. Then, we use Promise.all() to wait for all Promises to resolve before logging the result.

Is the Async/Await in Loops as same as using Promise.all() ?

No, the Promise.all() approach and the “Asynchronous Awaits in Synchronous Loops” approach serve different purposes and are not the same.

Promise.all(): When you want to execute multiple asynchronous operations concurrently, meaning you want them to run at the same time and wait for all the operations to complete before proceeding, you would use Promise.all(). It takes an array of promises and returns a new promise that resolves with an array of resolved values in the same order as the input promises.

Asynchronous Awaits in Synchronous Loops: When you want to execute multiple asynchronous operations sequentially, meaning you want each operation to complete before moving on to the next one, you would use the correct approach for “Asynchronous Awaits in Synchronous Loops” (using async/await with a for…of loop or a regular for loop).

Here’s a summary of the differences:

  1. Promise.all() is used for concurrent execution of multiple promises, while async/await in synchronous loops is used for sequential execution of multiple promises.

  2. Promise.all() returns a single promise that resolves when all input promises have resolved or rejects if any of the input promises reject, whereas async/await in synchronous loops allows you to handle errors individually for each promise within the loop.

Note that you would choose between these approaches based on whether you need concurrent or sequential execution of your asynchronous operations.

data, analysis, accountant

Conclusion:

We explored the intricacies of asynchronous JavaScript programming, understanding callbacks, promises, and the async/await syntax. We also discussed how to handle common challenges when working with these concepts, such as using async/await in synchronous loops.

These concepts enable developers to write cleaner, more readable, and maintainable code when dealing with asynchronous operations, ultimately leading to a better programming experience and more robust applications.

https://ahmedradwan.dev

Reach out if you want to join me and write articles with the nerds 🙂


© 2024 · Nerd Level Tech

Categories

Social Media

Stay connected on social media