Step 1: Define a Mock Asynchronous Operation (The Core Problem)

We need a function that simulates a slow task, like fetching data. This function will be the base for all three patterns. We use setTimeout to mimic network latency.
Instruction: Start by creating two basic functions that simulate fetching a User and then fetching their Orders, using the standard callback pattern ((error, result) => ...).


// A simple delay function to simulate network latency
const DELAY = 500;

// 1. Simulates fetching user data using a callback
function loadUser(userId, callback) {
  setTimeout(() => {
    console.log(`[Callback] Fetched user ${userId}.`);
    const user = { id: userId, name: "Alice" };
    callback(null, user); // null for no error
  }, DELAY);
}

// 2. Simulates fetching orders using a callback
function loadOrders(user, callback) {
  setTimeout(() => {
    console.log(`[Callback] Fetched orders for ${user.name}.`);
    const orders = ["Laptop", "Monitor"];
    // Introduce a potential error based on user data
    if (user.id === 999) {
        callback(new Error("User is banned!"), null);
    } else {
        callback(null, orders);
    }
  }, DELAY);
}

Step 2: Demonstrating Callback Hell (The Problem)

Now, explain that running these two tasks sequentially requires nesting, which is the definition of "Callback Hell." Instruction: Write the chained execution using the functions from Step 1. Emphasize how error handling and sequential logic quickly become confusing


// Nested Callbacks: Fetch user, then fetch orders
loadUser(123, (userError, user) => {
  if (userError) {
    return console.error("Error loading user:", userError.message);
  }
 
  loadOrders(user, (orderError, orders) => {
    if (orderError) {
      return console.error("Error loading orders:", orderError.message);
    }
   
    // Final Success Log
    console.log(`\n--- CALLBACK SUCCESS ---`);
    console.log(`Processing Order for: ${user.name}`);
    console.log(`Items: ${orders.join(', ')}`);
  });
});

Step 3: Refactoring to Promises (The Chaining Solution)

Explain that Promises wrap the callback logic, turning the (error, result) structure into a standard chainable object with .then() and .catch(). Instruction: Refactor the loadUser and loadOrders functions to return a new Promise(). The callback (error, result) becomes reject(error) and resolve(result).


// 1. Returns a Promise for user data
function loadUserPromise(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(`[Promise] Fetched user ${userId}.`);
      const user = { id: userId, name: "Bob" };
      resolve(user); // Success
    }, DELAY);
  });
}

// 2. Returns a Promise for orders
function loadOrdersPromise(user) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(`[Promise] Fetched orders for ${user.name}.`);
      // Simulating rejection (failure)
      if (user.id === 999) {
          reject(new Error("User is banned!")); // Failure
      } else {
          const orders = ["Keyboard", "Mouse"];
          resolve(orders); // Success
      }
    }, DELAY);
  });
}

Step 4: Chaining Promises for Clean Flow

Show how the Promise-based functions solve the nesting problem by creating a flat, linear chain of execution using .then(). Instruction: Write the clean, linear execution chain. Highlight the single .catch() block for centralized error handling.


// Chaining Promises: Linear execution
loadUserPromise(456)
  .then(user => {
    // This .then() handles the user result and starts the next promise
    return loadOrdersPromise(user);
  })
  .then(orders => {
    // This .then() receives the orders data from the previous step
    console.log(`\n--- PROMISE SUCCESS ---`);
    console.log(`Items: ${orders.join(', ')}`);
  })
  .catch(error => {
    // Single error handler for the entire chain!
    console.error("PROMISE CHAIN ERROR:", error.message);
  });

Step 5: Introducing Async/Await (The Readability Solution)

Explain that async/await is simply a cleaner way to write the Promise chain, making it look synchronous. Instruction: Define an async function and use await before each Promise call. Explain that await literally pauses the function until the Promise resolves.


// Uses the Promise functions from Step 3
async function processOrderModern(userId) {
  // 1. Wrap the entire block in try/catch for synchronous error handling
  try {
   
    // 2. await pauses here until loadUserPromise resolves
    const user = await loadUserPromise(userId);
    console.log(`[Async/Await] User object ready.`);

    // 3. await pauses again until loadOrdersPromise resolves
    const orders = await loadOrdersPromise(user);
   
    // 4. Final Success Log
    console.log(`\n--- ASYNC/AWAIT SUCCESS ---`);
    console.log(`Processing Order for: ${user.name}`);
    console.log(`Items: ${orders.join(', ')}`);

  } catch (error) {
    // 5. If any await fails, execution jumps here
    console.error("ASYNC/AWAIT ERROR:", error.message);
  }
}

Step 6: Final Execution and Comparison

Show the final call to the modern function. Instruction: Show how the modern function is called, completing the practical demonstration of the evolution.


// Calling the modern function (runs asynchronously)
processOrderModern(789);

// Example of calling the function that is designed to fail (e.g., user 999)
// processOrderModern(999);