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);
