DEV Community

Cover image for JavaScript Callbacks, Promises, and Async/Await
eric
eric

Posted on

JavaScript Callbacks, Promises, and Async/Await

Asynchronous programming is a crucial part of JavaScript, especially in modern web development. These methods of asynchronous execution help manage concurrency by allowing asynchronous code to be written in a synchronous style.

Such features in JavaScript makes it easier to handle operations such as network requests and file transfers. JavaScript supports three different approaches for handling asynchronous operations: callbacks, promises, and async/await. In this blog post, we'll dive into each of these approaches, their advantages and disadvantages, and when to use them.

Callbacks

A callback is a function passed as an argument to another function, which is then executed when the task is complete. Callbacks are the most basic way to handle asynchronous programming in JavaScript. In this example, let's imagine getting a sudden craving for your favorite ice cream and you must go order it immediately.

function orderIceCream(flavor, toppings, callback) {
  console.log(`Ordering ${flavor} ice cream with ${toppings}`);
  setTimeout(() => {
    const orderNumber = Math.floor(Math.random() * 1000);
    callback(`Your order number ${orderNumber} is ready!`);
  }, 2000);
}

orderIceCream('mint chocolate chip', ['hot fudge', 'caramel'], (message) => {
  console.log(message);
}); 
// Ordering mint chocolate chip ice cream with hot fudge,caramel
// Your order number # is ready!
Enter fullscreen mode Exit fullscreen mode

The example above takes three arguments: the flavor of ice cream, an array of toppings, and a callback function. The function logs the order details to the console and then simulates a 2-second delay using setTimeout. After the delay, we get a nice message telling us that our order is ready.

But what if you needed to order ice cream for a group of friends? What would the callback functions look like?

orderIceCream(<flavor>, [<topping>, <topping>], (message) => {
  console.log(message);
  orderIceCream(<flavor>, [<topping>, <topping>], (message) => {
    console.log(message);
    orderIceCream(<flavor>, [<topping>, <topping>], (message) => {
      console.log(message);
      orderIceCream(<flavor>, [<topping>, <topping>], (message) => {
        console.log(message);
        // ...and so on, with more nested callbacks
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Congratulations, you've created callback hell! This style of callback function - while still functional - is hard to read and even harder to maintain. 😭

Promises

To escape callback hell we can write the same function to order ice cream in a JavaScript promise. Promises provide a cleaner and more readable way to handle asynchronous operations than callbacks. A promise is an object that represents the result of an asynchronous operation. It has three states: pending, fulfilled, and rejected. Let's order our ice cream with a JavaScript promise:

function orderIceCream(flavor, toppings) {
  return new Promise((resolve, reject) => {
    console.log(`Ordering ${flavor} ice cream with ${toppings}`);
    setTimeout(() => {
      const iceCream = ['vanilla', 'chocolate', 'strawberry'];
      const orderNumber = Math.floor(Math.random() * 1000);
      if (iceCream.includes(flavor)) {
        resolve(`Your order number ${orderNumber} is ready!`);
      } else {
        reject(`Sorry ${flavor} is not available, please reorder.`);
      }
    }, 2000);
  });
}

orderIceCream('vanilla', ['chocolate chips', 'peanuts'])
// Ordering vanilla ice cream with chocolate chips,peanuts
  .then((order1) => {
    console.log(order1);
// Your order number # is ready!
    return orderIceCream('chocolate', 'caramel');
  })
// Ordering chocolate ice cream with caramel
  .then((order2) => {
    console.log(order2);
// Your order number # is ready!
    return orderIceCream('strawberry', 'whip cream');
  })
// Ordering strawberry ice cream with whip cream
  .then((order3) => {
    console.log(order3);
// Your order number # is ready!
    return orderIceCream('mint chocolate chip', 'sprinkles');
  })
// Ordering mint chocolate chip ice cream with sprinkles
  .catch((error) => {
    console.error(error);
  });
// Sorry mint chocolate chip is not available, please reorder.
Enter fullscreen mode Exit fullscreen mode

In this example, the orderIceCream function returns a Promise object that will either resolve with a message indicating that the requested ice cream flavor is available or reject with an error message if the flavor is not available. The Promise is created using the Promise constructor, which takes a function as its argument. This function takes two parameters, resolve and reject, which are used to indicate whether the Promise should resolve or reject.

In the function, the setTimeout method is used to simulate an asynchronous operation that takes 2 seconds to complete. If the requested flavor is available, the Promise is resolved with a message that includes the flavor. Otherwise, the Promise is rejected with an error message. If the Promise is resolved, The then method is used to chain the Promises and handle the responses sequentially. If the Promise is rejected, the catch method will be called with the error, which is true for our last promise chain because they were out of mint chocolate chip 😢

Async/Await

What if there was a better way to write and chain promises and have the function execute in the background? Async/await is here to save the day 👐🏻, providing an even cleaner and more readable way to handle asynchronous operations than promises. Let's try ordering our ice cream with this new format:

function orderIceCream(flavor, toppings) {
  return new Promise((resolve, reject) => {
    console.log(`Ordering ${flavor} ice cream with ${toppings}`);
    setTimeout(() => {
      const iceCream = ["vanilla", "chocolate", "strawberry"];
      const orderNumber = Math.floor(Math.random() * 1000);
      if (iceCream.includes(flavor)) {
        resolve(`Your order number ${orderNumber} is ready!`);
      } else {
        reject(`Sorry ${flavor} is not available, please reorder.`);
      }
    }, 2000);
  });
}

async function orderIceCreamAsync() {
  try {
    const order1 = await orderIceCream("vanilla", [
      "chocolate chips",
      "peanuts",
    ]);
    console.log(order1);
    const order2 = await orderIceCream("chocolate", "caramel");
    console.log(order2);
    const order3 = await orderIceCream("strawberry", "whip cream");
    console.log(order3);
    const order4 = await orderIceCream("mint chocolate chip", "sprinkles");
    console.log(order4);
  } catch (error) {
    console.error(error);
  }
}

orderIceCreamAsync();
Enter fullscreen mode Exit fullscreen mode

Wow, who knew syntactic sugar could make all the difference! 😍 From this example, the orderIceCreamAsyncfunction uses await to pause execution at each asynchronous operation and wait for it to complete before moving on to the next operation. The function returns a Promise that resolves with the final message when the ice cream is available, or rejects with an error message if the ice cream is not available. We get the same results without all the mess, such a great JavaScript feature!

Conclusion
In conclusion, JavaScript provides three approaches to handle asynchronous programming: callbacks, promises, and async/await. Callbacks are an antiquated and basic way to handle asynchronous operations, and can lead to callback hell. Promises provide a cleaner and more readable way to handle asynchronous operations, and they allow you to chain multiple Promises in sequence. Async/await simplifies the process of allowing developers to write asynchronous code that looks and behaves like synchronous code. This makes it easier to read and understand the code, and also makes it easier to handle errors and exceptions.

TLDR: use async/await

Top comments (0)