DEV Community

Cover image for Introduction to Asynchronous JavaScript
Hana Maruškevičová
Hana Maruškevičová

Posted on

Introduction to Asynchronous JavaScript

We regularly encounter synchronous and asynchronous operations in our daily lives, even if we don't notice it. For example, waiting for your partner to return from work before heading to dinner is a synchronous action—you complete one thing before moving on to the next. On the other hand, an asynchronous action is like job hunting: you submit a job application, but instead of waiting idly for a response, you continue with your day—maybe working on personal projects or polishing your CV for another application.

In programming, synchronous programming follows a "one thing at a time" approach. When you call a function that performs a time-consuming task, the program halts until that task finishes. However, with asynchronous programming, the program can continue running other tasks while the asynchronous operation is still in progress. Once the operation completes, the program is notified and handles the result.

OK, but how does that work in the single-threaded JavaScript?

JavaScript operates on a single-threaded event loop, meaning it can handle only one operation at a time. When it encounters asynchronous operations—such as setTimeout() or fetch()—they are passed to the Web API. Once the asynchronous task is completed, the result is placed in the event queue, and the event loop picks it up when the call stack is clear.

If you're curious about how the event loop works, I recommend watching this JSConf talk.

Why asynchronicity

Synchronous actions can lead to significant issues because the program halts until they are completed. For example, a synchronous file reading operation in Node.js can block other code, resulting in poor performance and a negative user experience:

const fs = require('fs');

// Synchronous file reading
const data = fs.readFileSync('largeFile.txt', 'utf8');

console.log(data);
Enter fullscreen mode Exit fullscreen mode

In this case, while the file is being read, no other code can execute. That´s because .readFileSync() method reads the file synchronously.

By contrast, the asynchronous version allows server to continue handling other requests, while reading the file in the background:

fs.readFile('largeFile.txt', 'utf8', (err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(data);
});
Enter fullscreen mode Exit fullscreen mode

Another example, this time from the client-side JavaScript blocking other code can be a synchronous HTTP request, which can cause the page to freeze temporarily:

function fetchDataSync() {
  var request = new XMLHttpRequest();
  request.open('GET', 'https://api.example.com/data', false); // false makes the request synchronous
  request.send();

  if (request.status === 200) {
    console.log(request.responseText);
  }
}
Enter fullscreen mode Exit fullscreen mode

In this scenario, the browser halts all actions—including user interactions like clicking, typing, and navigating menus—until the HTTP request is complete. This results in a very poor user experience. If the network is slow or the server is unresponsive, this can lead to significant delays, causing the page to freeze for an extended period.

That’s why we utilize a non-blocking asynchronous approach. This method keeps the UI responsive, allowing users to interact with the page while the request is processed in the background. Now you have a clearer understanding of why asynchronous JavaScript is used in certain situations.

In the upcoming chapters, we will take a closer look at the syntax of asynchronous JavaScript, including callbacks, Promises and async/await, which provide cleaner and more efficient ways to handle asynchronous operations.

Callbacks

Callback function is a function passed as an argument to another function. We can use them in asynchronous actions to handle tasks that take time to complete.

We can imagine a real-life analogy of ordering food at a restaurant. The asynchronous task is you placing your order. The callback function is the buzzer you receive as a notification that your food is ready. While your food is being prepared, you can sit and engage in other activities, like chatting with your friends or doom-scrolling through Instagram. The restaurant continues to function during this time (the asynchronous operation is non-blocking). When your food is ready, the buzzer (representing the callback function) takes action by beeping, letting you know your meal is ready.

Like in this example, where we are fetching data from an API using callbacks:

function fetchData(callback) {
  fetch('https://jsonplaceholder.typicode.com/posts/1') // Sample API endpoint
    .then(response => response.json()) // Parse the response as JSON
    .then(data => {
      callback(data); // Call the callback function with the fetched data
    })
    .catch(error => console.error('Error fetching data:', error)); // Handle errors
}

// Callback function to handle the fetched data
function handleData(data) {
  console.log('Received:', data);
}

// Call the fetchData function and pass the callback
fetchData(handleData);
Enter fullscreen mode Exit fullscreen mode

Or more real-life example of a function, that handles opening menu in a browser:

function toggleMenu() {
    const menu = document.getElementById('menu');
    if (menu.style.display === 'none' || menu.style.display === '') {
        menu.style.display = 'block'; // Show the menu
    } else {
        menu.style.display = 'none'; // Hide the menu
    }
}

// Event listener for the button click
document.getElementById('menuButton').addEventListener('click', toggleMenu);
Enter fullscreen mode Exit fullscreen mode

While callbacks are commonly used in the browser to handle user actions (such as clicks and key presses), they can lead to limitations, particularly when you need to chain multiple asynchronous operations. This scenario can result in what is known as callback hell, which can look like this:

fetchData((data1) => {
  processData(data1, (result1) => {
    fetchMoreData(result1, (data2) => {
      processData(data2, (result2) => {
        // And so on...
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

In this example, the nested structure makes the code difficult to read and maintain. This highlights the need for better alternatives for more complex flows.

Promises

Promises provide a powerful way to handle complex asynchronous actions while avoiding the pitfalls of callback hell. A promise represents a value that may not be available yet. It offers a .then() method that allows you to specify a function to be called once the asynchronous operation is completed and the promise is resolved.

Let’s revisit our restaurant analogy. When you place an order, the restaurant gives you a promise: you will either receive your food or a reason why the food is not available (error message).

Using our previous example of fetching data from an API, we can see how promises work in practice. Remember, the JS Fetch API returns a promise. Here’s how it looks with our restaurant analogy in the comments:

function fetchData() {
  return fetch('https://jsonplaceholder.typicode.com/posts/1') // you are placing the order at restaurant for specific dish
    .then(response => {
      if (!response.ok) {
        throw new Error('Network response was not ok'); // kitchen might run out of your dish or there is a problem with your order
      }
      return response.json();  // tasting the dish
    })
    .catch(error => { // there was some problem in your order
      console.error('Error fetching data:', error); // the restaurant cauth on fire or had some other major issue
      throw error;
    });
}

fetchData()
  .then(data => {
    console.log('Received:', data); // Eat the dish
  })
  .catch(error => {
    console.error('Error processing data:', error); // You were not able to eat the dish
  });
Enter fullscreen mode Exit fullscreen mode

Promise states

Promise States: A promise can be in one of three states:

  • Pending: The initial state, meaning the promise is still waiting for the asynchronous operation to complete (you ordered your food).
  • Resolved: The operation completed successfully, and the promise has a resulting value (you have your food).
  • Rejected: The operation failed, and the promise has a reason for the failure (an error - you dont have your food because the restaurant doestn have it or the chef cant make it).

Producer

In this example, the fetchData function acts as the producer. It initiates an asynchronous operation and returns a promise.

Consumers

The code that interacts with the promise is called consumer. These are the two methods, we used above - .then() and .catch(), that specify what to do when promise is resolved or rejected.
We can also use .finally() in both cases - if the promise is resolved or rejected.

Promise constructor

You can create a new promise in JavaScript using the promise constructor. The function passed as a parameter is called the executor function, which contains the asynchronous operation you want to perform. It takes two parameters: resolve (called when the asynchronous operation completes successfully) and reject (called when the operation fails).

In the example below, we simulate an asynchronous operation with setTimeout. The promise is resolved after one second, either with a success message or an error, depending on the value of success.

let promise = new Promise(function(resolve, reject) {
  // Simulating an asynchronous operation
  setTimeout(() => {
    const success = true; // Change this to false to simulate an error
    if (success) {
      resolve('Data fetched successfully!'); // Resolving the promise with a value
    } else {
      reject('Error fetching data'); // Rejecting the promise with an error
    }
  }, 1000); // Simulating a 1 second delay
});

// Consuming the promise
promise
  .then(result => console.log(result)) // Log success message
  .catch(error => console.error(error)); // Log error message
Enter fullscreen mode Exit fullscreen mode

Promises make it easier to handle asynchronous operations in a more manageable way, setting the stage for more advanced asynchronous patterns. In the next chapters, we will explore async/await, which builds on the promise concept to provide an even cleaner syntax for managing asynchronous operations.

Async / await

When dealing with asynchronous code, one powerful approach is to write pseudosynchronous code that describes asynchronous operations in a more straightforward manner. The async/await syntax provides a more comfortable way to handle asynchronous code, making it easier to read and maintain.

When you prepend a function with the async keyword, it means that the function will always return a promise. This simplifies the code and makes it clearer that an asynchronous operation is taking place. For example:

async function example() {
    return "I return a resolved promise." // we could write Promise.resolve("I return a resolved promise") with the same result
}

example().then(console.log) // I return a resolved promise
Enter fullscreen mode Exit fullscreen mode

There is another powerful keyworld await, that works inside an async function and makes JavaScript wait until that promise settles and return its result. It pauses the execution of the async function until the promise settles (either resolves or rejects) and returns its result. This allows you to write code that appears synchronous, making it easier to follow.

Look at the example below:

async function fetchUserData() {
  const response = await fetch('https://jsonplaceholder.typicode.com/users/1'); // waits until the fetch is resolved
  const data = await response.json(); // waits until the response is converted to JSON
  console.log(data);
}

fetchUserData();
Enter fullscreen mode Exit fullscreen mode

error handling

You can use a try...catch block to handle errors effectively in async functions. The try block contains the asynchronous code, and if any promise in that block rejects, the catch block will be triggered. It’s also good practice to check the response.ok property to catch any HTTP errors (like 404 or 500), as these might not throw a rejection by default. You can access the error's message using error.message for more meaningful information:

async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');

    // Check if the response is OK (status 200–299)
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }

    const data = await response.json();
    console.log('Data:', data);
  } catch (error) {
    // Catch network errors, JSON parsing errors, and other issues
    console.error('Error fetching data:', error.message);
  }
}
Enter fullscreen mode Exit fullscreen mode

chaining promises

You can also chain promises to define a sequence of asynchronous operations. This approach helps clarify what happens after each operation completes, as shown in the example below:

fetch('https://jsonplaceholder.typicode.com/posts/1')
  .then(response => response.json())
  .then(data => {
    console.log('Post:', data);
    // Fetch the user based on the post's userId
    return fetch(`https://jsonplaceholder.typicode.com/users/${data.userId}`);
  })
  .then(response => response.json())
  .then(user => {
    console.log('User:', user);
  })
  .catch(error => {
    console.error('Error:', error);
  });
Enter fullscreen mode Exit fullscreen mode

concurrent promises

In cases where the tasks are independent on each other an dont rely on the result of another task, you can execute them all in the same time (concurrently) using Promise.all(). This can significantly improve performance.

In the example below, both requests start at the same time, and you wait for both to finish before moving forward.

async function fetchInParallel() {
  const [posts, users] = await Promise.all([
    fetch('https://jsonplaceholder.typicode.com/posts').then(res => res.json()),
    fetch('https://jsonplaceholder.typicode.com/users').then(res => res.json())
  ]);

  console.log(posts, users);
}

fetchInParallel();
Enter fullscreen mode Exit fullscreen mode

Conclusion

In summary, asynchronous programming is a powerful tool in JavaScript, enabling developers to perform time-consuming tasks without blocking the main thread. We explored various techniques for handling asynchronous operations:

  • Callbacks: Useful but can lead to callback hell when nesting multiple asynchronous functions.
  • Promises: Provide a cleaner approach to handle asynchronous operations while avoiding callback hell. They represent a value that may be available in the future and come with states (pending, resolved, rejected).
  • Async/Await: A syntactic sugar over promises that allows writing asynchronous code in a more synchronous style, making it easier to read and maintain. Combined with try...catch for error handling, it significantly improves code quality and robustness.

Understanding these concepts will empower you to manage asynchronous behavior effectively in your JavaScript applications, enhancing performance and improving user experience.

In the next chapters, we will explore real-world applications of these concepts, diving deeper into practical use cases and best practices to leverage asynchronous programming in various scenarios.

I would appreciate your feedback in the comments.

!Cover Image
Image created using Bing Image Creator powered by DALL·E 3.

Top comments (0)