intermediate Step 12 of 20

Callbacks and Promises

JavaScript Programming

Callbacks and Promises

JavaScript is single-threaded, meaning it can only execute one piece of code at a time. However, many operations — like fetching data from a server, reading files, or waiting for user input — take time to complete. Asynchronous programming allows JavaScript to start these operations and continue executing other code while waiting for results. Callbacks were the original mechanism for handling async operations, but they lead to deeply nested, hard-to-read code (callback hell). Promises, introduced in ES6, provide a cleaner and more powerful way to manage asynchronous operations.

Callbacks

// A callback is a function passed as an argument to another function
function fetchData(url, onSuccess, onError) {
    // Simulating an async operation
    setTimeout(() => {
        const success = Math.random() > 0.3;
        if (success) {
            onSuccess({ data: "Some data from " + url });
        } else {
            onError(new Error("Failed to fetch"));
        }
    }, 1000);
}

fetchData(
    "/api/users",
    (data) => console.log("Success:", data),
    (err) => console.error("Error:", err.message)
);

// Callback Hell (pyramid of doom)
fetchData("/api/users", (users) => {
    fetchData("/api/posts/" + users[0].id, (posts) => {
        fetchData("/api/comments/" + posts[0].id, (comments) => {
            console.log(comments);  // 3 levels deep — hard to read!
        }, handleError);
    }, handleError);
}, handleError);

Promises

// A Promise represents a value that may be available now, later, or never
// States: pending → fulfilled (resolved) OR rejected

// Creating a Promise
function fetchDataPromise(url) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const success = Math.random() > 0.3;
            if (success) {
                resolve({ data: "Data from " + url });
            } else {
                reject(new Error("Failed to fetch " + url));
            }
        }, 1000);
    });
}

// Using a Promise
fetchDataPromise("/api/users")
    .then(result => {
        console.log("Success:", result.data);
        return result.data;
    })
    .then(data => {
        console.log("Processed:", data);
    })
    .catch(error => {
        console.error("Error:", error.message);
    })
    .finally(() => {
        console.log("Request completed (success or failure)");
    });

// Chaining eliminates callback hell
fetchDataPromise("/api/users")
    .then(users => fetchDataPromise("/api/posts/" + users.data))
    .then(posts => fetchDataPromise("/api/comments/" + posts.data))
    .then(comments => console.log("Comments:", comments))
    .catch(error => console.error("Error in chain:", error.message));

Promise Static Methods

// Promise.all — wait for ALL promises to resolve
const promise1 = fetchDataPromise("/api/users");
const promise2 = fetchDataPromise("/api/posts");
const promise3 = fetchDataPromise("/api/settings");

Promise.all([promise1, promise2, promise3])
    .then(([users, posts, settings]) => {
        console.log("All data loaded:", users, posts, settings);
    })
    .catch(error => {
        console.log("One or more requests failed:", error.message);
    });

// Promise.allSettled — wait for all, regardless of success/failure
Promise.allSettled([promise1, promise2, promise3])
    .then(results => {
        results.forEach(result => {
            if (result.status === "fulfilled") {
                console.log("Success:", result.value);
            } else {
                console.log("Failed:", result.reason.message);
            }
        });
    });

// Promise.race — first to settle wins
Promise.race([promise1, promise2])
    .then(fastest => console.log("Fastest:", fastest));

// Promise.any — first to SUCCEED wins (ignores rejections)
Promise.any([promise1, promise2])
    .then(first => console.log("First success:", first))
    .catch(err => console.log("All failed"));

Practical Promise Patterns

// Timeout wrapper
function withTimeout(promise, ms) {
    const timeout = new Promise((_, reject) =>
        setTimeout(() => reject(new Error("Timeout")), ms)
    );
    return Promise.race([promise, timeout]);
}

withTimeout(fetchDataPromise("/api/slow"), 5000)
    .then(data => console.log(data))
    .catch(err => console.log(err.message));  // "Timeout" if > 5s

// Sequential execution
async function sequential(urls) {
    const results = [];
    for (const url of urls) {
        const result = await fetchDataPromise(url);
        results.push(result);
    }
    return results;
}

// Retry pattern with promises
function fetchWithRetry(url, attempts = 3) {
    return fetchDataPromise(url).catch(err => {
        if (attempts <= 1) throw err;
        console.log(`Retrying... (${attempts - 1} left)`);
        return fetchWithRetry(url, attempts - 1);
    });
}
Pro tip: Always attach a .catch() handler to your promise chains. Unhandled promise rejections are a common source of bugs and can crash Node.js applications. Use Promise.allSettled() when you need all results regardless of individual failures, and Promise.all() when any failure should abort the entire operation.

Key Takeaways

  • Callbacks are functions passed to async operations, but deeply nested callbacks create "callback hell."
  • Promises represent eventual values with three states: pending, fulfilled, and rejected.
  • Chain promises with .then() for sequential operations and .catch() for error handling.
  • Use Promise.all() for parallel execution, Promise.allSettled() when you need all results, and Promise.race() for the fastest response.
  • Always handle promise rejections to avoid silent failures and unhandled rejection warnings.