JavaScript Async/Await - Asynchronous Programming Simplified

·

11 min read

Introduction

Asynchronous programming is an essential concept in JavaScript for handling tasks that take time to complete, such as fetching data from APIs or reading from a file. In the previous blog posts, we discussed Promises, which provide a way to handle asynchronous code in a more organized and structured manner. In this post, we will dive into async/await - a modern approach of handling asynchronous tasks in JavaScript that builds upon Promises and makes code even more concise and readable.

Importance and Benefits

Async/await has become an essential feature in modern JavaScript for handling asynchronous tasks. It provides a more concise and readable way to write asynchronous code compared to traditional callback-based or promise-based approaches. Async/await helps developers avoid "callback hell" and write more maintainable and error-free code. It has become the standard approach for handling asynchronous tasks in many modern JavaScript applications. Here are the benefits of using Async/await at a glance -

  1. Readability: Async/await allows you to write asynchronous code similar to the synchronous style, making it easier to understand and maintain.

  2. Error handling: Async/await provides a built-in error handling mechanism with try-catch blocks, making it easy to handle errors gracefully.

  3. Simplified control flow: Async/await allows you to write asynchronous code with a more sequential and linear flow, making it easier to reason about and debug.

  4. Compatibility with Promises: Async/await is built on top of Promises, so you can still use Promises when needed and seamlessly integrate async/await with existing codebases.

Async Functions

Definition

Async functions are a special type of function in JavaScript that allow you to write asynchronous code using the async keyword. An async function is a function that returns a Promise, and it can contain one or more await expressions, which pause the execution of the function until a Promise is resolved or rejected. Async functions provide a more concise and readable way to write asynchronous code compared to use of nested callbacks or chaining Promises with .then(). If you're feeling a little bit rusty or not sure how Promises work, click here to revisit the concepts of JavaScript Promises.

Syntax

The syntax of async functions is like regular functions in JavaScript, with the addition of the async keyword before the function declaration. Let's see an example -

async function fetchData() {
  // Asynchronous code here
}

Async functions can also have parameters, like regular functions, and can return a value or a Promise.

Using await with Promises

The await keyword is used to pause the execution of a function until a Promise is resolved, and it can only be used within an 'async' function. Async functions automatically wrap their return value in a resolved Promise. If an async function returns a value, the Promise will be resolved with that value. If an async function throws an error or returns a rejected Promise, then the Promise returned by the async function will be rejected with that error. This makes it easy to handle the results of async functions using await or chaining with .then() and .catch(). Let's see an example of async/await in action -

async function fetchUserData() {
  try {
    const response = await fetch('https://api.example.com/users');
    const data = await response.json();
    return data;
  } catch (err) {
    console.error('Error fetching user data:', err);
    throw err;
  }
}

In this example, async/await is used to fetch user data from an API, parse the response as JSON, and handle any errors that may occur during the process. If you're not familiar with APIs and JSON payload, don't worry! This is just an example to explain how the await keyword handles asynchronous operations and waits for the task to finish. After the task is finished, a Promise is returned either with a resolved value or a rejected state with an error message. The entire operation is placed in a try/catch block to check for Promise validation and catch and throw the error in case of a rejected Promise.

Avoiding "callback hell" with Async/Await

Async/await provides a more linear and sequential way of writing asynchronous code, without the need for nested callbacks. Let's see an example to understand this better -

// Using callbacks
function getData(callback) {
  fetchData(function (err, data) {
    if (err) {
      callback(err);
    } else {
      processData(data, function (err, result) {
        if (err) {
          callback(err);
        } else {
          callback(null, result);
        }
      });
    }
  });
}

// Using async/await
async function getData() {
  try {
    const data = await fetchData();
    const result = await processData(data);
    return result;
  } catch (err) {
    throw err;
  }
}

In the first example, the function with more than one nested callback for different asynchronous functions results in a chain of code blocks - which can be really messy when the number of nested callbacks increases even more. The second one is the simplified version in async/await, where the nested callbacks are placed separately with an await keyword to handle them asynchronously.

Error Handling with Async/Await

Handling errors with try-catch block

One of the main benefits of async/await is that it allows for easy and centralized error handling using a try-catch block. Errors that occur within an async function can be caught using a regular try-catch block, just like synchronous code. We have already seen the usage in our previous example. Let's see again -

async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    return data;
  } catch (err) {
    console.error('Error fetching data:', err);
    throw err; // Rethrow the error to propagate it to the caller
  }
}

In this example, any errors that occur during fetching and parsing data will be caught by the catch block, allowing for proper error handling.

Handling Promise Rejections with catch()

Async/await also allows the handling of Promise rejections using the catch() method. When a Promise is rejected within an async function, it will automatically trigger the catch() block, allowing for graceful error handling. Example -

async function processData(data) {
  try {
    // Perform data processing
    const result = await doSomeProcessing(data);
    return result;
  } catch (err) {
    console.error('Error processing data:', err);
    throw err; // Rethrow the error to propagate it to the caller
  }
}

In this example, if doSomeProcessing() returns a rejected Promise, it will trigger the catch block, allowing for proper error handling.

Handling Multiple Async/Await Errors

When multiple async/await operations are executed sequentially, it's important to handle errors for each operation individually. If an error occurs in one operation, the subsequent operations won't be executed, and the error will be caught in the nearest catch block. Let's see another example -

async function performTasks() {
  try {
    const task1Result = await doTask1();
    const task2Result = await doTask2();
    // More tasks...
    return task2Result;
  } catch (err) {
    console.error('Error performing tasks:', err);
    throw err; // Rethrow the error to propagate it to the caller
  }
}

In this example, if an error occurs in doTask1(), it will trigger the catch block and prevent the execution of doTask2() and subsequent tasks.

Parallel and Sequential Execution with Async/Await

One of the powerful features of async/await is the ability to execute asynchronous tasks in parallel or sequentially, depending on the requirements of your application. Let's delve into how you can achieve parallel and sequential execution with async/await.

Parallel Execution with Promise.all()

Promise.all() is a built-in utility in JavaScript that allows you to execute multiple Promises in parallel and wait for all of them to resolve or reject. When combined with async/await, you can easily execute multiple async tasks in parallel and wait for all of them to complete before proceeding. Example -

async function fetchAndProcessData() {
  const [data1, data2, data3] = await Promise.all([
    fetch('https://api.example.com/data1'),
    fetch('https://api.example.com/data2'),
    fetch('https://api.example.com/data3')
  ]);

  // Process the fetched data
  const processedData = await processFetchedData(data1, data2, data3);

  return processedData;
}

In this example, three fetch() requests are executed in parallel using Promise.all(), and the fetched data is then processed sequentially with async/await.

Sequential Execution with Async/Await

By default, async/await executes asynchronous tasks sequentially, meaning that the next task won't start until the previous one completes. This can be useful in scenarios where you need to ensure that either the tasks are executed in a specific order or depend on the result of a previous task. For example -

async function performTasksSequentially() {
  const task1Result = await doTask1(); // First task
  const task2Result = await doTask2(task1Result); 
  // Second task after completing first task
  const task3Result = await doTask3(task2Result);
  // Third task after completing second task

  // Return the final result
  return task3Result;
}

In this example, doTask1(), doTask2(), and doTask3() are executed sequentially using async/await, where the result of each task is passed as a parameter to the next task.

Mixing Parallel and Sequential Execution

Async/await provides flexibility in mixing parallel and sequential execution to suit the requirements of your application. You can execute some tasks in parallel using Promise.all() while executing others sequentially using async/await. For example -

async function fetchAndProcessDataMixed() {
// Get data extracted parallelly
  const [data1, data2] = await Promise.all([
    fetch('https://api.example.com/data1'),
    fetch('https://api.example.com/data2')
  ]);

// Use data extracted as inputs and execute tasks sequentially
  const processedData1 = await processFetchedData1(data1);
  const processedData2 = await processFetchedData2(data2);

  // Return the combined processed data
  return combineProcessedData(processedData1, processedData2);
}

In this example, fetch() requests for data1 and data2 are executed in parallel using Promise.all(), and the fetched data is then processed sequentially with async/await.

Combining Async/Await with Other Asynchronous Patterns

Async/await can be combined with other asynchronous patterns in JavaScript to handle complex scenarios and achieve efficient execution of asynchronous tasks. Let's take a look at some common asynchronous patterns that can be combined with async/await.

Promise Chaining

Async/await can be used in combination with Promise chaining to handle a sequence of asynchronous tasks. You can chain Promises together using the then() method and then use await to handle the resolved value of the Promises.

async function fetchDataAndProcess() {
  const data = await fetch('https://api.example.com/data')
    .then(response => response.json())
    .then(data => processData(data))
    .catch(error => {
      console.error('Error occurred:', error);
      // Handle the error or throw a new error
    });

  return data;
}

Here, the fetch() request is chained with then() to handle the response and parse the JSON data, and then the processData() function is called with the parsed data. Any errors that occur during the Promise chain can be caught with catch().

Callbacks

While async/await provides a more concise way to handle asynchronous code, you may encounter scenarios where you need to work with callback-based APIs or functions. In such cases, you can wrap the callback-based code in a Promise using util.promisify() or create your own Promise wrapper. You will be familiar with this if you are learning Node.js - but don't worry if you have not. Just focus on the concepts and you'll be fine for now.

const fs = require('fs');
const util = require('util');

const readFileAsync = util.promisify(fs.readFile); 
// Promisifies a synchronous funcion (readFile)

async function readAndProcessFile() {
  try {
    const fileContent = await readFileAsync('example.txt', 'utf8');
    const processedData = await processData(fileContent);
    return processedData;
  } catch (error) {
    console.error('Error occurred:', error);
    // Handle the error or throw a new error
  }
}

In this example, the readFile() function from the Node.js fs module, which uses callbacks, is wrapped with util.promisify() to convert it into a Promise, and then it is used with async/await to read and process the file.

Event Emitters

Async/await can also be used with event emitters, which are a common pattern for handling asynchronous events in Node.js applications. You can use the promisify() function to convert event-based APIs into Promises, and then use async/await to handle the resolved values. You'll see these in action while using Node.js.

Error Propagation with Async/Await

Error propagation is an important aspect of asynchronous code, as it allows you to handle and propagate errors throughout the call stack. With async/await, you can propagate errors in a more concise and structured way compared to other asynchronous patterns.

Throwing Errors

You can throw errors inside an async function using the throw statement, and the error will be propagated to the nearest catch block or rejected Promise. Example -

async function fetchDataAndProcess() {
  try {
    const data = await fetch('https://api.example.com/data');
    if (!data.ok) {
      throw new Error('Failed to fetch data'); // New error object creation
    }
    const processedData = await processData(data);
    return processedData;
  } catch (error) {
    console.error('Error occurred:', error);
    // Handle the error or re-throw it
    throw error;
  }
}

In this example, if the fetch() request returns a non-ok response, an error is thrown, which will be caught in the catch block. You can also choose to re-throw the error to propagate it further up the call stack or handle it as needed.

Returning Rejected Promises

You can return a rejected Promise inside an async function to propagate an error. You can use the Promise.reject() method to create a rejected Promise with a specific error message or an error object.

async function fetchDataAndProcess() {
  const data = await fetch('https://api.example.com/data');
  if (!data.ok) {
    return Promise.reject(new Error('Failed to fetch data'));
  }
  const processedData = await processData(data);
  return processedData;
}

In this example, if the fetch() request returns a non-ok response, then a rejected Promise is returned which will propagate the error to the caller of the async function.

Recap

Now let's recap all that we have discussed so far -

  • Async/await is a powerful feature in JavaScript that simplifies asynchronous programming. It allows for more concise and readable code when dealing with asynchronous tasks.

  • Syntax and usage of async/await with async functions and promises.

  • Error handling with try-catch blocks and Promise.catch().

  • Chaining async/await functions and using Promise.all() for parallel execution.

  • Combining async/await with other asynchronous patterns like callbacks and events.

  • Error handling and error recovery strategies with async/await.

Conclusion

Congratulations! You've learned about the powerful async/await feature in JavaScript and how it simplifies asynchronous programming. As a beginner, you may find async/await a bit challenging at first, but with practice and understanding of its syntax and usage, you can greatly enhance your JavaScript skills. Don't be afraid to experiment with async/await in your own projects and learn from the experience! So once again, let's code!