Async JS: Callbacks to Promises

Saurabh KumarSaurabh Kumar
8 min read

Asynchronous JavaScript (Async JS) is important in today's web development. It lets developers run long tasks without stopping the main thread, keeping the user experience smooth. This blog post will look at two common ways to handle asynchronous tasks in JavaScript: callbacks and promises.

We'll talk about how moving from callbacks to promises can make code simpler and easier to read, and share some interesting facts about how these methods have evolved. Understanding Callbacks A callback is a function given to another function to run after a task is done.

What are Callbacks?

A callback is a function that is passed as an argument to another function and is executed after the completion of that main function. In simple terms, a callback function is called at the end of a task to either deliver results or perform an action. You pass this callback function to the main function, and once the main function completes, it invokes the callback to proceed with the next steps.

Why use Callbacks?

Callbacks are used for managing the outcomes of asynchronous tasks without blocking the program’s execution. Asynchronous tasks, like network requests or database queries, take time to finish. If these tasks were synchronous, the program would halt until they were done, resulting in a sluggish user experience.

With callbacks, though, you can keep the program running while these tasks happen in the background. When the task finishes, the callback function handles the result. This ensures the program stays responsive, enhancing the user experience.

Important Points to Know About Callbacks

1. Asynchronous programming:

Callbacks are used to manage the results of tasks that run in the background, so they don't stop the rest of the program. The program keeps running, and the callback function is called when the task is finished.

2. Non-blocking:

Callbacks enable non-blocking programming, meaning the program doesn't pause and wait for an operation to finish before moving on. This is crucial for boosting the performance and responsiveness of applications.

3. Higher-order functions:

A higher-order function is a function that takes one or more functions as arguments or returns a function as a result. The main function in the examples above is a higher-order function because it takes a callback function as an argument..

4. Anonymous functions:

Anonymous functions are functions without a name and are often used as callbacks. The function given to setTimeout in the first code example is an anonymous function.

5. Closure:

A closure is a function that can still access variables from its outer scope, even after the outer function has finished running. This means the callback function can use variables and data from the main function, even after the main function is done.

For example,

let's implement a simple asynchronous operation:

fetching data from a server. Here's how you might fetch data using a callback:

function fetchData(callback) {

  setTimeout(() => {

    const data = { id: 1, name: 'John Doe' };

    callback(data);

  }, 2000);
}

function handleData(data) {

  console.log('Data received:', data);
}

fetchData(handleData);

In this example: - fetchData simulates an asynchronous operation using setTimeout, which mimics a network request by delaying for 2 seconds. - Once the data is "fetched," it calls the handleData callback, passing the data as an argument

Real-Life Examples

1. Loading images on a website

When you open a website, images might take some time to load, especially if they are big. If images loaded one by one, the website would stop and wait for each image to finish loading before moving on. Using callbacks, images can load in the background, allowing the website to keep loading at the same time.

2. Handling form submissions

When a user submits a form, it takes time to process the data and send it to the server. If the form submission happened one step at a time, the user would have to wait until the data is processed and sent before the form is submitted. With callbacks, the form submission can happen in the background, allowing the user to keep interacting with the form while the data is being processed and sent.

Drawbacks of Callbacks

Callback hell

Callback hell refers to the situation in JavaScript where multiple nested callbacks create complex, deeply indented code, often called the “pyramid of doom.” This structure makes the code difficult to read, debug, and maintain, resulting in poor code quality and scalability issues.

While callbacks are straight forward, they can lead to issues such as callback hell, where callbacks are nested within callbacks, making the code difficult to read and maintain.

Here's an exaggerated example of callback hell:

fetchData((data1) => {

  fetchData((data2) => {

    fetchData((data3) => {

      console.log('Data 3:', data3);

    });

  });

});

This nesting can quickly become difficult to manage, especially in larger applications. A solution is needed to avoid such complexity, leading to the development of promises.

Inversion of Control

Simply put, inversion of control with callbacks happens when the function being called gives control back to the calling function to decide what to do next. This means the called function doesn't control its own process and depends on the caller for the next step. This can make the two functions closely linked, making the code harder to change or test separately.


function fetchData(callback) {
  setTimeout(() => {
    const data = { id: 1, name: 'John Doe' };
    callback(data);
  }, 1000);
}

function processData(data) {
  console.log('Processing data:', data);
}

fetchData(processData);

Introducing Promises

A promise is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value. Promises provide a cleaner way to handle asynchronous operations by allowing you to chain operations.

Creating a Promise

Let’s see how to create the promise in JavaScript:

Here, we created a new promise using the Promise constructor. Inside the promise, there are two functions: resolve and reject. If everything works out, we call resolve and pass the result. If something goes wrong, we call reject and pass an error message.

Let's convert our previous example to use a promise:

function fetchData() {

  return new Promise((resolve) => {

    setTimeout(() => {

      const data = { id: 1, name: 'John Doe' };

      resolve(data);

    }, 2000);

  });

}

// Using the function

fetchData()

  .then(data => {

    console.log('Data received:', data);
  })

  .catch(error => { console.error('Error fetching data:', error); })

Promise Consumers

Promises can be consumed by registering functions using .then and .catch methods.

1. Promise then() Method

Promise method is called when a promise is either completed successfully or fails. It acts as a link that takes data from the promise and processes it.

Parameters: It takes two functions as parameters.

  • The first function runs if the promise is completed successfully and a result is received.

  • The second function runs if the promise fails and an error is received. (It is optional, and there is a better way to handle errors using the catch() method.)

2. Promise catch() Method

Promise catch() Method is used when a promise is rejected or an error happens during execution. It acts as an error handler whenever there's a possibility of an error.

Parameters: It takes one function as a parameter.

  • This function handles errors or promise rejections.

Benefits of Promises

1. Chaining: Promises allow chaining with .then(), making the code more readable and linear.

2. Error Handling: Promises have a standardized way to handle errors using .catch(), which simplifies debugging.

3. Improved Readability: The asynchronous flow is more apparent compared to deeply nested callbacks.

Chaining Promises One of the most powerful features of promises is the ability to chain multiple asynchronous operations.

For example, if you want to fetch multiple pieces of data sequentially:

function fetchData1() {

  return new Promise((resolve) => {

    setTimeout(() => {

      const data = { id: 1, name: 'John Doe' };

      resolve(data);

    }, 2000);

  });

}

function fetchData2() {

  return new Promise((resolve) => {

    setTimeout(() => {

      const data = { id: 2, name: 'Jane Doe' };

      resolve(data);

    }, 2000);

  });

}

fetchData1()

  .then(data1 => {
    console.log('Data 1:', data1);

    return fetchData2();

  })

  .then(data2 => { console.log('Data 2:', data2); })

  .catch(error => { console.error('Error:', error); });

In this chain: - After fetchData1 resolves, fetchData2 is called, further improving readability. - If any function in the chain fails, the error will be caught in the .catch() method

Promise States

Promises can be in one of three states:

- Pending: The initial state; the promise is neither fulfilled nor rejected.

- Fulfilled: The operation completed successfully, and the promise has a resulting value.

- Rejected: The operation failed, and the promise has a reason for the failure.

This state management allows developers to handle asynchronous operations predictably

Async/Await:

Async and Await in JavaScript are used to make working with promises easier. They make asynchronous code look like it's running in order, which improves readability and helps manage complicated asynchronous tasks.

Here's how the previous chaining could be rewritten using async/await:

Benefits of Async/Await

  1. Synchronous Style: Code appears almost synchronous, making it easier to read and understand.

2. Error Handling: Errors can be caught using a simple try/catch block, which is familiar to most developers.

  1. Optimized Flow: It allows for easy logical flow control, making complex sequences of asynchronous operations manageable.

Example: Fetching Multiple Data in Parallel Using Promise.all in conjunction with async/await, you can fetch multiple data points in parallel instead of sequentially, which is crucial for performance when dealing with independent asynchronous operations:

async function fetchData() {

  try {

    const data1 = await fetchData1();

    console.log('Data 1:', data1);

    const data2 = await fetchData2();

    console.log('Data 2:', data2);
  }

  catch (error) {

    console.error('Error:', error);

  }
}

fetchData();

In this example, both fetchData1 and fetchData2 are executed simultaneously, significantly reducing the total waiting time compared to sequential fetching.

Conclusion

In this blog post, we talked about how asynchronous JavaScript has changed from using callbacks to using promises. We pointed out the problems with callbacks and showed how promises provide a simpler way to manage asynchronous tasks.

We also explained async/await, which makes the code easier to read and makes asynchronous programming simpler. Knowing these ideas is important for today's web development, and using promises and async/await can help create cleaner and easier-to-maintain code.

0
Subscribe to my newsletter

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

Written by

Saurabh Kumar
Saurabh Kumar