๐Ÿš€ Understanding JavaScriptโ€™s Callbacks, Promises, and Async/Await: A Guide to Asynchronous Programming ๐Ÿ’ป

Yasin SarkarYasin Sarkar
5 min read

JavaScript, by design, is a single-threaded language ๐Ÿงต. This means it can only execute one task at a time. However, many modern applications require operations like network requests ๐ŸŒ, file handling ๐Ÿ“, or database queries ๐Ÿ—„๏ธ, which can take time to complete. If JavaScript waited for each task to finish before moving to the next, our apps would feel sluggish ๐Ÿข and unresponsive.

To address this, JavaScript offers asynchronous programming solutions โš™๏ธ that allow tasks to run in the background while the main program continues executing ๐Ÿš€. The key tools for handling asynchronous operations are callbacks, promises, and async/await. In this article, weโ€™ll explore how each of these works, where they shine โœจ, and when to use them.


1. Callbacks

๐Ÿค” What is a Callback?

A callback is a function passed as an argument to another function, which is executed after the completion of an asynchronous operation. This allows us to continue executing other parts of the code while waiting for the async task to finish โณ.

Example of a Callback:

function fetchData(callback) {
  setTimeout(() => {
    callback("๐Ÿ“ฆ Data loaded");
  }, 2000);
}

fetchData((data) => {
  console.log(data); // Output after 2 seconds: ๐Ÿ“ฆ Data loaded
});

Here, fetchData takes in a callback and runs it once the data is ready after 2 seconds. Instead of stopping the whole program, it continues running other code and later calls you back with the data ๐Ÿ“ž.

โš ๏ธ The Problems with Callback: Callback Hell

While callbacks are a simple and effective way to handle async code, they can lead to a problem known as callback hell. This occurs when callbacks are nested within each other, resulting in code that is difficult to read and maintain.

function firstTask(callback) {
  setTimeout(() => {
    console.log("First task done");
    callback();
  }, 1000);
}

function secondTask(callback) {
  setTimeout(() => {
    console.log("Second task done");
    callback();
  }, 1000);
}

function thirdTask(callback) {
  setTimeout(() => {
    console.log("Third task done");
    callback();
  }, 1000);
}

firstTask(() => {
  secondTask(() => {
    thirdTask(() => {
      console.log("๐ŸŽ‰ All tasks completed!");
    });
  });
});

You can see how deeply nested and confusing the code becomes ๐Ÿ˜ตโ€๐Ÿ’ซ. Luckily, promises offer a more elegant solution to this problem โœจ.


2. Promises

๐Ÿ”ฎ What is a Promise?

A promise is an object that represents a value that may be available now, in the future, or not at all โณ. It holds the result of an asynchronous operation, indicating whether the operation was successful ๐ŸŽ‰ or if it encountered an error โŒ.

A promise can be in one of three states:

  1. Pending: โณ The operation has not completed.

  2. Fulfilled: ๐ŸŽ‰ The operation completed successfully.

  3. Rejected: โŒ The operation failed.

Example of a Promise:

const fetchData = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve("๐Ÿ“ฆ Data fetched successfully");
    } else {
      reject("โŒ Error fetching data");
    }
  }, 2000);
});

fetchData
  .then((data) => {
    console.log(data); // Output: ๐Ÿ“ฆ Data fetched successfully
  })
  .catch((error) => {
    console.error(error);
  });

Promises allow you to avoid callback hell by using .then() for success and .catch() for handling errors ๐Ÿ› ๏ธ. No more deeply nested callbacks!

๐ŸŒŸ The Power of Promise Chaining

One of the most powerful features of promises is that they can be chained together. This solves the issue of callback hell by flattening the structure.

function firstTask() {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("First task done");
      resolve();
    }, 1000);
  });
}

function secondTask() {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("Second task done");
      resolve();
    }, 1000);
  });
}

function thirdTask() {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("Third task done");
      resolve();
    }, 1000);
  });
}

firstTask()
  .then(secondTask)
  .then(thirdTask)
  .then(() => {
    console.log("๐ŸŽ‰ All tasks completed!");
  });

This keeps the code flat and much easier to read ๐Ÿ“š!

3. Async/Await

๐Ÿš€ What is Async/Await?

Async/Await (Introduced in ES2017) is the modern and clean way to handle asynchronous code. It allows you to write asynchronous code in a synchronous (step-by-step) style without chaining .then() or dealing with callbacks directly.

How Async/Await Works:

  • An async function always returns a promise.

  • The await keyword is used inside an async function to pause execution until the promise resolves.

Example of Async/Await:

async function fetchData() {
  try {
    const data = await new Promise((resolve) => {
      setTimeout(() => {
        resolve("๐Ÿ“ฆ Data loaded");
      }, 2000);
    });
    console.log(data); // Output after 2 seconds: ๐Ÿ“ฆ Data loaded
  } catch (error) {
    console.error("โŒ Error:", error);
  }
}

fetchData();

With async/await, you get the benefit of promises without needing to chain .then() methods. It's easy to follow, just like reading top-to-bottom ๐Ÿ“œ.

Why Use Async/Await?

  1. Cleaner Code ๐Ÿงผ: It reads almost like synchronous code (even though itโ€™s asynchronous). This makes it easier to reason about and understand.

  2. Error Handling ๐Ÿ”ง: Instead of using .catch(), you can handle errors in a try...catch block, which keeps your code neat and organized.

Example of Async/Await with Multiple Tasks:

async function performTasks() {
  await firstTask();
  await secondTask();
  await thirdTask();
  console.log("๐ŸŽ‰ All tasks completed!");
}

performTasks();

async function firstTask() {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("โœ… First task done");
      resolve();
    }, 1000);
  });
}

async function secondTask() {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("โœ… Second task done");
      resolve();
    }, 1000);
  });
}

async function thirdTask() {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("โœ… Third task done");
      resolve();
    }, 1000);
  });
}

Now the asynchronous code looks synchronous and easy to follow ๐ŸŽฏ. Plus, the await keyword keeps everything in a step-by-step order, making it easier to reason about your async flow ๐Ÿšถโ€โ™‚๏ธ.


Conclusion: When to Use Each

  • Callbacks are useful for simple tasks, but when you have more complex operations, they can quickly lead to callback hell ๐Ÿ”ฅ.

  • Promises are more structured and handle asynchronous operations in a cleaner way. They also provide better error handling through the .catch() method.

  • Async/Await is the most modern and readable way to handle asynchronous code, especially when dealing with multiple asynchronous tasks. It offers the simplicity of synchronous code while still working asynchronously.

Understanding how to use these tools effectively will allow you to write better, more efficient, and more readable JavaScript code ๐Ÿ’ป. Whether youโ€™re fetching data from an API or performing file operations, mastering callbacks, promises, and async/await is essential to becoming a proficient JavaScript developer.


0
Subscribe to my newsletter

Read articles from Yasin Sarkar directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Yasin Sarkar
Yasin Sarkar

Front-End Developer. I create dynamic web applications using HTML, Tailwind CSS, JavaScript, React, and Next.js. I share my knowledge on social media to help others enhance their tech skills. An Open Source Enthusiast and Writer.