DEV Community

Cover image for JavaScript Asynchronous Programming: Concepts and Best Practices
Nzioki Dennis
Nzioki Dennis

Posted on

JavaScript Asynchronous Programming: Concepts and Best Practices

We all have been to a restaurant. Now picture this scenario, a waiter comes to your table to take your order. This waiter adopts a unique approach, where they take your order, deliver it to the kitchen, wait until it is ready, and then serve it to you. But there's a catch, the waiter has to take your order, deliver it to the kitchen and wait until it is ready then serve you, meaning in between they don't do anything else.

Now, let us consider a different scenario with a different waiter. In this case, the waiter takes your order and delivers it to the kitchen but instead of waiting for the chef to prepare the meal, they immediately move on to serve other tables and take additional orders. As they are doing this, back in the kitchen the chef prepares your order, and when ready informs the waiter who then promptly delivers the meal to your table, even if they are currently serving other customers.

Examples one and two can be used to show how synchronous and asynchronous programming work. JavaScript in recent years has seen a surge in popularity. As of 2022, around 98% of websites used JavaScript as a client-side programming language, enabling developers to create highly performant and scalable applications. The effectiveness of the language's ability to handle asynchronous programming has contributed hugely to the language's success.

JavaScript by default is synchronous and single-threaded. Synchronous programming means the execution of operations is done sequentially. an operation cannot be executed until the previous one has been executed. for complex computations, this presents a problem. On the other hand, asynchronous programming is a technique where a computer program handles multiple tasks at the same time than executing them sequentially, independent of the main program flow.

js call stack

Call stack

The call stack keeps track of multiple function calls in a program, itself being a data structure. Every time a function is called, a new frame is added to a call stack. The same happens when the function's execution is ended, with the frame removed from the stack. This is vital in the context of asynchronous programming, as this can help understand how function execution happens. To execute any asynchronous or synchronous code, callbacks have to interact with but are not limited to callback queues and event loops. The following code illustrates this with a simple example:


function greet(name) {
  return `Hello, ${name}!`;
}

function hello() {
  const greeting = greet("Nzioki");
  console.log(greeting);
}

hello();
Enter fullscreen mode Exit fullscreen mode

Output:

Hello, Nzioki!
Enter fullscreen mode Exit fullscreen mode

Code breakdown

  • hello() is called and added to the call stack

  • Now once inside hello(), greet("Nzioki") is called and added also

  • greet("Nzioki") completes, its frame is removed from the stack and control goes back to hello()
    hello() completes and its frame is removed from the stack

Synchronous vs. Asynchronous Execution

Synchronous execution works well for simple and linear operations since tasks are processed sequentially. Each task must complete before another starts execution, which can be an issue for complex operations (e.g. file I/O or network requests. This lead to poor user experience.

Asynchronous execution on the other hand allows execution of tasks independent of each other. In a case where an asynchronous operation is initiated, the main program flow does not wait for it to complete, it continues with other tasks. Once the asynchronous task completes, your program is presented with the result.

Consider a simple synchronous function that performs three tasks sequentially: printing "A," "B," and "C" with a delay of one second between each task.

function synchronousExample() {
  console.log("A");
  console.log("B");
  console.log("C");
}

synchronousExample();
Enter fullscreen mode Exit fullscreen mode

Output:

A
B
C
Enter fullscreen mode Exit fullscreen mode

In this case, the tasks are executed sequentially, and each task must finish before moving on to the next.

Now, let's implement an asynchronous version of the same functionality using setTimeout to introduce a delay. We'll print "A," "B," and "C" with a delay of one second between each task.

function asynchronousExecution() {
  console.log("A");
  setTimeout(() => {
    console.log("B");
  }, 3000);
  setTimeout(() => {
    console.log("C");
  }, 1000);
}

asynchronousExecution();
Enter fullscreen mode Exit fullscreen mode

Output:

A
C
B
Enter fullscreen mode Exit fullscreen mode

In the asynchronous execution, the main thread does not wait for the setTimeout functions to complete their tasks. Instead, it proceeds to the next statement immediately after invoking them. As a result, "A" is printed first, and then after one second, "C" is printed, and finally, another three seconds later, "B" is printed.

Synchronous vs. Asynchronous Execution timelines

To illustrate the difference between synchronous and asynchronous execution visually, consider the following timeline diagrams:

Synchronous Execution Timeline:

Time:    1s       2s       3s
         |        |        |
         |        |        |
Task:    A   ->   B   ->   C
Enter fullscreen mode Exit fullscreen mode

In the synchronous execution, each task blocks the main thread until it completes, creating a sequential flow.

Asynchronous Execution Timeline:

Time:    1s       2s       3s
         |        |        |
         |        |        |
Task:    A   ->   |        |
                |->   C    |
                     |->  B
Enter fullscreen mode Exit fullscreen mode

In the asynchronous execution, tasks are initiated and continue running in the background while the main thread progresses without waiting for them to complete.

1. Callbacks

A callback is a function that is passed as an argument to another function and executed later when invoked. The primary purpose of a callback is to execute code in response to an event. In JavaScript, callbacks allow developers to specify what should happen once the task finishes executing.

Let's consider a simple example where we have a function getUserData that simulates fetching user data from a server asynchronously using setTimeout. We provide a callback function onUserDataFetched to process the result once the data is ready.

function getUserData(callback) {
  setTimeout(() => {
    const user = { id: 001, name: "Nzioki Dennis" };
    callback(user);
  }, 1000);
}

function onUserDataFetched(user) {
  console.log("User data:", user);
}

getUserData(onUserDataFetched);
Enter fullscreen mode Exit fullscreen mode

Output (after 1 second):

User data: { id: 001, name: "Nzioki Dennis" }
Enter fullscreen mode Exit fullscreen mode

In this example, the getUserData function takes a callback function callback as an argument. Inside getUserData, we use setTimeout to simulate an asynchronous operation that takes one second. Once the timeout is complete, we invoke the callback function, passing the user data as an argument.

Callback Hell

Sometimes JavaScript operations grow and require multiple asynchronous operations, which lead to nested callbacks below each other which forms a "pyramid of doom". The reason we call this pyramid of doom is that every callback will have to depend/wait for the previous callback. This affects the maintainability and readability of the code. Here's an example demonstrating callback hell:

function asyncTask1(callback) {
  setTimeout(() => {
    console.log("Task 1 completed");
    callback();
  }, 1000);
}

function asyncTask2(callback) {
  setTimeout(() => {
    console.log("Task 2 completed");
    callback();
  }, 1500);
}

function asyncTask3(callback) {
  setTimeout(() => {
    console.log("Task 3 completed");
    callback();
  }, 500);
}

asyncTask1(() => {
  asyncTask2(() => {
    asyncTask3(() => {
      console.log("All Tasks completed.");
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Output:

Task 1 completed
Task 2 completed
Task 3 completed
All Tasks completed.
Enter fullscreen mode Exit fullscreen mode

How do you avoid callback hell?

  • Use Promises or Async/Await: Embrace Promises or Async/Await for better control over asynchronous operations.

  • Modularize Code: Break down complex tasks into smaller, manageable functions.

  • Use Error Handling: Implement error handling mechanisms to catch and handle errors gracefully.

  • Avoid Nested Callbacks: Refrain from nesting multiple callbacks within each other.

  • Named Functions: Use named functions for callbacks to enhance readability.

  • Separation of Concerns: Keep different functionalities separate to reduce callback nesting.

  • Libraries and Modules: Utilize libraries or modules designed for handling asynchronous operations.

  • Use Control Flow Libraries: Consider using control flow libraries like async.js or functional programming concepts to manage asynchronous flow.

  • Promisify Functions: Convert callback-based functions into Promises for consistent handling.

  • Avoid Anonymous Functions: Use named functions instead of anonymous functions to improve code clarity.

  • Refactor and Simplify: Regularly refactor code to simplify and reduce callback chains.

2. Promises

Promises object represents the eventual completion/failure of an asynchronous operation and its resulting value and can be chained using .then() and .catch() methods, or with the async/await syntax for more structured handling. A promise exists at any time in one of these three states:

  • Pending: When a promise is created it's usually pending.

  • Fulfilled (Resolved): If the asynchronous operation represented by a promise is completed, the promise transitions to a fulfilled state.

  • Rejected: A Promise transitions to a rejected state if the asynchronous operation fails.

Creating a Promise

We utilize the Promise constructor, which accepts a function as a parameter, to create a Promise. The parameters for this function, also known as an executor function, resolve and reject. Inside the executor function, we perform the asynchronous task, and when it completes successfully, we call resolve with the result. If an error occurs, we call reject with an error object.

const myPromise = new Promise((resolve, reject) => {
    // Asynchronous operation
    if (/* operation successful */) {
        resolve(result);
    } else {
        reject(error);
    }
});
Enter fullscreen mode Exit fullscreen mode

Promise Chaining

With the aid of promise chaining, you can carry out several asynchronous operations in succession, with the outcome of one operation serving as the input for the next.

function asyncTask1() {
    return new Promise((resolve, reject) => {
        // ...
    });
}
function asyncTask2(resultFromTask1) {
    return new Promise((resolve, reject) => {
        // ...
    });
}
asyncTask1()
    .then(result1 => asyncTask2(result1))
    .then(finalResult => {
        // Handle final result
    })
    .catch(error => {
        // Handle errors
    });
Enter fullscreen mode Exit fullscreen mode

Promise.all

Promise.all() produces a single promise after accepting a list of promises as input.


const promises = [asyncTask1(), asyncTask2(), asyncTask3()];
Promise.all(promises)
    .then(results => {
        // Handle all results
    })
    .catch(error => {
        // Handle errors
    });
Enter fullscreen mode Exit fullscreen mode

3. Async/Await

Async/await builds on top of Promises and eliminates the need for Promise chaining. The terms "async" and "await" in JavaScript are used together to create asynchronous code that appears more synchronous and readable. An asynchronous function is declared using the "async" keyword, and its execution can be paused using the "await" keyword until a Promise is resolved or rejected. Within an async function, the "await" keyword is used to call functions that return Promises, effectively synchronizing the flow of execution and making the asynchronous code easier to comprehend. The following example shows how Async/await works:

// An asynchronous function that returns a Promise after a delay
function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// An async function using await to pause execution
async function asyncTask() {
    console.log("Task started");
    await delay(2000); // Pause here until the Promise is resolved
    console.log("Task completed");
}

// Call the async function
asyncTask();
Enter fullscreen mode Exit fullscreen mode

Output

Task started
[After a 2-second delay]
Task completed
Enter fullscreen mode Exit fullscreen mode

In the above code snippet, the asyncTask function demonstrates how async/await works. The await keyword pauses the execution of the function until the delay Promise resolves after a 2-second delay.

Handling Errors with Async/Await

Async/await also provides a convenient way to handle errors in asynchronous code. Async/await makes error handling straightforward with the use of try-catch blocks. If an error occurs within the async function, it will be caught in the catch block, allowing for clean and centralized error handling.

Example of Error Handling with Async/Await

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function getUserData() {
  await delay(1000);
  throw new Error("Failed to fetch user data.");
}

async function fetchUserData() {
  try {
    const user = await getUserData();
    console.log("User data:", user);
  } catch (error) {
    console.error("Error fetching user data:", error.message);
  }
}

fetchUserData();
Enter fullscreen mode Exit fullscreen mode

Output

Error fetching user data: Failed to fetch user data.
Enter fullscreen mode Exit fullscreen mode

The getUserData function throws an error. The fetchUserData function catches the error using a try-catch block, allowing us to handle it gracefully.

4. Error Handling in Asynchronous Code

A crucial component of JavaScript's asynchronous programming is error handling. It's crucial to appropriately handle problems when working with asynchronous processes to preserve application stability and give users useful feedback. In this section, we'll explore various error-handling strategies and techniques for dealing with errors in asynchronous code.

1. Using Promises with .catch()

Promises provide a straightforward way to handle errors using the .catch() method. When a Promise is rejected the control is passed to the nearest .catch() block, where you can handle the error.

function fetchUserData() {
  return fetch('https://api.example.com/user')
    .then(response => {
      if (!response.ok) {
        throw new Error('Failed to fetch user data.');
      }
      return response.json();
    })
    .catch(error => {
      console.error('Error fetching user data:', error.message);
    });
}

fetchUserData();
Enter fullscreen mode Exit fullscreen mode

Using the fetch() function, the fetchUserData function retrieves user data from an API. If the response status is not okay (e.g., HTTP status code 404 ), the Promise is rejected with an error using throw new Error(). The. catch() block then catches the error and logs a message to the console.

2. Using async/await with try-catch:

Try-catch blocks make error handling even clearer and easier to understand when using async/await. Inside an async function, you can use try-catch to catch and handle errors that occur during await operations.

async function fetchUserData() {
  try {
    const response = await fetch('https://api.example.com/user');
    if (!response.ok) {
      throw new Error('Failed to fetch user data.');
    }
    const userData = await response.json();
    console.log('User data:', userData);
  } catch (error) {
    console.error('Error fetching user data:', error.message);
  }
}

fetchUserData();
Enter fullscreen mode Exit fullscreen mode

The fetchUserData function uses async/await to fetch user data. If during fetch or JSON parsing an error occurs, it's caught in the try-catch block and logged to the console.

3. Handling Multiple Promises with Promise.all():

When running with multiple asynchronous operations, and all promises are completed, Promise.all() enables you to handle errors collectively by either resolving or rejecting the Promise it returns.

async function fetchDataFromMultipleSources() {
  const [data1, data2, data3] = await Promise.all([
    fetch('https://api.example.com/data1'),
    fetch('https://api.example.com/data2'),
    fetch('https://api.example.com/data3')
  ]);

  const jsonPromises = [data1.json(), data2.json(), data3.json()];
  const [parsedData1, parsedData2, parsedData3] = await Promise.all(jsonPromises);

  console.log('Data 1:', parsedData1);
  console.log('Data 2:', parsedData2);
  console.log('Data 3:', parsedData3);
}
Enter fullscreen mode Exit fullscreen mode

The fetchDataFromMultipleSources fetches data from multiple endpoints using fetch(), and then it waits for all the fetch Promises to resolve using Promise. all(). If any of the fetch operations fail, the Promise returned by Promise.all() will be rejected, and the error will be caught in the catch block if used.

5. Concurrency Model and the Event Loop

Concurrency Model

As discussed earlier, JavaScript by default is a single-threaded language that has one execution thread responsible for handling all tasks. This is the opposite of multi-threaded languages, which concurrently execute independent tasks. Concurrency refers to a system's ability to handle multiple tasks simultaneously, with no need to execute them simultaneously.

So how does JavaScript which is a single-threaded language achieve concurrency? It does this through its non-blocking behavior. Asynchronous operations, such as network requests, file I/O, or timers, are executed in the background while the main thread continues executing the remaining code. A callback function or a resolved Promise alerts the event loop that it can run the related callback after an asynchronous operation is complete.

The Event Loop

A critical part of Javascript's concurrency, event loops work by continuously running as long as the program is executing, and maintaining the application's responsiveness. The event loop follows a specific sequence of phases during each iteration. they are 6 in total;

  1. Timers: The event loop in this phase checks for and handles any scheduled timer callbacks using functions like setTimeout() and setInterval().

  2. I/O Callbacks: Executes I/O-related callbacks, such as network requests, file system operations, or other asynchronous tasks that involve I/O that have been completed.

  3. Waiting / Preparation: In this phase, the event loop waits for new I/O events to be added to the queue. Simply put it, is a phase of internal maintenance

  4. I/O Polling: Checks for I/O events that have occurred since the previous iteration and executes their callbacks.

  5. setImmediate() Callbacks: Also called the check phase, this phase executes setImmediate() callbacks

  6. Close Events: The final phase handles cleanup tasks. It processes callbacks such as close event callbacks, such as closing database connections

Let's consider a simplified illustration of how the event loop operates:

+---------------------------+
|                           |
|                           V
+- Poll ---------------------> Check ------------------> Timers -----------+
|                           ^       |                 |       |            |
|                           |       |                 |       |            |
|                           |       v                 |       v            |
|                           +--- Pending Callbacks --+       +--- Microtasks--+
|                                                                         |
+-------------------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

6. Performance Considerations

JavaScript's asynchronous programming capabilities can significantly improve an application's performance and responsiveness. It also presents several performance-related variables, though, that developers should be aware of to ensure the best possible resource and code execution.

1. Avoiding Unnecessary Asynchronous Operations

While asynchronous operations can be beneficial, unnecessary use can impact performance. When performing tasks that are simple and can be executed synchronously try to avoid using asynchronous calls. Overusing asynchronous operations might add additional complexity and possibly make the codebase more complicated.

2. Minimizing Callback Nesting (Callback Hell)

Excessive usage of callbacks, commonly referred to as "callback hell," can result in difficult-to-read and maintainable code. Asynchronous code must be written in a way that reduces nesting and increases readability. If you want to compress callback chains and improve code organization, think about utilizing Promises or async/await.

3. Throttling and Debouncing Asynchronous Operations

Consider employing throttling or debouncing strategies in instances where many asynchronous operations are initiated (such as managing user input events). Debouncing delays the execution of a function until a certain period of inactivity, whereas throttling restricts the number of times a function can be called within a particular time frame. Debouncing is a programming technique that is used in a variety of situations, including front-end web development, to manage how frequently a specific operation or function is carried out in response to rapid or frequent events, such as user input.

4. Efficient Error Handling
For an application to remain stable, error handling must be done correctly. However, handling errors in callback chains with many levels of nesting might occasionally result in performance overhead. To prevent needless duplication of error handling code while utilizing Promises or async/await, think about centralizing error handling.

5. Optimizing Asynchronous APIs

When designing custom asynchronous APIs, aim for simplicity and ease of use. Well-designed APIs can improve code readability and reduce the potential for errors. Consider providing clear documentation and using intuitive function names to make the API more developer-friendly.

7. Best Practices for Asynchronous Programming in JavaScript

Asynchronous programming can be powerful and efficient when used correctly. However, it can also lead to code complexity and subtle bugs if not handled properly. Here are some best practices to follow when working with asynchronous code in JavaScript:

1. Use Promises or Async/Await

Instead of utilizing conventional callbacks to handle asynchronous actions, use Promises or async/await. By eliminating callback hell and streamlining error handling, they offer a more organized and readable method to work with asynchronous tasks.

2. Handle Errors Gracefully
Always handle errors in asynchronous code. Use .catch() with Promises or try-catch blocks with async/await to catch and handle errors appropriately. By doing this, you can be sure that your application will remain reliable and will give users useful feedback when something goes wrong.

3. Limit the Use of Global Variables
Avoid using global variables to store data that might be modified asynchronously. Instead, use function parameters or closures to pass data between functions. Global variables can lead to race conditions and make code harder to maintain.

4. Throttle and Debounce Asynchronous Calls:
For frequently triggered asynchronous operations (e.g., handling user input), consider throttling or debouncing the function calls to control the rate of execution and prevent excessive resource usage.

5. Use Web Workers for CPU-Intensive Tasks:
For computationally intensive tasks, consider offloading the work to Web Workers. This approach prevents the main thread from becoming unresponsive and provides a better user experience.

6. Avoid Blocking the Event Loop:
Avoid long-running synchronous operations that block the event loop. Asynchronous code should not be used as a way to circumvent expensive synchronous tasks.

7. Optimize Network Requests:
Minimize the number of network requests and optimize their size whenever possible. Consider using techniques like HTTP compression and caching to reduce response times and bandwidth usage.

8. Clean Up After Asynchronous Operations:
Be diligent in cleaning up after asynchronous operations. Close files, release resources, and unsubscribe from event listeners to prevent memory leaks.

9. Test Asynchronous Code:
Write thorough unit tests for asynchronous functions to ensure they behave as expected under various scenarios, including successful execution and error handling.

10. Document Asynchronous APIs:
When designing custom asynchronous APIs, provide clear and concise documentation. Explain the purpose of the API, the expected input, and the structure of the returned data. Well-documented APIs ease integration and improve code readability.

Real-world Examples

Fetching Data from APIs

Let's consider a common scenario where a web application needs to fetch data from multiple APIs and display the combined results to the user. In this example, we'll use the GitHub API to fetch information about a user and their repositories.

async function fetchUserData(username) {
  const userResponse = await fetch(`https://api.github.com/users/${username}`);
  const userData = await userResponse.json();
  return userData;
}

async function fetchUserRepositories(username) {
  const repoResponse = await fetch(`https://api.github.com/users/${username}/repos`);
  const repositories = await repoResponse.json();
  return repositories;
}

async function displayUserDetails(username) {
  try {
    const [userData, repositories] = await Promise.all([
      fetchUserData(username),
      fetchUserRepositories(username)
    ]);

    console.log('User Details:', userData);
    console.log('Repositories:', repositories);
  } catch (error) {
    console.error('Error fetching data:', error.message);
  }
}

displayUserDetails('octocat');
Enter fullscreen mode Exit fullscreen mode

In this example, we have three asynchronous functions: fetchUserData, fetchUserRepositories, and displayUserDetails. Each function uses await to fetch data from the GitHub API, making the code look synchronous and easy to read.

The displayUserDetails function uses Promise.all() to fetch both the user data and repositories concurrently. This approach ensures that both API calls happen simultaneously, improving the overall performance and reducing the waiting time for the user.

Real-time Chat Application

Consider a real-time chat application where users can exchange messages instantly. Such an application requires efficient handling of asynchronous events to provide a seamless and responsive user experience.

// WebSocket server setup using Node.js and ws library
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

// Keep track of connected clients
const clients = new Set();

// WebSocket connection event
wss.on('connection', (ws) => {
  // Add the new client to the set of connected clients
  clients.add(ws);

  // WebSocket message event
  ws.on('message', (message) => {
    // Broadcast the received message to all connected clients
    for (const client of clients) {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(message);
      }
    }
  });

  // WebSocket close event
  ws.on('close', () => {
    // Remove the disconnected client from the set of connected clients
    clients.delete(ws);
  });
});
Enter fullscreen mode Exit fullscreen mode

In this example we use Node.js and the ws library to create a WebSocket server. WebSocket is a communication protocol that enables bidirectional data transfer between a client and a server over a single, long-lived connection.

When a client establishes a WebSocket connection, the server adds it to the client set. Whenever a message is received from any client, the server broadcasts the message to all connected clients (except the sender). This approach ensures that messages are delivered to all participants in real time.

The event-driven nature of WebSocket makes it ideal for building real-time applications, as it allows the server to handle multiple asynchronous events concurrently without blocking the main thread. This results in a highly responsive chat application that delivers messages instantly to all users.

Wrap Up!

Asynchronous programming is a fundamental aspect of JavaScript that allows developers to perform non-blocking operations, making applications more responsive and efficient. By leveraging techniques like Promises and async/await, developers can write clean and organized code that handles asynchronous tasks in a structured manner. Additionally, understanding the event loop and the concurrency model in JavaScript helps developers optimize code execution and resource utilization

Happy Coding

Top comments (0)