Academic Block

Callbacks vs Promises Get Certified

For a long time, asynchronous JavaScript was handled almost exclusively with callbacks. While they still have their place, they quickly become hard to manage when operations must be sequenced or composed. Promises were introduced to solve those problems with cleaner chaining, better error handling, and easier composition.

This lesson compares the two approaches side-by-side so you can choose the right tool and convert older callback-based code into modern Promise-based code.

Callbacks – The Classic Approach

A callback is simply a function passed as an argument to be called later, usually when an asynchronous operation finishes.

What this code does: Demonstrates a basic asynchronous operation using a classic error-first callback pattern. Immediate logs are included so you always see output in the editor.

// Classic error-first callback pattern
function fetchUser(userId, callback) {
    // Simulate async work
    setTimeout(function () {
        if (userId <= 0) {
            callback(new Error("Invalid user ID"), null);
        } else {
            callback(null, { id: userId, name: "Alice" });
        }
    }, 50);
}

console.log("Requesting user...");

fetchUser(1, function (err, user) {
    if (err) {
        console.log("Error:", err.message);
        return;
    }
    console.log("User received:", user.name);
});

// Immediate log so the editor shows something right away
console.log("Callback registered (result will appear shortly)");

The Problem: Callback Hell

When several asynchronous steps depend on each other, callbacks nest deeper and deeper, producing hard-to-read “pyramid of doom” code.

What this code does: Shows the classic nested-callback style (callback hell). Immediate logs are added so you can see the structure while the async parts run.

function getUser(id, cb) {
    setTimeout(function () {
        cb(null, { id: id, name: "Alice" });
    }, 40);
}

function getPosts(userId, cb) {
    setTimeout(function () {
        cb(null, ["Post 1", "Post 2"]);
    }, 40);
}

function getComments(postId, cb) {
    setTimeout(function () {
        cb(null, ["Nice!", "Thanks"]);
    }, 40);
}

console.log("Starting nested callbacks...");

getUser(1, function (err, user) {
    if (err) return console.log(err);
    console.log("1. User:", user.name);

    getPosts(user.id, function (err, posts) {
        if (err) return console.log(err);
        console.log("2. Posts:", posts);

        getComments(posts[0], function (err, comments) {
            if (err) return console.log(err);
            console.log("3. Comments:", comments);
            console.log("All nested callbacks finished");
        });
    });
});

console.log("Callbacks registered (watch the order above)");

Promises – A Cleaner Alternative

A Promise is an object that represents a future value. It can be in one of three states: pending, fulfilled, or rejected.

What this code does: Creates a simple Promise and handles both success and failure paths with .then() and .catch(). Immediate logs guarantee you see output in the editor.

function fetchUser(userId) {
    return new Promise(function (resolve, reject) {
        setTimeout(function () {
            if (userId <= 0) {
                reject(new Error("Invalid user ID"));
            } else {
                resolve({ id: userId, name: "Alice" });
            }
        }, 50);
    });
}

console.log("Creating promise...");

fetchUser(1)
    .then(function (user) {
        console.log("Promise resolved → User:", user.name);
    })
    .catch(function (err) {
        console.log("Promise rejected → Error:", err.message);
    });

console.log("Promise handlers attached (result appears next)");

Promise Chaining

Returning a new Promise (or a value) from a .then() handler lets you build flat, readable sequences instead of nested callbacks.

What this code does: Converts the earlier nested-callback example into a clean, linear Promise chain.

function getUser(id) {
    return new Promise(resolve => {
        setTimeout(() => resolve({ id, name: "Alice" }), 80);
    });
}
function getPosts(userId) {
    return new Promise(resolve => {
        setTimeout(() => resolve(["Post 1", "Post 2"]), 80);
    });
}
function getComments(postId) {
    return new Promise(resolve => {
        setTimeout(() => resolve(["Nice!", "Thanks"]), 80);
    });
}

console.log("Starting promise chain...");

getUser(1)
    .then(user => {
        console.log("User:", user.name);
        return getPosts(user.id);
    })
    .then(posts => {
        console.log("Posts:", posts);
        return getComments(posts[0]);
    })
    .then(comments => {
        console.log("Comments:", comments);
        console.log("Chain finished successfully");
    })
    .catch(err => console.log("Error:", err));

console.log("Handlers attached");

Promise.all() – Parallel Operations

When independent asynchronous operations can run at the same time, Promise.all waits for all of them to finish.

What this code does: Runs three independent fetches in parallel and continues only when every Promise has resolved.

function fetchUser() {
    return new Promise(function(resolve) {
        setTimeout(function() {
            resolve({ name: 'Alice', id: 1 });
        }, 100);
    });
}

function fetchPosts() {
    return new Promise(function(resolve) {
        setTimeout(function() {
            resolve(['Post 1', 'Post 2']);
        }, 150);
    });
}

function fetchComments() {
    return new Promise(function(resolve) {
        setTimeout(function() {
            resolve(['Comment 1', 'Comment 2', 'Comment 3']);
        }, 200);
    });
}

console.log('Starting parallel operations...');

Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchComments()
])
.then(function(results) {
    console.log('All operations complete!');
    console.log('User:', results[0]);
    console.log('Posts:', results[1]);
    console.log('Comments:', results[2]);
})
.catch(function(error) {
    console.log('Error:', error);
});

console.log("Promise.all registered");

Promise.race() – First to Complete

Promise.race() resolves or rejects as soon as the first promise settles.

What this code does: Uses Promise.race so the first Promise to settle wins, demonstrating a common concurrency pattern.

// Promise.race example
function fastOperation() {
    return new Promise(function(resolve) {
        setTimeout(function() {
            resolve('Fast operation completed');
        }, 100);
    });
}

function slowOperation() {
    return new Promise(function(resolve) {
        setTimeout(function() {
            resolve('Slow operation completed');
        }, 300);
    });
}

console.log('Racing two operations...');

Promise.race([
    fastOperation(),
    slowOperation()
])
.then(function(result) {
    console.log('Winner:', result);
});

console.log("Race started");

Promise.finally() – Cleanup

The finally() method executes regardless of whether the promise was fulfilled or rejected.

What this code does: Shows Promise.finally(), which runs cleanup code whether the Promise is fulfilled or rejected.

// Promise.finally example
function fetchData(shouldSucceed) {
    return new Promise(function(resolve, reject) {
        setTimeout(function() {
            if (shouldSucceed) {
                resolve('Data loaded');
            } else {
                reject('Failed to load');
            }
        }, 100);
    });
}

console.log('Loading data...');

fetchData(true)
    .then(function(data) {
        console.log('Success:', data);
    })
    .catch(function(error) {
        console.log('Error:', error);
    })
    .finally(function() {
        console.log('Cleanup: Hide loading spinner');
    });

console.log("Handlers attached");

When to Use Callbacks

Callbacks are still useful in certain scenarios:

  • Simple, one-time asynchronous operations
  • Event listeners (click, keypress, etc.)
  • Array methods (map, filter, forEach)
  • When working with older APIs that only support callbacks
  • Performance-critical code where promise overhead matters

What this code does: Highlights situations where callbacks are still the natural choice (array methods, event handlers, simple one-off callbacks).

// Good use cases for callbacks

// Array methods
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(function(num) {
    return num * 2;
});
console.log('Doubled:', doubled);

// Simple one-off operations
setTimeout(function() {
    console.log('Simple timeout callback');
}, 100);

console.log("Array method finished immediately");
console.log("Timeout registered");

When to Use Promises

Promises are better for:

  • Multiple sequential asynchronous operations
  • Parallel asynchronous operations
  • Complex error handling scenarios
  • When you need to return a value from async code
  • Modern APIs (fetch, file system promises, etc.)
  • When using async/await syntax

Promise Anti-Patterns to Avoid

What this code does: Demonstrates an anti-pattern: nesting Promises inside each other instead of returning them and chaining with .then().

// Anti-pattern 1: Nesting promises (defeating the purpose)
function badExample1() {
    return new Promise(function(resolve, reject) {
        asyncOperation1().then(function(result1) {
            asyncOperation2().then(function(result2) {
                // Don't do this! Use chaining instead
                resolve(result2);
            });
        });
    });
}

// Better approach: Chain promises
function goodExample1() {
    return asyncOperation1()
        .then(function(result1) {
            return asyncOperation2();
        })
        .then(function(result2) {
            return result2;
        });
}

// Anti-pattern 2: Not returning promises in chains
function badExample2() {
    asyncOperation1()
        .then(function(result) {
            asyncOperation2(); // Missing return!
        })
        .then(function(result) {
            console.log(result); // undefined!
        });
}

// Better approach: Always return
function goodExample2() {
    return asyncOperation1()
        .then(function(result) {
            return asyncOperation2(); // Return the promise
        })
        .then(function(result) {
            console.log(result); // Correct value
        });
}

function asyncOperation1() {
    return Promise.resolve('result1');
}

function asyncOperation2() {
    return Promise.resolve('result2');
}

console.log('Testing good example:');
goodExample2();

Callbacks vs Promises Comparison

Feature Callbacks Promises
Syntax Function passed as argument Object with then/catch methods
Error Handling Error-first callback pattern Centralized with .catch()
Chaining Leads to callback hell Clean, flat chaining
Composition Difficult Easy with Promise.all/race
Return Values Cannot return values directly Returns promise object
Control Flow Complex to manage Built-in flow control
Debugging Harder to debug Better stack traces
Browser Support All browsers Modern browsers (IE11+)
Performance Slightly faster Minimal overhead
Readability Poor with nesting Excellent

Best Practices

  • Use promises for complex asynchronous flows
  • Use callbacks for simple operations and event handlers
  • Always handle promise rejections with .catch()
  • Return promises from functions for composability
  • Don’t nest promises (use chaining instead)
  • Use Promise.all() for parallel operations
  • Consider async/await for even cleaner code
  • Wrap callback-based APIs when migrating to promises
  • Use .finally() for cleanup operations
  • Avoid mixing callbacks and promises in the same codebase

Common Mistakes

  • Creating callback hell instead of using promises
  • Forgetting to return promises in chain
  • Not handling promise rejections (unhandled rejection warnings)
  • Nesting promises instead of chaining them
  • Creating unnecessary promises (Promise constructor anti-pattern)
  • Not understanding the difference between then() callbacks and regular callbacks
  • Using promises for synchronous operations
  • Forgetting that promises execute immediately when created
  • Not using Promise.all() for parallel operations

Quick Summary

  • Callbacks are functions passed to other functions
  • Promises are objects representing future values
  • Callbacks can lead to callback hell with nested operations
  • Promises provide clean chaining with .then()
  • Promises have better error handling with .catch()
  • Promises can be composed with Promise.all() and Promise.race()
  • Use callbacks for simple operations and event handlers
  • Use promises for complex async flows
  • Promises are the foundation for async/await
  • Modern JavaScript favors promises over callbacks

Try It Yourself

Practice converting callback-based code to promises. Try both approaches and compare.

What this code does: A practice exercise that shows the same sequential data-fetching flow written with callbacks and then rewritten cleanly with Promises.

// Practice: Convert this callback code to promises

// Callback version
function getUserCallback(userId, callback) {
    setTimeout(function() {
        callback(null, { id: userId, name: 'Alice' });
    }, 100);
}

function getPostsCallback(userId, callback) {
    setTimeout(function() {
        callback(null, ['Post 1', 'Post 2']);
    }, 100);
}

console.log('=== Callback Version ===');
getUserCallback(1, function(err, user) {
    if (err) return console.log('Error:', err);
    console.log('User:', user.name);
    
    getPostsCallback(user.id, function(err, posts) {
        if (err) return console.log('Error:', err);
        console.log('Posts:', posts);
    });
});

// Promise version
function getUserPromise(userId) {
    return new Promise(function(resolve) {
        setTimeout(function() {
            resolve({ id: userId, name: 'Alice' });
        }, 100);
    });
}

function getPostsPromise(userId) {
    return new Promise(function(resolve) {
        setTimeout(function() {
            resolve(['Post 1', 'Post 2']);
        }, 100);
    });
}

console.log('');
console.log('=== Promise Version ===');
getUserPromise(1)
    .then(function(user) {
        console.log('User:', user.name);
        return getPostsPromise(user.id);
    })
    .then(function(posts) {
        console.log('Posts:', posts);
    })
    .catch(function(error) {
        console.log('Error:', error);
    });

console.log("Both versions registered");
Tip: While callbacks are still useful for simple operations, promises provide better structure for complex asynchronous code. Master both approaches, but favor promises for maintainable, production-ready code. Consider learning async/await next for even cleaner asynchronous code!

Understanding the difference between callbacks and promises is fundamental to modern JavaScript development. Promises form the foundation for async/await and are the standard way to handle asynchronous operations in modern JavaScript applications.

🧪 Test Your JavaScript Code

Edit the code on the left and click “Run Code” to see the console output on the right.

📝 JavaScript Code
👁 Console Output

Click “Run Code” to see the output here.

Ctrl + Enter to run code Esc to close