Back

Best Practices for Async Programming in JavaScript

Best Practices for Async Programming in JavaScript

JavaScript provides several tools to allow you to write and execute asynchronous (async) code with no loss in performance. This article will show several best practices for async coding so you can write optimum code.

JavaScript is one of the most popular programming languages for developing web apps since it is dynamic and versatile. One of its most notable features is its ability to execute asynchronous code, allowing it to do multiple tasks concurrently without blocking the main thread. This article will discuss asynchronous programming and show the best technique to perform asynchronous programming in Javascript, as well as how to manage and handle async code effectively.

Asynchronous Programming

This programming paradigm allows the execution of multiple tasks at one time without blocking the main thread. In other words, it lets your application continue operating while it waits for a task to finish, such as an API request. The significance of asynchronous programming in JavaScript cannot be overemphasized. Async code is vital for providing a seamless user experience in a world where people demand quick and responsive online apps. It lets developers build applications that can perform numerous activities at once, making them more efficient and scalable.

How does asynchronous programming differ from its counterpart, synchronous programming? Synchronous programming is a programming paradigm in which the execution of a task prevents the execution of succeeding tasks until it is done. This indicates that one task must be completed before the next may begin. In other words, the program waits for one job to finish before proceeding to the next. In a synchronous application, for example, if you make an API call, the software will wait for the response before moving on to the next task. In an asynchronous application, the application will continue to do other operations while it waits for the API response. As a result, the application runs more smoothly and efficiently.

Asynchronous programming is essential for building fast and responsive web applications, while synchronous programming is better suited for basic, single-threaded applications.

Asynchronous Patterns in JavaScript:

What exactly are asynchronous patterns? They are various approaches or tactics for managing and executing asynchronous programming. These patterns include callbacks, promises, and usage of async/await; we’ll consider them in the following sections.

Callbacks

The callback pattern refers to a programming technique in which a function is passed as an argument to another function and runs (“is called back”) when a certain event occurs or the outer function has completed some processing.

function fetchData(callback) {
  fetch("https://reqres.in/api/users/2")
    .then((response) => response.json())
    .then((data) => {
      callback(data);
    });
}

fetchData(function (data) {
  console.log(data);
});

As seen, the fetchData function is defined and takes a callback as its argument. This function makes an asynchronous operation, such as an HTTP request, and then calls the callback with the result.

Promises

Promises, however, provide a cleaner way to handle asynchronous code than callbacks, as they allow for chaining.

function fetchData() {
  return fetch("https://reqres.in/api/users/2").then((response) =>
    response.json()
  );
}

fetchData().then((data) => {
  console.log(data);
});

The code above is similar to callbacks, except instead of a callback, the fetchData function returns a Promise that resolves with the JSON data. The caller may then use the then() method to register a callback that accepts the JSON data as input.

Async/Await

The silver lining is the async/await pattern, which was introduced in ECMAScript 2017 and made it easier to handle async code in a synchronous-like way. You can build async code that looks like synchronous code and is easier to understand and maintain with async/await.

async function fetchData() {
  const response = await fetch("https://reqres.in/api/users/2");
  return response.json();
}

fetchData().then((data) => {
  console.log(data);
});

The code is still similar to callbacks and promises, but instead of utilizing the then() method, the await keyword is used. The fetchData function is declared async, which implies it may utilize the await keyword. The await keyword is used to wait for the promise returned by fetch to resolve before continuing execution. The await keyword is then used again to wait for the promise returned by response.json() to resolve before resuming execution. Finally, the JSON data is logged to the console.

Each pattern has its pros and cons, and the best choice depends on the specific requirements of your application.

Session Replay for Developers

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — an open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

Best practices for Asynchronous Programming in JavaScript

Asynchronous programming in JavaScript may be tricky and challenging, but by following best practices, you can make your code more efficient and manageable. Some of the practices I recommend are as follows:

  • Avoiding callback hell
  • Proper consumption of promises
  • Use async/await
  • Error handling (try-catch-finally)
  • proper parallel running of promises

Avoiding Callback hell

Callback Hell refers to a situation where you have multiple nested callbacks, making the code hard to read and maintain.

Here is a simple example of callback hell:

async function fetchData() {
  const response = await fetch("https://restcountries.com/v3.1/name/spain");
  return response.json();
}

fetchData().then((data) => {
  console.log(data);
  const countryName = data[0].name.common;
  console.log(countryName);
  const [neighbour] = data[0].borders;
  if (!neighbour) return;
  async function fetchData2() {
    const response = await fetch(
      `https://restcountries.com/v3.1/alpha/${neighbour}`
    );
    return response.json();
  }
  fetchData2().then((data) => {
    console.log(data);
    const neighbourName = data[0].name.common;
    console.log(neighbourName);
  });
});

The above scenario is a perfect example of callback hell. In the above example, we utilize the restcountries API to get the name of a country and one of its neighbors. The above code demonstrates callback hell. In this case, ‘fetchData()’ is called, followed by a sequence of nested callbacks that first extract the country name and then extract the neighboring country name using an asynchronous ‘fetchData2()’ method. Since the nested structure of callbacks may make the code difficult to comprehend, debug, and maintain, promise offers a solution.

Proper consumption of Promises

Using promises, each operation returns a promise, and all operations are linked together using ‘.then()‘. This method is much easier to read and manage than the previous one, which used nested callbacks. It’s also shorter since you only need one.catch() statement at the end of the chain to handle errors.

async function fetchData() {
  try {
    const response = await fetch("https://restcountries.com/v3.1/name/spain");
    return response.json();
  } catch (error) {
    return console.error(error);
  }
}

async function fetchNeighbourData(neighbour) {
  try {
    const response = await fetch(
      `https://restcountries.com/v3.1/alpha/${neighbour}`
    );
    return await response.json();
  } catch (error) {
    return console.error(error);
  }
}

fetchData()
  .then((data) => {
    console.log(data);
    const countryName = data[0].name.common;
    console.log(countryName);
    const [neighbour] = data[0].borders;
    if (!neighbour) return Promise.reject("No neighbour found.");
    return fetchNeighbourData(neighbour);
  })
  .then((data) => {
    console.log(data);
    const neighbourName = data[0].name.common;
    console.log(neighbourName);
  })
  .catch((error) => console.error(error));

In the above code, we utilized promises to resolve the callback hell problem. We utilized ‘.then()’ to chain dependent activities so that once the country’s data is fetched, the code continues to get its neighbor, and if not, the promise is rejected.

The following are some recommended practices for dealing with promises in JavaScript:

  • Always use .then() and .catch() methods to handle the resolved and rejected states of a Promise, respectively.

  • Once a Promise is resolved with a value, it should not be modified. Resolved values should be treated as immutable. Promises can be modified indirectly by creating a new Promise that wraps the original Promise and modifies the resolved value when the new Promise is resolved. For best practice, this process should be avoided.

Use Async Await

As seen in the “Asynchronous patterns in JavaScript” section, the Async and Await pattern gives you many ways to deal with asynchronous code. It is also the most popular and effective pattern. Compared to other patterns, this one is the best because of the following reasons:

  • Code readability
  • Simplifies error handling
  • Avoids callback hell
  • Improved debugging

So, always try to use async/await when possible.

Error Handling (try-catch-finally)

One of the key best practices in asynchronous programming in JavaScript is proper error handling. When writing asynchronous code, it’s important to handle any errors that may occur to prevent unexpected behavior and ensure a stable application. What better way to handle errors than using the try-catch-finally block?

The try-catch-finally block is a fundamental structure for error handling in JavaScript. An async function’s try block contains code that may throw an error and need error handling. The try block’s function is performed, and if an error is thrown, it is caught by the catch block, which has the error handling code. The finally block specifies a block of code that will be executed regardless of whether an error occurs. This block is also optional, but it must come after the catch block if included.

async function fetchData() {
  try {
    const response = await fetch("https://reqres.in/api/users/2");
    if (!response.ok) {
      throw new Error("Failed to fetch data, status code: " + response.status);
    }
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error("An error occurred while fetching data:", error);
  } finally {
    console.log("Data fetch operation completed");
  }
}
fetchData();

In this example, we’ve added a throw statement to the try block that throws a new error with a custom error message (i.e., response.ok is false) if the API response is unsuccessful. If an error occurs during the fetchData function, a custom error message with further details about the error will be logged to the console. Lastly, we have a block called finally, which logs a message to the console that says our fetch operation is complete. You may offer additional precise information about the error that occurred by throwing a new error in the try block, allowing you to identify and fix any problems with your code.

Proper Parallel running of Promises

Running promises in parallel means executing multiple promises simultaneously and processing their results as soon as they are available. This can improve the performance of your asynchronous code and make it run faster. Here are the top Promise combinators when running promises in parallel and the best use case for each.

  • Promise.all(): This takes an array of promises as an argument and returns a single promise that either resolves when all of the promises in the array have resolved or rejects with the reason of the first rejected promise. The resolved values are returned in the order in which they were passed in the array.

Here’s an example of how to use Promise.all():

const urls = [
  "https://reqres.in/api/users/2",
  "https://reqres.in/api/users/3",
  "https://reqres.in/api/users/4",
];

async function fetchData(url) {
  const response = await fetch(url);
  const data = await response.json();
  return data;
}

async function getData() {
  try {
    const data = await Promise.all(urls.map(fetchData));
    console.log("All responses resolved:", data);
  } catch (error) {
    console.error("One or more promises rejected:", error);
  }
}

getData();

In this example, we pass an array of three promises to Promise.all(). When all of the promises have been resolved, the then() method is called with an array of response objects. If any of the promises reject, the catch() method is called with the reason for the first rejected promise.

If one of the promises in the array rejects, the entire Promise.all() call will immediately reject with the reason for the first rejected promise, and the remaining promises in the array will not be executed. Whether to cancel or continue with the remaining promises depends on the context of your application and the logic you want to implement.

  • Promise.race(): This takes an array of promises as an argument and returns a single promise that resolves or rejects with the value of the first promise that settles. Use Promise.race() when you need to return the result of the first promise that settles, regardless of whether it resolves or rejects. The Promise.race() method is mostly used for timeouts.

Here’s an example of how to use Promise.race():

async function getFastestResponse() {
  const promises = [
    fetch("https://reqres.in/api/users/2"),
    fetch("https://reqres.in/api/users/3"),
    fetch("https://reqres.in/api/users/4"),
  ];

  try {
    const fastestResponse = await Promise.race(promises);
    const data = await fastestResponse.json();
    console.log("Fastest response resolved:", data);
  } catch (error) {
    console.error("One or more promises rejected:", error);
  }
}

getFastestResponse();

In this example, we pass an array of three promises into Promise.race(). The Promise.race() function will be resolved or rejected based on the promise that settles first, and the other promises will be ignored. The value of the first settled promise, which might be resolved or rejected, is sent to the then() function. If all of the promises fail, the catch() function is called, passing the cause for the first failed promise.

Conclusion

In conclusion, asynchronous programming is an important component of modern web development, and JavaScript offers several techniques to handle asynchronous code. The async and await keywords are two of the most common and efficient methods of handling asynchronous programming. You can guarantee that your asynchronous code is well-structured, efficient, and simple to maintain by utilizing best practices such as try-catch blocks and proper promise consumption.

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs and track user frustrations. Get complete visibility into your frontend with OpenReplay, the most advanced open-source session replay tool for developers.

OpenReplay