JavaScript Asynchronous: Transitioning from Callback Hell to Async and Await

A crucial aspect of crafting a successful web application lies in the ability to execute numerous AJAX calls within a single page. This challenge is a common occurrence in asynchronous programming, and the approach you adopt to handle these asynchronous calls will significantly impact the success of your app, potentially influencing the fate of your entire startup.

The synchronization of asynchronous tasks in JavaScript posed a significant hurdle for a considerable period.

This issue affects both back-end developers utilizing Node.js and front-end developers working with any JavaScript framework. Asynchronous programming constitutes a fundamental part of our daily work, yet its significance is often underestimated, and it may not receive adequate attention at the appropriate stage.

A Concise Overview of Asynchronous JavaScript

The earliest and most straightforward solution emerged in the form of nested functions employed as callbacks. However, this approach led to a phenomenon known as callback hell, a problem that continues to plague numerous applications today.

Subsequently, Promises were introduced. While this pattern significantly enhanced code readability, it fell short of the Don’t Repeat Yourself (DRY) principle. There remained numerous instances where code repetition was necessary to effectively manage the flow of the application. The latest advancement, embodied in the form of async/await JavaScript statements, has finally made asynchronous code in JavaScript as effortless to write and comprehend as any other code segment.

Let’s delve into examples of each solution and contemplate the evolution of asynchronous programming within JavaScript.

To illustrate, our asynchronous JavaScript tutorial will focus on a simple task comprising the following steps:

  1. Authenticate a user by verifying their username and password.
  2. Retrieve the user’s application roles.
  3. Record the user’s application access time.

Method 1: Callback Hell (The Notorious “Pyramid of Doom”)

In the early days, synchronizing these calls was achieved through nested callbacks. This method sufficed for basic asynchronous JavaScript tasks but lacked scalability due to an issue termed callback hell.

Illustration: Asynchronous JavaScript callback hell anti-pattern

The code for these three straightforward tasks would resemble the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const verifyUser = function(username, password, callback){
   dataBase.verifyUser(username, password, (error, userInfo) => {
       if (error) {
           callback(error)
       }else{
           dataBase.getRoles(username, (error, roles) => {
               if (error){
                   callback(error)
               }else {
                   dataBase.logAccess(username, (error) => {
                       if (error){
                           callback(error);
                       }else{
                           callback(null, userInfo, roles);
                       }
                   })
               }
           })
       }
   })
};

Each function receives an argument, another function, which is then invoked with a parameter representing the outcome of the preceding action.

Comprehending even the sentence above could lead to mental contortions. An application riddled with hundreds of such code blocks would inflict even greater torment on the individual tasked with maintaining the code, even if they were the original author.

This example becomes even more convoluted when considering that database.getRoles itself is a function containing nested callbacks.

1
2
3
4
5
6
7
const getRoles = function (username, callback){
   database.connect((connection) => {
       connection.query('get roles sql', (result) => {
           callback(null, result);
       })
   });
};

Beyond the difficulty in maintaining such code, the DRY principle holds no sway in this scenario. Error handling, for instance, is replicated within each function, and the primary callback is invoked from every nested function.

More intricate asynchronous JavaScript operations, like iterating through asynchronous calls, present an even greater challenge. In fact, achieving this with callbacks is far from trivial. This limitation fueled the popularity of JavaScript Promise libraries like Bluebird and Q, as they offered a means to execute common operations on asynchronous requests, a capability the language itself lacked.

This is where native JavaScript Promises enter the picture.

JavaScript Promises

Promises emerged as the logical next step in breaking free from callback hell. While not eliminating callbacks entirely, this method streamlined the chaining of asynchronous functions in JavaScript and [simplified the code, rendering it far more readable.

Illustration: Asynchronous JavaScript Promises diagram

With Promises in place, the code in our asynchronous JavaScript example would transform into something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const verifyUser = function(username, password) {
   database.verifyUser(username, password)
       .then(userInfo => dataBase.getRoles(userInfo))
       .then(rolesInfo => dataBase.logAccess(rolesInfo))
       .then(finalResult => {
           //do whatever the 'callback' would do
       })
       .catch((err) => {
           //do whatever the error handler needs
       });
};

To achieve this level of clarity, all functions employed in the example would need to be Promisified. Let’s examine how the getRoles method could be modified to return a Promise:

1
2
3
4
5
6
7
8
9
const getRoles = function (username){
   return new Promise((resolve, reject) => {
       database.connect((connection) => {
           connection.query('get roles sql', (result) => {
               resolve(result);
           })
       });
   });
};

We’ve adjusted the method to return a Promise, accompanied by two callbacks, and the Promise itself handles the method’s actions. Now, resolve and reject callbacks will be mapped to Promise.then and Promise.catch methods, respectively.

You might observe that the getRoles method internally remains susceptible to the pyramid of doom. This is attributed to the design of database methods, which do not return Promise. If our database access methods also returned Promise, the getRoles method would resemble the following:

1
2
3
4
5
6
7
8
const getRoles = new function (userInfo) {
   return new Promise((resolve, reject) => {
       database.connect()
           .then((connection) => connection.query('get roles sql'))
           .then((result) => resolve(result))
           .catch(reject)
   });
};

Method 3: The Advent of Async/Await

While Promises significantly mitigated the pyramid of doom, we were still reliant on callbacks passed to the .then and .catch methods of a Promise.

Promises paved the path for one of the most elegant enhancements in JavaScript. ECMAScript 2017 introduced syntactic sugar atop Promises in JavaScript through the async and await statements.

These statements empower us to write Promise-based code as if it were synchronous, all without obstructing the main thread, as this code snippet demonstrates:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const verifyUser = async function(username, password){
   try {
       const userInfo = await dataBase.verifyUser(username, password);
       const rolesInfo = await dataBase.getRoles(userInfo);
       const logStatus = await dataBase.logAccess(userInfo);
       return userInfo;
   }catch (e){
       //handle errors as needed
   }
};

Awaiting the resolution of a Promise is permissible only within async functions, necessitating that verifyUser be defined using async function.

However, once this minor adjustment is made, you can await any Promise without needing to modify other methods.

Async JavaScript - The Long-Awaited Fulfillment of a Promise

Async functions represent the next evolutionary stage in asynchronous programming within JavaScript. They contribute to cleaner, more maintainable code. By declaring a function as async, you guarantee that it consistently returns a Promise, eliminating that concern altogether.

Why should you embrace JavaScript async functions starting today? Consider the benefits:

  1. Significantly cleaner and more readable code.
  2. Simplified error handling, leveraging the familiar try/catch mechanism used in synchronous code.
  3. Enhanced debugging experience. Unlike setting breakpoints within .then blocks, which only step through synchronous code, stepping through await calls feels akin to navigating synchronous calls.
Licensed under CC BY-NC-SA 4.0