Asynchronous Programming in JavaScript – Guide for Beginners

As JavaScript developers, asynchronous programming is a crucial concept we need to understand. Many common tasks in JavaScript, like fetching data from an API, reading files, or querying a database, can take an unknown amount of time to complete. We need a way for our code to start a long-running operation in the background and continue executing other tasks while it waits for the operation to finish. That‘s where asynchronous programming comes in.

In this beginner‘s guide, we‘ll dive deep into the world of asynchronous JavaScript programming. By the end, you‘ll have a solid understanding of what async programming is, why we need it, and how to implement it in your own JavaScript code using callbacks, promises, and the async/await syntax. Let‘s get started!

Synchronous vs Asynchronous Programming

To understand asynchronous programming, it helps to first look at its opposite – synchronous programming. In synchronous code, each operation is executed one at a time in the order it appears. An operation must fully complete before the next one starts. It‘s like waiting in line at the grocery store – you can‘t begin checking out until the person in front of you has finished paying.

Here‘s an example of synchronous JavaScript code:

function addSync(x, y) {
  console.log(`Adding ${x} and ${y}...`);
  const start = Date.now();
  while (Date.now() - start < 1000) { } // Pause for 1 second
  return x + y;
}

console.log("Starting...");
const sum1 = addSync(2, 3);
console.log(sum1);
const sum2 = addSync(5, 8);
console.log(sum2);
console.log("Done!");

In this code, we have a addSync function that adds two numbers together. To simulate a long-running operation, it uses a while loop to pause execution for 1 second before returning the sum.

When we run this code, here‘s what gets logged to the console:

Starting...
Adding 2 and 3...
5
Adding 5 and 8...
13
Done!

Notice how each console.log statement executes in order, and the second addSync call doesn‘t start until the first one has fully completed. The total execution time is a little over 2 seconds due to the 1 second pause in each addSync call.

This synchronous behavior can cause issues in real-world JavaScript programs. If a web page makes an API call to fetch some data, and that API call takes a few seconds to return, the entire page will be unresponsive during that time. The user can‘t interact with the page at all until the API call finishes. That‘s a poor user experience.

The solution is asynchronous programming. In asynchronous code, long-running operations can be started in the background and the rest of the program can continue executing without waiting for the operation to complete. Once the operation finishes, the program can be notified and given the result.

Here‘s the same example rewritten to be asynchronous using setTimeout:

function addAsync(x, y, callback) {
  console.log(`Adding ${x} and ${y}...`);
  setTimeout(() => {
    callback(x + y);
  }, 1000);
}

console.log("Starting...");
addAsync(2, 3, (sum1) => {
  console.log(sum1);

  addAsync(5, 8, (sum2) => {  
    console.log(sum2);
    console.log("Done!");
  });
});

Now the addAsync function takes a callback function as a third argument. Inside addAsync, instead of pausing execution with a while loop, we use the setTimeout function to schedule the callback to be invoked after 1000ms (1 second).

Here‘s the output from running this code:

Starting...
Adding 2 and 3...
Adding 5 and 8...
5
13
Done!

The execution flow is quite different this time:

  1. First "Starting…" is logged to the console.
  2. Then the first addAsync call logs "Adding 2 and 3…" and schedules the callback to run after 1 second.
  3. Without waiting for the 1 second to elapse, execution continues to the second addAsync call which logs "Adding 5 and 8…" and schedules another callback for 1 second in the future.
  4. The two scheduled callbacks run after their respective 1 second delays, logging "5" and "13" to the console.
  5. Finally, "Done!" is logged after both operations have completed.

The total execution time is just over 1 second now since the two addAsync operations are executed in parallel. This is the power of asynchronous programming – we can start multiple long-running operations without blocking the rest of the program.

Callback Hell

In the previous example, you may have noticed that we ended up with nested callbacks when calling addAsync inside the callback of another addAsync. This is a common pitfall of asynchronous programming with callbacks called "callback hell".

Callback hell is when you have callbacks nested inside callbacks nested inside callbacks, ad infinitum. The code becomes deeply indented and difficult to read and reason about. Error handling also gets tricky since you have to handle errors at each level of nesting.

Here‘s a more extreme example of callback hell:

asyncOperation1((result1) => {
  asyncOperation2(result1, (result2) => {
    asyncOperation3(result2, (result3) => {
      asyncOperation4(result3, (result4) => {
        asyncOperation5(result4, (result5) => {
          // Do something with result5
        });
      });  
    });
  });
});

Yikes! The nested callback pattern is sometimes called the "pyramid of doom". Good luck debugging that code.

Fortunately, JavaScript has evolved to provide better solutions to callback hell. The two main ones are promises and async/await. We‘ll cover both in the following sections.

Promises

Promises are objects that represent the eventual completion or failure of an asynchronous operation and allow us to chain operations together. Promises were introduced in ES6 and have become the standard way to handle asynchronous code in modern JavaScript.

A promise is in one of three states:

  • Pending: The initial state before the operation completes.
  • Fulfilled: The operation completed successfully.
  • Rejected: The operation encountered an error.

We can create a promise using the Promise constructor, which takes a function called an "executor". The executor function itself takes two arguments, resolve and reject, which are callbacks to signal that the async operation succeeded or failed.

Here‘s an example:

function addPromise(x, y) {
  return new Promise((resolve, reject) => {
    if (typeof x !== "number" || typeof y !== "number") {
      reject("Arguments must be numbers");
    }

    console.log(`Adding ${x} and ${y}...`);
    setTimeout(() => {
      resolve(x + y);
    }, 1000);
  });
}

This addPromise function returns a new promise that will be fulfilled with the sum of x and y after 1 second. If x or y is not a number, the promise will be rejected with an error message.

To use this promise, we can call addPromise and chain .then and .catch methods to specify what should happen when the promise is fulfilled or rejected:

addPromise(2, 3)
  .then((sum) => {
    console.log(sum);
    return addPromise(sum, 4);
  })
  .then((sum2) => {
    console.log(sum2);
  })
  .catch((error) => {
    console.error(error);
  });

If the promise fulfills, the .then callback is invoked with the fulfilled value. If the promise rejects, the .catch callback is invoked with the error reason.

We can even return another promise inside .then, allowing us to chain multiple async operations together without nesting. This is much cleaner than the callback hell pyramid from earlier.

Sometimes we need to run multiple async operations in parallel and wait for all of them to complete. We can use Promise.all for that:

Promise.all([addPromise(2, 3), addPromise(4, 5)])
  .then((sums) => {
    console.log(sums); // [5, 9]
  });

Promise.all takes an array of promises and returns a new promise that fulfills with an array of the fulfillment values when all input promises have fulfilled, or rejects if any input promise rejects.

Async/Await

Promises are great, but dealing with long promise chains can still be a bit cumbersome. That‘s where async and await come in. They allow us to write asynchronous code that looks and behaves more like synchronous code.

Async functions are declared with the async keyword. Inside an async function, we can use the await keyword before a promise to pause execution until that promise settles. The settled value of the promise is then returned.

Here‘s our addition example rewritten to use async/await:

async function addAsync(x, y) {
  if (typeof x !== "number" || typeof y !== "number") {
    throw "Arguments must be numbers";
  }

  console.log(`Adding ${x} and ${y}...`);
  await new Promise((resolve) => setTimeout(resolve, 1000));
  return x + y;
}

async function main() {
  try {
    const sum1 = await addAsync(2, 3);
    console.log(sum1);

    const sum2 = await addAsync(sum1, 4); 
    console.log(sum2);
  } catch (error) {
    console.error(error);
  }
}

main();

The addAsync function is declared as async, allowing us to use await inside it. Instead of explicitly creating a promise, we can simply await any promise. If the promise rejects, an error is thrown which we can catch with a standard try/catch block.

To run multiple async operations in parallel with async/await, we can use Promise.all like before:

async function main() {
  const sums = await Promise.all([addAsync(2, 3), addAsync(4, 5)]);
  console.log(sums); // [5, 9]
}

One common async operation in JavaScript is making HTTP requests to an API using the fetch function. Here‘s an example of using fetch with async/await:

async function getJoke() {
  const response = await fetch("https://api.chucknorris.io/jokes/random");
  const data = await response.json();
  return data.value;
}

async function main() {
  const joke = await getJoke();
  console.log(joke);
}

main();

The getJoke function uses fetch to make a GET request to the Chuck Norris jokes API. The await keyword is used twice – first to wait for the response from the API, and then to wait for the response to be parsed as JSON. Finally, the text of the random joke is returned.

Conclusion

Asynchronous programming is a core concept in JavaScript that all developers need to understand. It allows us to run long operations in the background without blocking the rest of our code.

We‘ve covered the three main ways to handle async code in JavaScript – callbacks, promises, and async/await. While callbacks are the most basic, they can lead to callback hell. Promises provide a cleaner way to chain async operations and handle errors. Async/await builds on promises, making async code even more concise and readable.

Let‘s review the key concepts:

  • JavaScript is single-threaded, but we can achieve asynchronous behavior through the event loop and Web APIs
  • Async operations like timers, network requests, and file I/O are handled by the operating system/browser, allowing our JavaScript to continue executing
  • We can use callbacks to be notified when an async operation completes, but too many nested callbacks leads to callback hell
  • Promises represent the eventual result of an async operation and allow us to chain .then calls to handle the result
  • Async functions allow us to use the await keyword to write async code that looks like synchronous code

I hope this guide has helped you better understand asynchronous programming in JavaScript. It‘s a complex topic, but a critical one to master. With practice, you‘ll be writing clean, efficient async code in no time. Remember, async is awesome!

Similar Posts