javascript

Understanding Asynchronous JavaScript

author

Luis Paredes

published

Apr 28, 2023

Asynchronous programming is a key aspect of JavaScript that allows for non-blocking, event-driven programming.

In synchronous programming, the program execution is blocked until the task is completed, while in asynchronous programming, the program can continue to run while waiting for the task to complete.

However, asynchronous programming can also be more complex and harder to debug than synchronous programming, and can also lead to anti-patterns if not understood correctly.

In this article, I'll be explaining asynchronous JavaScript so you can use it confidently and avoid its common pitfalls.

Async Patterns in JavaScript

JavaScript provides several patterns for writing asynchronous code. Let's take a look at the most common ones:

Callbacks

Callbacks are functions that are passed as arguments to other functions and are executed once the main function completes its execution.

This pattern is commonly found when using Node. Let's see a concrete example so that we can understand it better:

const fs = require("fs");

console.log("I get called first");

fs.readFile("example.txt", (err, data) => {
  console.log("I get called third");
});

console.log("I get called second");

In the example, the log statement in the last line gets executed before the log statement inside the callback because this function gets executed asynchronously after the main block of code has finished its execution.

Notice that, while callbacks can be effective for simple tasks, they can quickly become unwieldy for more complex tasks due to the need for multiple nested callbacks, which results in an antipattern known as callback hell.

For instance, if we wanted to modify the previous example so that we create a backup copy of the original example.txt, check that the backup was created, and finally remove the original, we could be left with messy code as this if we were to only use callbacks:

const fs = require("fs");

fs.readFile("example.txt", (err, data) => {
  if (err) throw err;

  fs.writeFile("backup/copy.txt", data, (err) => {
    if (err) throw err;

    fs.readFile("backup/copy.txt", (err, data) => {
      if (err) throw err;

      console.log("example.txt was successfully backed up");

      fs.unlink("example.txt", (err) => {
        if (err) throw err;
      });
    });
  });
});

While this example is a dummy one, it shows how easily we can fall into the antipattern if we're not careful with the way we structure our code.

In the next sections we'll examine some alternatives that can help us make our asynchronous code cleaner.

Promises

Promises provide a more structured way of handling asynchronous operations. A promise represents a value that may not be available yet but will be resolved at some point in the future.

A promise can be in one of three states:

  • pending: when the operation that's executed asynchronously is running
  • fulfilled: if the asynchronous operation was executed and no errors occurred
  • rejected: if the asynchronous operation finished its execution with an error

Notice that, fulfilled and rejected states are also grouped together as settled.

Promises can be chained together using the .then() method to handle the resolved value or the .catch() method to handle errors.

If we were to rewrite the callback hell example using promises, we would be left with something like this:

const fs = require("fs").promises;

fs.readFile("example.txt")
  .then((data) => fs.writeFile("backup/copy.txt", data))
  .then(() => fs.readFile("backup/copy.txt"))
  .then((data) => {
    console.log("example.txt was successfully backed up");
    return fs.unlink("example.txt");
  })
  .catch((err) => {
    console.error(err);
  });

Much cleaner, right? 😉

async/await

async/await is a more recent addition to JavaScript and provides a cleaner way of writing asynchronous code mimicking synchronous code execution.

The keyword async is used to tell JavaScript that a function is running asynchronous code, as follows:

// Using vanilla functions
async function someFunction() {};

// Using arrow functions
const anotherFunction = async () => {};

Async functions always return a promise, and the await keyword is used to wait for the promise to be resolved.

If we were to rewrite the initial example we started with, using an async/await approach, we would be left with something like this:

const fs = require("fs").promises;

console.log("I get called first");

(async () => {
  try {
    const data = await fs.readFile("example.txt");
    console.log("I get called third");
  } catch (err) {
    console.error(err);
  }
})();

console.log("I get called second");

Notice that await can only be used within an async function, save for specific contexts.

Real-world examples

Asynchronous programming is used in a wide range of applications, including:

  • Fetching data from APIs
// The fetch API is one of the most common examples you'll find
fetch(API_URL)
  .then((response) => response.json())
  .then((data) => console.log(data))
  .catch((error) => console.error(error));
  • Posting data to API endpoints
// Axios is a widely used JS library that makes extensive use of async JS
axios
  .post("https://example.com/api/users", data)
  .then((response) => {
    console.log("User created:", response.data);
  })
  .catch((error) => {
    console.error("Error creating user:", error);
  });
  • Making database queries
export const getHandler = async (req, res) => {
  try {
    // Notice that User.findById() returns a promise and because of that
    // we need to await for its result
    const user = await User.findById(req.params.id);

    if (!user) {
      return res.status(404);
    }

    res.send(user);
  } catch (error) {
    res.status(500).send(error);
  }
};
  • Async operations in Node.js

If you find yourself struggling in any of the previous scenarios, chances are that revisiting how the underlying asynchronous execution works can go a long way in helping you to tackle whatever challenge you're dealing with.

Conclusion

Asynchronous programming is an essential aspect of JavaScript and allows for better performance and responsiveness in web applications.

By using the right patterns and techniques, you can write effective and performant asynchronous code. Keep these tips in mind and always strive to write clear, maintainable code that is easy to understand and debug.