Skip to main content

Command Palette

Search for a command to run...

Understanding Asynchronous Programming Fundamentals

Published
11 min read
V

I'm developer and technical writer passionate about creating exceptional software solutions and crafting engaging technical content. With a strong background in software development and a flair for effective communication, I bring a unique blend of technical expertise and writing skills to my work.

Introduction

In today's fast-paced world of web development, imagine a scenario using an e-commerce website. Customers are browsing products, adding items to their shopping carts, and making purchase decisions in real-time. To provide a seamless shopping experience, your website needs to communicate with multiple servers, retrieve product details, calculate prices, and update the user interface instantly.

This is where asynchronous programming becomes indispensable. It allows your website to perform these tasks efficiently and concurrently, ensuring that customers can shop without delays or disruptions. In this article which is the first part of this series, we'll delve into the basics of asynchronous programming and explore how it powers such real-world scenarios.

Pre-requisite

Basic Javascript knowledge

What is asynchronous programming?

Asynchronous programming is a technique that allows long tasks to be executed independently without slowing down the main project. It allows you to run more code without waiting for the slow code to finish first. Plus, you can use the results of the slow code as soon as they're ready.

By default, JavaScript executes code in a single thread, meaning that it processes one task at a time in sequential order. This behavior is known as synchronous execution. When you execute a function or a piece of code, JavaScript will wait for that code to finish before moving on to the next instruction.

Example of synchronous code

function synchronousExample() {
  console.log("Start");
  // Simulate a time-consuming task
  function timeConsumingTask() {
    for (let i = 0; i < 1000000; i++) {

    }
  }
  timeConsumingTask(); // This blocks the execution
  console.log("Task Complete");
  console.log("End");
}
synchronousExample();

The timeConsumingTask function causes the subsequent code to pause execution until the loop completes. Only after the loop finishes does the following code execute.

However, JavaScript also provides ways to handle asynchronous operations, which are crucial for tasks that could take time, like network requests, file reading, or timers. Asynchronous operations prevent the main thread from being blocked while waiting for these tasks to complete.

let's use set timeout as an example

//Asynchronous example
console.log("Start");
setTimeout(() => {
  console.log("Delayed log after 2 seconds");
}, 2000);
console.log("End");

Inspect your console, and you'll observe that "Start" was logged initially, followed by "End," and eventually "Delayed after 2 seconds." Although the latter action consumed more time, it didn't hinder the main execution, in line with our earlier explanation.

The need for asynchronous programming?

JavaScript often encounters operations that take time to complete, such as fetching data from a server or reading a file. In traditional synchronous code, these operations would block the entire execution until they are finished, potentially leading to unresponsive applications.

Asynchronous programming addresses this issue by allowing multiple tasks to occur simultaneously, improving application responsiveness. Instead of waiting for an operation to complete, the program can continue executing other tasks while it waits for time-consuming operations.

Managing asynchronous operations

Callbacks

Callbacks are like saying, "Call me back when you're done," to a function. They're functions you pass as arguments to other functions, and they get executed once a specific task has finished. It enables you to specify what should happen once that task is finished without blocking the rest of your code.

Let's see an example of callbacks in action

function getUsers(callback) {
  //Asynchronous task
  setTimeout(() => {
    const usersData = ['John' , 'Mary' , 'Anna'];
    console.log(usersData)
 // Call the callback function and pass the data as an argument
    callback(usersData);
  }, 5000); // Simulate a 2-second delay
}
// Callback function to handle the retrieved data
function handleUsersData(users) {
  console.log('Users retrieved:', users);
}
// Initiate the Asynchronous task and provide the callback
getUsers(handleUsersData);

console.log('Request initiated. Waiting for data...');

The code will display the following in the console: "The asynchronous getUsers function didn't block the rest of the code execution. The last console.log was displayed first, and then, after a 5-second delay, the callback was invoked."

The problem with using Callbacks (Callback Hell)

Callback hell, also known as "Pyramid of Doom," occurs when you have multiple nested callbacks within asynchronous code, leading to code that's hard to read and maintain. Here's a code example that illustrates the problem of callback hell:

let's use our Morning routine as asynchronous tasks in this example


function brushTeeth(callback) {
  setTimeout(() => {
    console.log('Brushed teeth.');
    callback();
  }, 3000); // 3 seconds
}

function haveBath(callback) {
  setTimeout(() => {
    console.log('Had a bath.');
    callback();
  }, 5000); // 5 seconds
}

function combHair(callback) {
  setTimeout(() => {
    console.log('Combed hair.');
    callback();
  }, 2000); // 2 seconds
}

function haveBreakfast() {
  setTimeout(() => {
    console.log('Had breakfast.');
  }, 5000); // 5 seconds
}

brushTeeth(() => {
  haveBath(() => {
    combHair(() => {
      haveBreakfast();
    });
  });
});

We have four asynchronous functions,brushTeeth , haveBath, combHairand haveBreakfast, each simulating a step in a process with a time delay. To ensure that each step executes in sequence, we use nested callbacks, where each step's callback function calls the next step's function.

As more tasks are added, the nesting deepens, resulting in code that's difficult to read and reason about. This structure can become even more complex and error-prone in real-world applications with numerous asynchronous operations

To address these issues, modern JavaScript introduced Promises and the async/await syntax, which provides more structured and readable ways to handle asynchronous code.

Promises

Promises were introduced to JavaScript in the ECMAScript 2015 (ES6) specification. A promise is an object that represents a future value, It encapsulates the result of an asynchronous operation, which may or may not have been completed at the time the promise is created, allowing you to work with the value or handle potential errors once the operation is complete.

A promise has 3 states:

  • Pending: this is the initial state, where the promise is neither fulfilled nor rejected

  • Fulfilled: this is when the operation is completed successfully

  • Rejected: it means the operation failed

How to create a promise

We create a Promise object using the Promise constructor. Inside the constructor, we provide a function with two parameters, the parameters are also functions(callbacks) that are used to control the outcome of promise.These callbacks can be divided into two categories:

  • resolve: It indicates that the asynchronous operation associated with the promise has been completed successfully, and it provides a value as the result of that operation. If resolve is called, the .then() handler associated with the promise will execute, allowing you to work with the resolved value.

  • reject: It is used to signal that a promise should be rejected, indicating that the asynchronous operation encountered an error or failed for some reason. If reject is called, the .catch() handler block with error handling will execute, allowing you to handle the error or rejection reason.

Promise Chaining

Promise chaining is a powerful technique in JavaScript that allows you to execute a sequence of asynchronous operations one after the other in a structured and readable manner. This is accomplished by using the .then() method to chain promises together. Each .then() call returns a new promise, enabling you to create a sequence of asynchronous tasks.

Here's how promise chaining works:

  1. Returning Promises: In a promise chain, each .then() callback function should return a promise. This is crucial for chaining to work correctly. You can return an existing promise or create a new one inside the callback.

  2. Sequential Execution: When one promise in the chain is fulfilled (resolved with a value), the callback provided to its .then() method is executed. This callback can perform some processing and return another promise.

  3. Passing Data: Data can be passed from one .then() to the next by returning a value in the callback of the first .then(). This value becomes the resolved value of the subsequent promise in the chain.

  4. Error Handling: You can use .catch() at the end of the promise chain to handle errors that occur at any point in the chain. If any promise in the chain is rejected (an error occurs), the control jumps directly to the nearest .catch() block.

Let's modify the Morning routine asynchronous task using promises


function brushTeeth() {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('Brushed teeth.');
      resolve();
    }, 3000); // 3 seconds
  });
}

function haveBath() {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('Had a bath.');
      resolve();
    }, 5000); // 5 seconds
  });
}

function combHair() {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('Combed hair.');
      resolve();
    }, 2000); // 2 seconds
}

function haveBreakfast() {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('Had breakfast.');
      resolve();
    }, 5000); // 5 seconds
  });
}

brushTeeth()
  .then(() => haveBath()) //chain haveBath Function
  .then(() => combHair()) // Chain combHair Function
  .then(() => haveBreakfast()); // Chain haveBreakfast Function

When the asynchronous operation completes (after the simulated delay), one of these handlers will be called with either value or reason(error) based on whether the promise was resolved or rejected(check your console).

how did using promises solve the problem of callback hell?

Promises introduced a cleaner, sequential way of writing asynchronous code. The chaining of .then() and .catch() methods allowed developers to structure code more linearly, making it easier to understand and also enhancing error handling.

Let's compare our previous code example

In the callback hell example, we have four asynchronous functions (brushTeeth , haveBath, combHairand haveBreakfast,) that depend on each other. To maintain the order of execution and pass data from one function to another, we nest the callbacks. This nesting can become deeply nested and challenging to read as more asynchronous operations are added.

By using promises, we've eliminated the callback hell problem. The code is more structured, readable, and easier to reason about. The chaining of promises ensures that the asynchronous operations execute in the desired order, without deeply nested callbacks. If an error occurs at any stage, it's efficiently caught by the .catch() handler. Promises significantly improve the maintainability of asynchronous code.

Async/Await

ECMAScript 2017 (ES8) introduced the async and await keywords as syntactical sugar built on top of Promises. Async/await provides a more concise and linear way to work with asynchronous code such as writing asynchronous code that looks similar to synchronous code, which enhances readability and maintainability.

Key points you need to know;

  • To use async/await, you define an asynchronous function using the async keyword.

  • An async function always returns a Promise. The await keyword is used inside an async function to wait for a Promise to resolve.

  • When await is used, the function will pause until the Promise is resolved, and then it will continue executing.

Now let's modify our previous Morning routine asynchronous task using async/await

async function brushTeeth() {
  await new Promise((resolve) => {
    setTimeout(() => {
      console.log('Brushed teeth.');
      resolve();
    }, 3000); // 3 seconds
  });
}

async function haveBath() {
  await new Promise((resolve) => {
    setTimeout(() => {
      console.log('Had a bath.');
      resolve();
    }, 5000); // 5 seconds
  });
}

async function combHair() {
  await new Promise((resolve) => {
    setTimeout(() => {
      console.log('Combed hair.');
      resolve();
    }, 2000); // 2 seconds
}

async function haveBreakfast() {
  await new Promise((resolve) => {
    setTimeout(() => {
      console.log('Had breakfast.');
      resolve();
    }, 5000); // 5 seconds
  });
}

async function doMorningRoutine() {
  await brushTeeth();
  await haveBath();
  await combHair();
  await haveBreakfast();
}

doMorningRoutine();

async/await allows you to write the code in a way that closely resembles synchronous code. Each task is executed sequentially, making it easy to understand and maintain the order of operations. In this case, brushing your teeth will happen before having a bath, and so on.

Unlike the callback-based code, async/await eliminates the need for nesting callback functions. The doMorningRoutine function reads top-down, and each task is awaited one after the other, improving code readability.

Try and Catch

The try...catch statement in JavaScript is used for handling exceptions or errors in your code. It allows you to define a block of code to be tested for errors while providing a block of code to be executed if an error occurs. Here's how it works:

  1. try Block: You start with the try block, which contains the code that might throw an error. The code within the try block is executed sequentially. If no errors occur, it runs to completion without interruption.

  2. catch block: It receives the error object as an argument (usually named error or err) that contains information about the error, including an error message. The code within the catch block is executed if and only if an error occurs within the try block, then the execution of the try block is halted, and the control is transferred immediately to the nearest catch block.

    Here's the doMorningRoutine code with added try...catch blocks to handle potential errors, along with a simulated error:

     async function brushTeeth() {
       try {
         await new Promise((resolve) => {
           setTimeout(() => {
             console.log('Brushed teeth.');
             resolve();
           }, 3000); // 3 seconds
         });
       } catch (error) {
         console.error('Error brushing teeth:', error);
       }
     }
    
     async function haveBath() {
       try {
         await new Promise((resolve) => {
           setTimeout(() => {
    
            console.log('Had a bath.');
             resolve();
           }, 5000); // 5 seconds
         });
       } catch (error) {
         console.error('Error having a bath:', error);
       }
     }
    
     async function combHair() {
       try {
         await new Promise((resolve) => {
           setTimeout(() => {
             console.log('Combed hair.');
             resolve();
           }, 2000); // 2 seconds
         });
       } catch (error) {
         console.error('Error combing hair:', error);
       }
     }
    
     async function haveBreakfast() {
       try {
         await new Promise((resolve, reject) => {
           setTimeout(() => {
             const simulateError = Math.random() < 0.5; // Simulate a random error
             if (simulateError) {
               reject('Error: Breakfast could not be prepared.');
             } else {
               console.log('Had breakfast.');
               resolve();
             }
           }, 5000); // 5 seconds
         });
       } catch (error) {
         console.error('Error having breakfast:', error);
       }
     }
    
     async function doMorningRoutine() {
       try {
         await brushTeeth();
         await haveBath();
         await combHair();
         await haveBreakfast();
       } catch (error) {
         console.error('Morning routine error:', error);
       }
     }
    
     doMorningRoutine();
    

    In this code:

    • Each asynchronous task (brushTeeth, haveBath, combHair, haveBreakfast) is wrapped in a try...catch block to catch and handle errors specific to that task.

    • In the haveBreakfast function, I've simulated an error by introducing a random condition. When the condition is met (simulated error), the Promise is rejected with an error message.

    • In the catch blocks, we log the error message associated with the specific task. If any task encounters an error, the doMorningRoutine function will catch it and log a general "Morning routine error" message.

Async/await simplifies error handling by allowing asynchronous code to use try...catch blocks, providing a more synchronous-like structure for error detection and handling(there is a separate article on error handling in this series, you can check it out).

In conclusion, we've explored the fundamentals of asynchronous programming, essential for modern web development. Let's move to Part 2, where we dive into working with APIs.