DEV Community

Cover image for Mastering Asynchronous Programming in JavaScript: Unraveling the Magic of async/await, Promises, and More
Oluwatobi Adedeji
Oluwatobi Adedeji

Posted on

Mastering Asynchronous Programming in JavaScript: Unraveling the Magic of async/await, Promises, and More

Picture this: You're at a bustling coffee shop, eagerly awaiting your favorite brew. As you stand in line, you notice a peculiar sight. Instead of waiting their turn, people are multitasking effortlessly. Some chat with friends, others read newspapers, and a few even engage in a friendly game of chess—all while their coffee orders are processed behind the scenes. In this coffee shop, everyone is in perfect sync with their caffeine fix, but what if I told you that JavaScript, your trusty programming language, could function in much the same way? Welcome to the world of asynchronous programming in JavaScript, where code can juggle multiple tasks without missing a beat. It's a bit like a coffee shop where your code can chat with databases, read files, and fetch data from the web—all while serving up a delightful user experience.

Introduction:

In the realm of modern web development, where user expectations are higher than ever, the ability to handle multiple tasks simultaneously is crucial. Enter asynchronous programming in JavaScript, a paradigm that has revolutionized how we write code for the web. At its core, asynchronous programming allows your JavaScript code to efficiently manage tasks that might take time to complete, such as fetching data from an API, reading files, or performing database operations, all without blocking the main execution thread.

In this article, we embark on a journey to demystify the intricacies of asynchronous programming in JavaScript. We'll explore the power duo of async/await functions and the ever-reliable Promises, diving deep into their workings and real-world applications. Whether you're a seasoned JavaScript developer looking to sharpen your skills or a newcomer eager to grasp the fundamentals, you'll find valuable insights here to help you master the art of asynchronous programming.

So, grab your favorite beverage, settle in, and get ready to unlock the true potential of JavaScript's asynchronous capabilities. By the end of this article, you'll be equipped with the knowledge and confidence to harness the magic of async/await, conquer Promises, and navigate the nitty-gritty of asynchronous programming in JavaScript with ease.

What is Asynchronous Programming ?

Asynchronous programming is a programming paradigm and technique that allows tasks or operations to be executed independently and concurrently, without waiting for the previous task to complete before starting the next one. In asynchronous programming, a program can initiate a task and then continue executing other tasks without blocking, or waiting for, the completion of the initial task. This approach is particularly valuable in scenarios where certain tasks may take a variable amount of time to finish, such as network requests, file I/O operations, or user interactions. By performing these tasks asynchronously, a program can remain responsive and efficient, ensuring that it doesn't freeze or become unresponsive while waiting for time-consuming operations to finish.

Implementing Asynchronous Programming in Javascript

Javascript in particular uses asynchronous programming extensively, making use of mechanisms like Promises, async/await, and callback functions to manage asynchronous tasks and maintain a responsive user experience. I will start by using promises, then to async/await then back to promises for some use cases.

Promises

A Promise is a proxy for a value not necessarily known when the promise is created. It allows you to associate handlers with an asynchronous action's eventual success value or failure reason. This lets asynchronous methods return values like synchronous methods: instead of immediately returning the final value, the asynchronous method returns a promise to supply the value at some point in the future.
A Promise is in one of these states:
⦁ Pending: initial state, neither fulfilled nor rejected.
⦁ Fulfilled: meaning that the operation was completed successfully.
⦁ Rejected: meaning that the operation failed.

The eventual state of a pending promise can either be fulfilled with a value or rejected with a reason (error). When either of these options occurs, the associated handlers queued up by a promise's .then method are called. If the promise has already been fulfilled or rejected when a corresponding handler is attached, the handler will be called, so there is no race condition between an asynchronous operation completing and its handlers being attached. A promise is said to be settled if it is either fulfilled or rejected, but not pending.

First, let's create a promise object using the new keyword..

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("resolved");
  }, 300);
});
Enter fullscreen mode Exit fullscreen mode

The promise constructor takes in a callback function with two arguments, the first is called when the promise is fulfilled while the second is for when the promise is rejected(note that the naming of the arguments is for readability, you can use any naming of your choice).. In both arguments, the message you want to pass in is what is received on fulfillment or rejection.

Now let's move to chained promises..
The methods Promise.prototype.then(), Promise.prototype.catch(), and Promise.prototype.finally() are used to associate further action with a promise that becomes settled. As these methods return promises, they can be chained. Meaning .then() returns a newly generated promise object, which can optionally be used for chaining; for example:

myPromise
  .then(handleFulfilledA, handleRejectedA)
  .then(handleFulfilledB, handleRejectedB)
  .then(handleFulfilledC, handleRejectedC);
Enter fullscreen mode Exit fullscreen mode

Processing continues to the next link of the chain even when a .then() lacks a callback function that returns a Promise object. Therefore, a chain can safely omit every rejection callback function until the final .catch() and we have .finally(). So we can have something like

myPromise
.then((value)=> console.log(`${value} is in then`))
.catch((error)=> console.log(`Error message: ${error}`))
.finally(()=> console.log('I am here no matter what happened'))
Enter fullscreen mode Exit fullscreen mode

This picture explains promises graphically

Image description
Also, we have Promise concurrency which gives more power to promises, and that will be discussed in the later part of this article.

Async/Await

The async function declaration creates a binding of a new async function to a given name. The await keyword is permitted within the function body, enabling asynchronous, promise-based behavior to be written in a cleaner style and avoiding the need to explicitly configure promise chains. In clearer terms, async/await helps us to achieve cleaner code over the chains of promises. Consider the following compared to the chains of promises:

const callMyPromise = async()=>{
try{
return await myPromise
}catch(err){
return err
}
}
Enter fullscreen mode Exit fullscreen mode

We will consider more complex and real use cases of async/await in the application section of this article.
Take note :
⦁ await is only valid on promise, if a function does not return a promise, it's invalid to use await even though it does not break your app, but for the readability of your codebase stay away.
⦁ await keyword can be used within the async function and also at the top level of modules too.
Here is an example of a top-level await

 // create a file named promise.js 
// in your promise.js you can have this:
const myPromise = new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("resolved");
    }, 300);
  });
  module.exports = myPromise;
Enter fullscreen mode Exit fullscreen mode
// Create another file named top_level.mjs; .mjs simply means modularized javascript file
await Promise.resolve(console.log("Top level await!!"))
Enter fullscreen mode Exit fullscreen mode

A practical use case of this is when you want to load a file that returns a promise in your .mjs file:


const result =  await import ("./promise.js");
Enter fullscreen mode Exit fullscreen mode

Use cases of Asynchronous Programming

  1. Making Network Requests: When you want to make a network request to a server or any other service, definitely the request will take some microseconds if not seconds to respond and your application depends on the data you are receiving from the network request. We can simulate this by making a simple GET request using Javascript fetch API.
//Make a request with Promise chain
const fetchWithPromiseChain = ()=>{
    const response =  fetch("https://www.boredapi.com/api/activity");
    response.then(response=> response.json()).then(data=> console.log(data)).catch(err => 

}

fetchWithPromiseChain()

// Make request with async await
const fetchWithAsyncAwait = async ()=> {
  try {
    const response = await fetch("https://www.boredapi.com/api/activity");
    const activity = await response.json();
    console.log(activity);
  } catch (e) {
    console.log("ERROR",e.message);
  }
}
fetchWithAsyncAwait();

Enter fullscreen mode Exit fullscreen mode

From the above code snippet, we have two different approaches, and async/await seems to be cleaner, you won't realize this until you have many stuff to handle in your application.

Let's step up the game for instance when you want to make multiple network requests that your app essentially needs... Let's illustrate our approach to this.

const landingPageDataWithAsyncAwait = async () => {
  try {
    console.time("async fetching data");
    const firstCall = await fetch(
      "https://official-joke-api.appspot.com/random_joke"
    );
    const randomJoke = await firstCall.json();
    const secondCall = await fetch("https://www.boredapi.com/api/activity");
    const boredomData = await secondCall.json();
    const thirdCall = await fetch("https://randomuser.me/api/");
    const randomUser = await thirdCall.json();
    const fourthCall = await fetch("https://api.ipify.org?format=json");
    const ipData = await fourthCall.json();
    console.timeEnd("async fetching data");
    return { randomJoke, boredomData, randomUser, ipData };
  } catch (error) {
    console.log("ERROR", error.message);
  }
};

Enter fullscreen mode Exit fullscreen mode

The time taken to execute this function

async fetching data: 3.058s
Enter fullscreen mode Exit fullscreen mode

In this case, each request is independent of another, and what we are doing is that after a network request is done, that's when the next request can be made, which is unnecessarily going to make your app slow down, this method is best when each network request is dependent of data coming from another... There is a better approach to this by making parallel requests, here we can now talk about Promise concurrency; we have Promise.all, Promise.allSettled, Promise.any, Promise.reject each as their use cases.

  1. Promise.all: Fulfills when all promises are fulfilled; rejects when any of the promises are rejected.

  2. Promise.allSettled: Fulfills when all promises settle.

  3. Promise.any: Fulfills when any of the promises fulfills; rejects when all of the promises are rejected.

  4. Promise.race: Settles when any of the promises settles. In other words, fulfills when any of the promises fulfills; rejects when any of the promises are rejected.

In this case, we will be using .allSettled over the .all

const landingPageDataWithPromiseChain = async () => {
  try {
    console.time("fetching data");
    const firstCall = fetch(
      "https://official-joke-api.appspot.com/random_joke"
    );
    const secondCall = fetch("https://www.boredapi.com/api/activity");
    const thirdCall = fetch("https://randomuser.me/api/");
    const fourthCall = fetch("https://api.ipify.org?format=json");

    const [randomJoke, boredomData, randomUser, userIp] =
      await Promise.allSettled([firstCall, secondCall, thirdCall, fourthCall])
        .then((res) => res.map((r) => r.value.json()))
        .catch((e) => console.log(e));
    console.timeEnd("fetching data");

    return {
      randomJoke: await randomJoke,
      boredomData: await boredomData,
      randomUser: await randomUser,
      userIp: await userIp,
    };
  } catch (error) {
    console.log(error.message);
  }
};

(async () => {
  const result = await landingPageDataWithPromiseChain();
  console.log(result);
})();
Enter fullscreen mode Exit fullscreen mode

Time taken for the function to execute

fetching data: 1.912s
Enter fullscreen mode Exit fullscreen mode

Running each of these cases differently, you'll see from the time logged in the console the latter is faster than the former and as an engineer, every microseconds counts.

We need to touch another use case which is having tasks that require us to perform asynchronous actions in an array, this can be quite brain-tasking but here is an example and the fastest approach we can take..

Let's say we have an array of delivery boxes sent to the backend, and each box contains an array of items and each item has a value of productId, we need to get some specific detail of the product from our database, and in the end we want to return the product details of each product in items for every box

Let's have our code...

const products = async () => await Promise.all(
        payload.boxes.map(async (box)=>{
          const items =  Promise.all( box.items.map(async (item)=>{
          const product = await products.findOne({where:{id:item.productId}});
            return {name:product?.name,id:product?.id,categoryId:product?.categoryId,subCategoryId:product?.subCategoryId};
          })
          )
          return  new Promise((resolve)=> resolve(items)); 
        })
      ) 
Enter fullscreen mode Exit fullscreen mode

We needed to have multiple Promise.all because we are dealing with a task that has to do with a multidimensional array kind of thing, and this proves to be arguably the fastest approach to this kind of problem.

There are so many other use cases of asynchronous programming, in fact, a lot of Web APIs are built on promises e.g. WebRTC, WebAudio, etc... As a software engineer asynchronous programming is very important and I hope with this article you have been able to learn more about asynchronous programming with Javascript. Thank you for reading this article, you can share what topic you want me to write on in my next article.

Top comments (9)

Collapse
 
oluwatobi_ profile image
Oluwatobi Adedeji

I am glad you did love it.

Collapse
 
mfp22 profile image
Mike Pearson

The only way to master asynchronous programming in JavaScript is to learn RxJS.

Collapse
 
oluwatobi_ profile image
Oluwatobi Adedeji

Thank you for the great opinion.
While I agree that RxJS is great for asynchronous programming in JS, it's also very important to understand and master the core of it in JS, afterall RxJs is a library 😅

Collapse
 
mfp22 profile image
Mike Pearson

Observables might actually make it into ES soon. Anyway, yeah it helps to understand promises and other bad async utilities provided by JS, because you will definitely see developers mistakingly use them and you have to understand that code so you can convert it into something that's actually declarative, flexible and performant.

Collapse
 
dumebii profile image
Dumebi Okolo

Great work!

Collapse
 
oluwatobi_ profile image
Oluwatobi Adedeji

Thank you Dumebi

Collapse
 
juanduartea profile image
Juan Duarte • Edited

Nice article, however the ChatGPT style is very obvious. You should use your own article titles or at least try modifying the ones ChatGPT suggest because they are all so generic.

Collapse
 
xmohammedawad profile image
Mohammed Awad

exactly. the title and the body, there is no shame in using ChatGpt but we should use it to deliver our own idea and how we understand it

Collapse
 
soulflo profile image
SOULFLO

Hi