DEV Community

Chirath Nissanka
Chirath Nissanka

Posted on

Callbacks, promises, and async/await!

Callbacks, promises, and async/await are all related to asynchronous programming.

Asynchronous programming is a crucial concept in modern development, especially for handling tasks like API calls, file reading, database queries, and UI interactions without blocking execution. Here’s a comprehensive list of topics covering all aspects of asynchronous programming:

Callbacks

Functions passed as arguments to other functions, executed after an operation completes.

function fetchData(callback) {
  setTimeout(() => {
    callback("Data received");
  }, 2000);
}
fetchData(console.log); // Logs "Data received" after 2 seconds
Enter fullscreen mode Exit fullscreen mode

Problems

  1. Callback Hell (Nested callbacks, difficult to read and maintain)
  2. Inversion of Control (Less predictable flow)

What is Callback Hell?

Callback Hell refers to the situation where multiple nested callbacks make the code hard to read, maintain, and debug. This happens when dealing with multiple asynchronous operations that depend on each other, leading to deeply nested structures.

Example

Imagine we need to execute multiple async tasks in sequence, such as:

1. Fetch user data from a database.
2. Get the user's orders.
3. Process the payment.
4. Send a confirmation email.
Enter fullscreen mode Exit fullscreen mode

Using callbacks, the code might look like this:

getUser(1, (user) => {
    console.log("User fetched:", user);

    getOrders(user.id, (orders) => {
        console.log("Orders fetched:", orders);

        processPayment(orders, (paymentStatus) => {
            console.log("Payment processed:", paymentStatus);

            sendConfirmationEmail(user.email, (emailStatus) => {
                console.log("Email sent:", emailStatus);
            });
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

This causes the following issues:

  1. Readability Issues → Hard to follow the flow of execution.
  2. Difficult Debugging → Errors are harder to trace in deeply nested code.
  3. Scalability Issues → Adding more async steps makes it worse.
  4. Error Handling Challenges → Each callback needs its own error handling logic.

Why is inversion of control (IoC) bad in callbacks?

In callback-based asynchronous programming, Inversion of Control (IoC) means that we pass a callback to another function, and we lose control over when, how, and even if it gets executed. This can lead to several issues:

  1. Loss of Execution Control
  2. Callback Hell (Nested Callbacks)
  3. Error Handling is Difficult
  4. Hard to Compose and Reuse Functions
  5. Difficult to Handle Multiple Async Operations

We will talk in depth about the architectural challenges in these problems later for system design, but for now, know that these two problems are the main motivation for promises & async/await!

Promises & async/await are solutions to the problems caused by callbacks.

Promises can be implemented in any language, and async/await is just syntactical sugar!

What are promises?

A Promise in JavaScript is an object that represents the eventual completion (or failure) of an asynchronous operation. It acts as a placeholder for a value that isn’t available yet, but will be resolved in the future.

Before Promises, we used callbacks to handle asynchronous code. However, callbacks led to callback hell, making the code difficult to read, debug, and maintain.

Promises solve these problems by:

✅ Making asynchronous code more readable
✅ Providing a cleaner chaining mechanism (.then())
✅ Simplifying error handling (.catch())
✅ Allowing multiple asynchronous tasks to run in parallel (Promise.all())

Closer look into promises

A "promise" is an object that can be in 3 states, at a time.

  1. Pending -> The initial state, waiting for completion
  2. Fulfilled -> The async operation completed successfully
  3. Rejected -> The async operation failed

Once a Promise is settled (fulfilled or rejected), it cannot change its state.

How to create a promise?

A Promise is created using the new Promise constructor, which takes a function with two parameters:

resolve(value): Called when the operation is successful.
reject(error): Called when the operation fails.
Enter fullscreen mode Exit fullscreen mode
const myPromise = new Promise((resolve, reject) => {
    let success = true;
    setTimeout(() => {
        if (success) {
            resolve("Operation successful!");
        } else {
            reject("Operation failed!");
        }
    }, 2000);
});

console.log(myPromise); // Outputs: Promise { <pending> }
Enter fullscreen mode Exit fullscreen mode

Promises in action

To handle the result of a Promise, we use:

.then() → Runs when the promise is fulfilled.
.catch() → Runs when the promise is rejected.
.finally() → Runs regardless of success or failure.

myPromise
    .then((result) => {
        console.log("Success:", result);
    })
    .catch((error) => {
        console.log("Error:", error);
    })
    .finally(() => {
        console.log("Promise completed!");
    });

Enter fullscreen mode Exit fullscreen mode

Now async/await in the next step in this - make the code even readable and short, by removing then/catch chaining in promises.

Async/Await - Syntax Sugar

async function fetchData() {
    try {
        const data = await myPromise;
        console.log("Data:", data);
    } catch (error) {
        console.log("Error:", error);
    } finally {
        console.log("Done!");
    }
}

fetchData();

Enter fullscreen mode Exit fullscreen mode

Benefits of async/await:

✅ Looks like synchronous code (easier to read)
✅ No need for .then() chaining
✅ Error handling with try...catch

Best Practice: Use async/await for modern, clean, and maintainable code.

Thanks for reading - let's talk soon,

Chirath!

Top comments (0)