Asynchronous Programing

Keshav AKeshav A
13 min read

Asynchronous Programming allows to perform tasks that takes time to execute (such as fetching data from a server or reading files) without blocking the execution of other code. Instead of waiting for a task to complete, the code moves on to other tasks and handles the result later.

Common methods to manage asynchronous code

  1. Callbacks function

  2. Promises

  3. Async/Await

Callbacks

A callback function is a function passed into another function as an argument, which is then invoked inside the outer function.

Syntax:

function print(callback) {
    /* Code */    
    callback();
}

Why Callback functions are required

  • JavaScript runs code sequentially in top-down order.

    Example:

      function taskOne(){
          console.log("Task one is executed");
          taskTwo();
      }
      function taskTwo(){
          console.log("Task two is executed");
      }
      taskOne();
      taskTwo();
    
      Task one is executed
      Task two is executed
    

    Here taskOne() is executed first, then later taskTwo() is executed.

  • However, there are some cases that code runs after something else happens and also not sequentially. This is called asynchronous.

  • Consider the following program

      function taskOne(){
          console.log("Task one is executed");
      }
      function taskTwo(){
          console.log("Task two is executed");
      }
      setTimeout(taskOne,2000);
      taskTwo();
    
      Task two is executed
      Task one is executed
    

Here even though the taskOne() is called, taskTwo() is executed first, and when the timer ends, taskOne() is executed.

The function passed to setTimeout() is a callback function.

  • Callbacks make sure that a function is not going to run before a task is completed but will run right after the task has completed.

Advantage of callbacks

  1. Callbacks reduce usage of redundant codes.

    • For Example, to find area and circumference of circle for given array of radius:

        const area = radius => Math.PI * radius * radius;
        const circumference = radius => 2 * Math.PI * radius;
      
        // Define the circle function which takes radius values and callbacks for area and circumference
        function circle(radii, areaCallback, circumferenceCallback) {
            const areas = radii.map(areaCallback);
            const circumferences = radii.map(circumferenceCallback);
            return { areas, circumferences };
        }
      
  2. Callbacks does not pause the execution of main program.

  3. Flexibility in Handling Results and Errors.

     function divide(a, b, callback) {
         if (b === 0) {
             callback('Cannot divide by zero', null);
         } else {
             callback(null, a / b);
         }
     }
     divide(10, 2, (error, result) => {
         if (error) {
             console.error(error);
         } else {
             console.log('Result:', result);  // Result: 5
         }
     });
    

Nested callbacks or Callback Hell

  • Callback Hell refers to the situation where the code becomes deeply nested due to multiple levels of callbacks, making it difficult to read, understand, and maintain.

      main(function(err, result) {
          if (err) {
          console.error(err);
          } else {
              callback1(result, function(err, newResult) {
                  if (err) {
                      console.error(err);
                  } else {
                      callback2(newResult, function(err, finalResult) {
                          if (err) {
                              console.error(err);
                          } else {
                              console.log(finalResult);
                          }
                      });
                  }
              });
          }
      });
    

    Callback Hell is also reffered to as 'Pyramid of Doom', since the code grows horizontally, it becomes difficult to read and maintain.

Promise

A 'Promise' is an object that represents the eventual completion (or failure) of an asynchronous operation.

Basic Structure of a Promise

const promise = new Promise((resolve, reject) => {
  // Asynchronous operation
  if (/* operation successful */) {
    resolve(result); // Operation was successful
  } else {
    reject(error);   // Operation failed
  }
});

Promise states

  • A Promise in JavaScript represents the eventual result of an asynchronous operation and can be in one of three distinct states:
  1. Pending:

    • The 'pending' state is the initial state of a promise. When a promise is created, it starts in the 'pending' state.

    • The final result of the asynchronous operation is not yet known.

  2. Fulfilled

    • A promise transitions to the 'fulfilled' state when the asynchronous operation completes successfully.

    • Resolved: The Promise has been resolved with a value.

    • The promise is now settled.

  3. Rejected

    • A Promise transitions to the Rejected state when the asynchronous operation fails.

    • The promise is now settled.

Key Methods of Promises

  1. then(onResolve, onReject)

    • Adds fulfillment and rejection handlers to the promise.

    • Returns a new promise resolving to the return value of the called onResolve or onReject callback.

    • then() method makes sure to use callback function only when the promise is either resolved or rejected.

Syntax:

promise.then(onResolve, onReject);

Example:

const pr = new Promise((resolve, reject) => {
    setTimeout(() => resolve("Success!"), 1000);
});
pr.then((result, error) => {
    if (error) return console.log(error); // Handles error
    else return console.log(result); // Handles result
});
  1. catch(onReject)

    • catch() handles promise rejection.

    • It immediately returns another promise object.

    • It is a shortcut for Promise.prototype.then(undefined, onReject)

Syntax:

promise.catch(onReject);

Example:

const pr = new Promise((resolve, reject) => {
    setTimeout(() => reject("Error!"), 1000);
});
pr.catch((error) => console.log(error)); // Error!
  1. finally(onFinally)

    • Adds a handler to be called when the promise is settled (either fulfilled or rejected).

    • It immediately returns another Promise object.

    • finally() avoids duplicating code in both the promise's then() and catch() handlers.

Syntax:

promise.finally(onFinally);

Example:

const pr = new Promise((resolve, reject) => {
    setTimeout(() => resolve("Finished!"), 1000);
});
pr
.then((result) => console.log(result)) // "Finished!"
.finally(() => console.log("Cleanup")); // Cleanup
  1. Promise.all(iterable)

    • Promise.all() is a static method that takes an iterable of promises as input and returns a single Promise.

    • The returned promise fulfils when all of the input's promises fulfill (including when an empty iterable is passed).

    • The returned promise rejects when any of the iterable promises rejects, with this first rejection reason.

      Syntax:

        Promise.all([promise1, promise2, ...])
      

      Example:

        const pr1 = new Promise((resolve, reject) => {
            return resolve("Promise 1 resolved");
        });
        const pr2 = new Promise((resolve, reject) => {
            return resolve("Promise 2 resolved");
        });
        const pr3 = new Promise((resolve, reject) => {
            return resolve("Promise 3 resolved");
        });
        const prAll = Promise.all([pr1, pr2, pr3]);
        prAll
            .then((result) => console.log(result)) // ['Promise 1 resolved', 'Promise 2 resolved', 'Promise 3 resolved']
            .catch((error) => console.error(error));
      
    • When one or more promise is rejected:

        const pr1 = new Promise((resolve, reject) => {
        return reject("Uh oh! Promise 1 failed to resolve");
        });
            const pr2 = new Promise((resolve, reject) => {
            return resolve("Promise 2 resolved");
        });
            const pr3 = new Promise((resolve, reject) => {
            return resolve("Uh oh! Promise 3 failed to resolve");
        });
        const prAll = Promise.all([pr1, pr2, pr3]);
        prAll
            .then((result) => console.log(result))
            .catch((error) => console.error(error)); // "Uh oh! Promise 1 failed to resolve"
      
  2. Promise.allSettled(iterable)

    • Returns a promise that resolves after all of the given promises have either resolved or rejected.

      Syntax:

        Promise.allSettled([promise1, promise2, ...])
      

      Example:

        const pr1 = new Promise((resolve, reject) => reject("Uh oh! Promise 1 did not resolve"));
        const pr2 = new Promise((resolve, reject) => resolve("Promise 2 resolved"));
        const pr3 = new Promise((resolve, reject) => resolve("Promise 3 resolved"));
      
        const prAll = Promise.allSettled([pr1, pr2, pr3]);
        prAll.then((results) => results.forEach(result=>console.log(result)));
      

      Outputs:

        {status: 'rejected', reason: 'Uh oh! Promise 1 did not resolve'}
        {status: 'fulfilled', value: 'Promise 2 resolved'}
        {status: 'fulfilled', value: 'Promise 3 resolved'}
      
  3. Promise.any(iterable)

    • Promise.any() is a static method that takes an iterable of promises as input and returns a single Promise.

    • The returned promise fulfills when any of the input's promises fulfils, with this first fulfillment value.

    • It rejects when all of the input's promises reject (including when an empty iterable is passed), with an AggregateError containing an array of rejection reasons.

      Syntax:

        Promise.any([promise1, promise2, ...])
      

      Example:

        const pr1 = new Promise((resolve, reject) =>
            reject("Uh oh! Promise 1 failed to resolve")
        );
        const pr2 = new Promise((resolve, reject) => resolve("Promise 2 resolved"));
        const pr3 = new Promise((resolve, reject) =>
            resolve("Uh oh! Promise 3 failed to resolve")
        );
        const prAny = Promise.any([pr1, pr2, pr3]);
        prAnl
            .then((result) => console.log(result)) // Promise 2 resolved
            .catch((error) => console.error(error));
      

      If all the promises are rejected:

        const pr1 = new Promise((resolve, reject) => reject("Uh oh! Promise 1 failed to resolve"));
        const pr2 = new Promise((resolve, reject) => resolve("Uh oh! Promise 1 failed to resolve"));
        const pr3 = new Promise((resolve, reject) => resolve("Uh oh! Promise 3 failed to resolve"));
        const prAny = Promise.any([pr1, pr2, pr3]);
        prAny
            .then((result) => console.log(result))
            .catch((error) => console.error(error)); // Uh oh! Promise 1 failed to resolve
      
  4. Promise.race(iterable)

    • Promise.any() is a static method that takes an iterable of promises as input and returns a single Promise.

    • The returned promise settles with the eventual state of the first promise that settles.

      Syntax:

        Promise.race([promise1, promise2, ...])
      

      Example:

        const promise1 = new Promise((resolve) =>
            setTimeout(resolve, 100, "First")        // First promise to be settles
        );
        const promise2 = new Promise((resolve) =>
            setTimeout(resolve, 200, "Second")
        );
      
        Promise.race([promise1, promise2])        // returns promise as soon as promise 1 is settled
            .then((result) => console.log(result)) // "First"
            .catch((error) => console.log(error));
      

      If the first that settles is rejected

        const promise1 = new Promise((resolve, reject) =>
            setTimeout(reject, 100, "First")        // First promise to be settles
        );
        const promise2 = new Promise((resolve) =>
            setTimeout(resolve, 200, "Second")
        );
      
        Promise.race([promise1, promise2])        // returns promise as soon as promise 1 is settled
            .then((result) => console.log(result))
            .catch((error) => console.log(error)); // "First"
      
  5. Promise.resolve(value)

    • Returns a Promise object that is resolved with the given value.

      Syntax:

        Promise.resolve(value);
      

      Example:

        Promise.resolve("Resolved")
            .then((value) => console.log(value)) // "Resolved"
            .catch((error) => console.log(error));
      
  6. Promise.reject(reason)

    • Returns a Promise object that is rejected with the given reason.

      Syntax:

        Promise.reject(reason);
      

      Example:

        Promise.reject("Rejected")
            .then((value) => console.log(value))
            .catch((reason) => console.log(reason)); // "Rejected"
      

Promise chaining

  • Promise chaining refers to the technique of linking multiple .then() and .catch() methods to a single Promise, where each .then() method returns a new Promise.

  • This allows you to handle asynchronous operations one after another, where the output of one operation becomes the input for the next.

    Syntax:

      asyncFunction()
      .then(result => {
          // Handle the result of asyncFunction
          return anotherAsyncFunction(result); // Return a new Promise
      })
      .then(newResult => {
      // Handle the result of anotherAsyncFunction
          return yetAnotherAsyncFunction(newResult); // Return a new Promise
      })
      .then(finalResult => {
          // Handle the final result
          console.log(finalResult);
      })
      .catch(error => {
          // Handle any error that occurred in any of the previous Promises
          console.error('Error:', error);
      });
    

    Example:

      function getUser(id) {
          return new Promise((resolve, reject) => {
              setTimeout(() => {
                  console.log('Fetching user data...');
                  if (id) {
                      resolve({ id, name: 'John Doe' });
                  } else {
                      reject('No user ID provided');
                  }
              }, 1000);
          });
      }
    
      function getUserPosts(user) {
          return new Promise((resolve) => {
              setTimeout(() => {
              console.log(`Fetching posts for user ${user.name}...`);
              resolve(['Post1', 'Post2', 'Post3']);
              }, 1000);
          });
      }
    
      function getPostComments(post) {
          return new Promise((resolve) => {
              setTimeout(() => {
                  console.log(`Fetching comments for post ${post}...`);
                  resolve(['Comment1', 'Comment2']);
              }, 1000);
          });
      }
    
      // Promise chaining
      getUser(1)
          .then(user => getUserPosts(user))
          .then(posts => getPostComments(posts[0]))
          .then(comments => console.log('Comments:', comments))
          .catch(error => console.error('Error:', error));
    

    The above program will produce:

      Fetching user data...
      Fetching posts for user John Doe...
      Fetching comments for post Post1...
      Comments: (2) ['Comment1', 'Comment2']
    

Why promise chaining is necessary

  1. Managing Sequential Asynchronous Operations and

  2. Error handling across multiple operations

    • Promise chaining helps manage asynchronous operations that need to occur in sequence.

    • Chained .catch() handles errors from any part of the promise chain.

      Example:

        getUser(1)
        .then(user => getUserPosts(user)) // will be executed when promise returned by getUser is resolved.
        // getUserPosts(user) will return promise.
        .then(posts => getPostComments(posts[0])) // will be executed when promise returned by getUserPosts(user) is resolved.
        // getPostComments(posts[0]) will return promise.
        .then(comments => console.log('Comments:', comments)) // will be executed when promise returned by getPostComments(posts[0]) is resolved.
        .catch(error => console.error('Error:', error)); // will be executed if any of the above promise is rejected.
      
  3. Improving Readability and Maintainability

    • Chained promises help avoid “callback hell” where nested callbacks can become hard to read and maintain. Promises and chaining provide a linear flow of operations that is easier to understand.

    • Above example with callbacks.

        getUser(1, function (err, user) {
            if (err) throw err;
            getUserPosts(user, function (err, posts) {
                if (err) throw err;
                getPostComments(posts[0], function (err, comments) {
                    if (err) throw err;
                    console.log(comments);
                });
            });
        });
      
  4. Combining Multiple Promises

    • Multiple promises returning from asynchronous operation can be handled using Promise.all() or Promise.race() and then chain additional operations.

    • Example:

        Promise.all([getUser(1), getUser(2)])
        .then(([user1, user2]) => {
        // Both users' data are available
        })
        .catch(error => console.error('Error:', error));
      
  5. Chaining with async and await

    • Using async/await syntax for chaining can make your code look even cleaner:

        async function handleUser() {
            try {
                const user = await getUser(1);
                const posts = await getUserPosts(user);
                const comments = await getPostComments(posts[0]);
                console.log(comments);
            } catch (error) {
                console.error('Error:', error);
            }
        }
      
        handleUser();
      

Error handling in promise

  1. Error thrown inside .then() when there is .catch()

    • Any error occurerd inside .then() will be handled by .catch() placed at end of promise chain.

    • Any subsequent .then() function written between .catch and error thrown will not be executed.

      Example:

        Promise.resolve("Hello")
        .then(data => {                // This function processes the data
            throw new Error("Something went wrong in then");     // An error is thrown
            return processData(data);  // This line is not reached
        })
        .then(result => {            // This function is skipped.
            console.log(result);
        })
        .catch(error => {            // This .catch() handles the error thrown 
            console.error("Caught an error:", error.message);
        });
      
  2. Error thrown inside .then() when there is no .catch()

    • The error causes the Promise to be rejected, but since there is no .catch() block, the rejection is unhandled.

        Promise.resolve("Hello")
        .then(data => {
            // This function processes the data
            throw new Error("Something went wrong in then"); // An error is thrown
            return processData(data);  // This line is not reached
        })
        .then(result => {
            // This function will not run
            console.log(result);
        });
      

      Outputs:

        Uncaught Error Error: Something went wrong in thenAsync/Await
      

Async/Await

  • Asynchronous functions always return a Promise.

      async function getData() {
          return;
      }
      console.log(getData());
    
        Promise {[[PromiseState]]: 'fulfilled', [[PromiseResult]]: undefined, Symbol(async_id_symbol): 83, Symbol(trigger_async_id_symbol): 48}
    

    If any other values is returned except 'Promise', async function will wrap the values in promise and return.

      async function getData() {
          return "Hello";
      }
      console.log(getData());
    
        Promise {[[PromiseState]]: 'fulfilled', [[PromiseResult]]: 'Hello', Symbol(async_id_symbol): 83, Symbol(trigger_async_id_symbol): 48}
    
  • Await is a keyword that can be only be used inside async function.

  • Await pauses execution until the Promise is resolved or rejected.

      async function getData(){
          const p = await /* code */
          console.log(p);
      }
    

    Will display the value of 'p'.

  • Try...catch handles errors.

Chaining with Async/Await

  • Just like Promise.then(), chaining can be done in Async/Await.

      async function process() {
          try {
              const result = await fetchData();
              const processedResult = await processResult(result);
              await saveResult(processedResult);
              console.log("Data saved successfully!");
          } catch (error) {
              console.error(error);
          }
      }
      process();
    

Inversion of control

  1. With callbacks

    • Callbacks are function that are passed as an argument to another function.

    • When a developer defines a function, he passes his callback function to a 3rd party API to be executed.

    • With the 3rd party API, the developer is unaware about following things

      1. A Callback may be called multiple times.

      2. A Callback would never get called.

      3. A Callback may be called synchronously.

    • Consider an example below.

        function fetchData(callback) {
            setTimeout(() => { 
            // External source manages the timing
                callback("Data received");  
                // Control is inverted: the callback handles the result\
            }, 1000);
        }
      
      • Here the callback function is passesd as an argument to the fetchData and later called in setTimeout().

      • The developer do not have control over the callback function, and relies about the functionality of setTimeout().

      • To handle inversion of control, we use promises.

  2. With Promises

    • Promises allow us to not rely on callbacks passed into asynchronous functions to handle asynchronous results.

    • Instead, we can chain Promises for a sequence of asynchronous operations, escaping the Callback hell.

      Example:

        const cart = ["TV", "Washing Machine", "Mini Fridge"];
      
        // With callback
        createOrder(cart, function(orderId){
            proceedWithPayement(orderId);
        });
      
        // with Promise
        const promise = createOrder(cart);
        promise.then((orderId) => {proceedWithPayment(orderId)});
      
      • In the first part of the above example, we are inverting the control to the third-party library or API. We rely on the API to execute our callback

      • In the second part, we are using a Promise object instead of a callback, so as soon as the Promise object gets filled, we can use the ‘then()’ method to call our proceedWithPayment() function. Here we are the ones in control this time and can easily make sure that our function gets called only once.

Comparison of asynchronous methods

Aspect

Callbacks

Promises

Async/Await

Readability

Can lead to deeply nested code.

Better readability with chaining.

Most readable and synchronous-like syntax.

Error Handling

Errors need explicit checks in each callback.

Centralized with .catch().

Centralized with try/catch.

Control Flow

Complex for multiple asynchronous tasks.

Easier chaining of async operations.

Simplest for managing complex async.

Complexity

High complexity for nested callbacks.

Reduced complexity through chaining.

Simplest approach for async tasks.

References:

  1. MDN Web Docs

  2. JavaScript Info

  3. Namaste JavaScript By Akshay Saini

  4. How Promise work in JavaScript by FreeCodeCamp

0
Subscribe to my newsletter

Read articles from Keshav A directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Keshav A
Keshav A