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)");
// Classic error-first callback pattern is a comment naming the convention used here: the callback is called as callback(error, result). On failure the first argument is an Error object; on success it is null and the second argument holds the data.function fetchUser(userId, callback) { declares a function with two parameters: the id of the user to look up, and a callback, a function that will be called when the work is finished.// Simulate async work explains the next lines. Real code might call a server or a database; a short timer stands in for that waiting time.setTimeout(function () { asks the browser to run the inner function after a delay, without blocking. fetchUser itself returns immediately; the result will be delivered later, only through the callback.if (userId <= 0) { treats ids of zero or less as invalid, and callback(new Error("Invalid user ID"), null); reports the failure: a new Error object as the first argument, and null as the (missing) result.} else { handles valid ids. callback(null, { id: userId, name: "Alice" }); reports success: null for the error, and a user object as the result.} on line 9 closes the else block. }, 50); closes the timer’s function and sets the delay to 50 milliseconds, ending the setTimeout() call.} ends fetchUser. Line 12 is a blank separator line.console.log("Requesting user..."); runs immediately and synchronously, printing Requesting user.... Line 14 is a blank separator line.fetchUser(1, function (err, user) { starts the lookup for user 1, and passes an inline function as the callback. Its parameters follow the convention: err first, then user.if (err) { checks whether an error was passed. For user 1 it is null, which counts as false, so the block is skipped.console.log("Error:", err.message); would print the error’s message. It runs only on failure.return; would leave the callback early, so the result is never used when there was an error.} closes the if (err) block.console.log("User received:", user.name); runs only when there was no error. About 50 ms later it prints User received: Alice. Note: the Test My Code window shows only what is printed while your code is running, so lines printed later by a timer or a Promise appear in your browser’s developer console (F12) instead of the output panel.}); closes the callback, the argument list and the statement. Line 22 is a blank separator line.console.log("Callback registered (result will appear shortly)"); runs right away, because registering the callback is synchronous. The order of the output is: Requesting user..., then Callback registered (result will appear shortly), and finally User received: Alice.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)");
function getUser(id, cb) { declares a helper that pretends to load a user. id is the user’s id and cb is the callback to call with the result.setTimeout(function () { waits, cb(null, { id: id, name: "Alice" }); then calls the callback with null (no error) and a user object, and }, 40); sets the delay to 40 milliseconds.} ends getUser. Line 6 is a blank separator line.getPosts(userId, cb) follows the same pattern: after 40 ms it calls cb(null, ["Post 1", "Post 2"]) with a list of two posts. Line 12 is a blank separator line.getComments(postId, cb) finishes the set: after 40 ms it calls cb(null, ["Nice!", "Thanks"]) with two comments. Line 18 is a blank separator line.console.log("Starting nested callbacks..."); prints Starting nested callbacks... immediately. Line 20 is blank.getUser(1, function (err, user) { starts the first step. Everything that depends on the user has to be written inside this callback, because the user is not available before it runs.if (err) return console.log(err); prints the error and leaves the callback if the user could not be loaded.console.log("1. User:", user.name); prints 1. User: Alice about 40 ms after the start. Line 24 is a blank separator line.getPosts(user.id, function (err, posts) { needs the user’s id, so it can only be written inside the previous callback. The code moves one indentation level deeper.if (err) return console.log(err); repeats the same error check. Each level needs its own.console.log("2. Posts:", posts); prints the posts array (after about 80 ms in total). Line 28 is a blank separator line.getComments(posts[0], function (err, comments) { uses the first post, so it goes inside the second callback. This is a third layer of nesting.if (err) return console.log(err); is the error check for the third step.console.log("3. Comments:", comments); prints the comments (after about 120 ms in total).console.log("All nested callbacks finished"); prints the final message. Any new step would need yet another level of nesting.}); closes the getComments callback and its call.}); closes the getPosts callback and its call.}); closes the getUser callback and its call. Together, the three closing lines and the growing indentation form the "pyramid of doom" or callback hell. Line 36 is blank.console.log("Callbacks registered (watch the order above)"); runs right after getUser has been called, before any timer fires. The output order is: Starting nested callbacks..., Callbacks registered (watch the order above), then the three numbered lines and the final message. Note: the Test My Code window shows only what is printed while your code is running, so lines printed later by a timer or a Promise appear in your browser’s developer console (F12) instead of the output panel.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)");
function fetchUser(userId) { declares a function with only one parameter. There is no callback: instead, the function returns a Promise, an object that stands for a value that will be available later.return new Promise(function (resolve, reject) { creates the Promise. The function you give it (the executor) receives two functions: resolve to report success and reject to report failure. A Promise starts as pending.setTimeout(function () { stands in for a network delay. The inner function will run after the time on line 9.if (userId <= 0) { treats ids of zero or less as invalid.reject(new Error("Invalid user ID")); settles the Promise as rejected with an Error. This is the Promise version of the error argument in an error-first callback.} else { handles valid ids.resolve({ id: userId, name: "Alice" }); settles the Promise as fulfilled, with the user object as its value.} closes the else block.}, 50); closes the timer’s function and sets the delay to 50 milliseconds.}); closes the executor function and the new Promise(...) call. The Promise is returned at once, while it is still pending.} ends fetchUser. Line 12 is a blank separator line.console.log("Creating promise..."); prints Creating promise... immediately. Line 14 is blank.fetchUser(1) calls the function and gets back a pending Promise. The dot chain on the following lines is attached to it..then(function (user) { registers a function that runs only if the Promise is fulfilled. It receives the resolved value as user. console.log("Promise resolved → User:", user.name); prints Promise resolved → User: Alice, and the }) on line 18 closes the handler. Note: the Test My Code window shows only what is printed while your code is running, so lines printed later by a timer or a Promise appear in your browser’s developer console (F12) instead of the output panel..catch(function (err) { registers a function that runs only if the Promise is rejected (or if a handler before it throws). Its log would print Promise rejected → Error: and the message. It is not triggered here. The }); on line 21 closes it and ends the statement.console.log("Promise handlers attached (result appears next)"); runs right away, because attaching handlers is synchronous. The output order is: Creating promise..., then Promise handlers attached (result appears next), and about 50 ms later the success message. Line 22 is a blank separator line.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");
getUser(id) returns a Promise, so it needs no callback parameter. Inside, an arrow function receives resolve, and setTimeout(() => resolve({ id, name: "Alice" }), 80) resolves the Promise after 80 ms. The short form { id, name: "Alice" } means { id: id, name: "Alice" }. Only resolve is used here, because this example never fails.getPosts(userId) follows the same pattern. After 80 ms the Promise resolves with the list ["Post 1", "Post 2"].getComments(postId) resolves after 80 ms with ["Nice!", "Thanks"]. Line 16 is a blank separator line.console.log("Starting promise chain..."); prints Starting promise chain... immediately. Line 18 is blank.getUser(1) starts the first step and returns a pending Promise. The .then() calls on the next lines are attached to it one after another..then(user => { runs when the user is available. It prints User: Alice and then return getPosts(user.id);. This return is essential: returning a Promise makes the next .then() wait for it. The }) on line 23 closes the handler..then(posts => { receives the posts array. It prints it and returns getComments(posts[0]), a Promise for the comments of the first post. The chain stays flat, with no extra indentation..then(comments => { receives the comments. It prints the comments and then Chain finished successfully..catch(err => console.log("Error:", err)); catches a rejection from any of the steps above. This is far simpler than the three separate error checks of the nested-callback version. Line 33 is a blank separator line.console.log("Handlers attached"); runs immediately. The output order is: Starting promise chain..., Handlers attached, then (80 ms, 160 ms and 240 ms later) User: Alice, the posts, and the comments with Chain finished successfully. Note: the Test My Code window shows only what is printed while your code is running, so lines printed later by a timer or a Promise appear in your browser’s developer console (F12) instead of the output panel.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");
fetchUser() returns a Promise that resolves after 100 ms with the user object { name: 'Alice', id: 1 }. The line }, 100); sets the delay. Line 8 is a blank separator line.fetchPosts() returns a Promise that resolves after 150 ms with the array ['Post 1', 'Post 2']. Line 16 is blank.fetchComments() returns a Promise that resolves after 200 ms with three comments. Line 24 is blank.console.log('Starting parallel operations...'); prints Starting parallel operations... immediately. Line 26 is blank.Promise.all([fetchUser(), fetchPosts(), fetchComments()]) receives an array of three Promises. Calling the three functions starts all three timers at the same moment, so they run in parallel. Promise.all() returns a new Promise that is fulfilled only when all of them are fulfilled, which here is after the slowest one (200 ms), not after 100 + 150 + 200 ms..then(function(results) { receives an array of the three results, in the same order as the input array, not in the order in which they finished.console.log('All operations complete!'); prints All operations complete!.console.log('User:', results[0]); prints the first result, the user object.console.log('Posts:', results[1]); prints the second result, the array of posts.console.log('Comments:', results[2]); prints the third result, the array of comments.}) closes the .then() handler..catch(function(error) { runs if any one of the Promises is rejected, since Promise.all() then rejects immediately. console.log('Error:', error); would print the reason. It is not triggered here.}); closes the catch handler and ends the statement. Line 41 is a blank separator line.console.log("Promise.all registered"); runs immediately. The output order is: Starting parallel operations..., Promise.all registered, and after about 200 ms the four result lines. Note: the Test My Code window shows only what is printed while your code is running, so lines printed later by a timer or a Promise appear in your browser’s developer console (F12) instead of the output panel.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.race example is a comment. Promise.race() settles with the first Promise that settles, whether it resolves or rejects. The result of the others is ignored.fastOperation() returns a Promise that resolves after only 100 ms with the text 'Fast operation completed'. Line 9 is a blank separator line.slowOperation() resolves after 300 ms with 'Slow operation completed'. It is slower, so it will lose the race. Line 17 is blank.console.log('Racing two operations...'); prints Racing two operations... immediately. Line 19 is blank.Promise.race([fastOperation(), slowOperation()]) starts both timers at once, and returns a Promise that adopts the outcome of whichever one settles first..then(function(result) { runs when the race is decided. result is the value of the winner.console.log('Winner:', result); prints Winner: Fast operation completed, because the 100 ms timer finishes first.}); closes the handler and ends the statement. Line 27 is a blank separator line.console.log("Race started"); runs immediately. A common use of Promise.race() is a timeout: race a real request against a timer that rejects. The output order is: Racing two operations..., Race started, and after 100 ms Winner: Fast operation completed. Note: the Test My Code window shows only what is printed while your code is running, so lines printed later by a timer or a Promise appear in your browser’s developer console (F12) instead of the output panel.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");
// Promise.finally example is a comment. finally() is for cleanup work that must happen whether the operation succeeded or failed, such as hiding a loading spinner or closing a connection.function fetchData(shouldSucceed) { declares a function that takes a boolean. The flag lets you choose between the success and the failure outcome.return new Promise(function(resolve, reject) { creates and returns a Promise with its two settling functions.setTimeout(function() { waits before settling.if (shouldSucceed) { chooses the outcome.resolve('Data loaded'); fulfils the Promise with the text 'Data loaded'.} else { handles false.reject('Failed to load'); rejects the Promise with the text 'Failed to load'.} closes the else block.}, 100); closes the timer’s function and sets the delay to 100 milliseconds.}); closes the Promise’s executor function and the new Promise(...) call.} ends fetchData. Line 13 is a blank separator line.console.log('Loading data...'); prints Loading data... immediately. Line 15 is blank.fetchData(true) starts the operation with the flag set to true, so it will succeed. Try false to see the failure path..then(function(data) { runs only when the Promise is fulfilled. It prints Success: Data loaded..catch(function(error) { would run on rejection and print Error: and the reason. It is skipped here, because the Promise was fulfilled..finally(function() { runs after the then or catch, whichever happened. It prints Cleanup: Hide loading spinner. A finally handler receives no argument, and does not change the result passed along the chain.console.log("Handlers attached"); runs immediately. The output order is: Loading data..., Handlers attached, and after 100 ms Success: Data loaded and Cleanup: Hide loading spinner. Note: the Test My Code window shows only what is printed while your code is running, so lines printed later by a timer or a Promise appear in your browser’s developer console (F12) instead of the output panel.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");
// Good use cases for callbacks is a comment. Not every task needs a Promise: callbacks are still ideal for the array methods, event handlers and simple one-off actions shown here. Line 2 is a blank separator line.// Array methods labels the first group. Array methods such as map, filter and forEach are built around callbacks.const numbers = [1, 2, 3, 4, 5]; creates an array of five numbers.numbers.map(function(num) { calls map() with a callback that runs synchronously once for each element. return num * 2; returns the double of each number, and }); closes the callback. The result is a new array stored in doubled. No Promise is needed.console.log('Doubled:', doubled); prints the array [2, 4, 6, 8, 10] right away. Line 9 is a blank separator line.// Simple one-off operations labels the second group.setTimeout(function() { takes a callback and a delay. console.log('Simple timeout callback'); runs after the 100 ms on line 13. For a single delayed action, a callback is clearer than wrapping a Promise around it. The delayed message appears about 100 ms later. Note: the Test My Code window shows only what is printed while your code is running, so lines printed later by a timer or a Promise appear in your browser’s developer console (F12) instead of the output panel.console.log("Array method finished immediately"); prints right away, because the array callback ran synchronously. Line 14 is a blank separator line.console.log("Timeout registered"); also prints right away, before the timer fires. This shows the contrast: the array callback finished before this line, while the timeout callback is still waiting. The output order is: Doubled: ..., Array method finished immediately, Timeout registered and later Simple timeout callback.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();
// Anti-pattern 1: Nesting promises (defeating the purpose) names the first mistake. Putting a .then() inside another .then() brings back the pyramid that Promises were meant to remove.function badExample1() { returns new Promise(function(resolve, reject) {. Wrapping an existing Promise chain in another new Promise is unnecessary, because the inner calls already produce Promises.asyncOperation1().then(function(result1) { starts the first operation, and asyncOperation2().then(function(result2) { starts the second one inside the first handler. That is one level of nesting too many.resolve(result2); passes the final value out to the wrapper Promise by hand.}); closes the inner .then(), and the second }); on line 9 closes the outer one.}); closes the Promise executor, and } ends the function.// Better approach: Chain promises introduces the fixed version.function goodExample1() { returns asyncOperation1() directly, with no wrapper Promise, and the .then() calls follow on the next lines..then(function(result1) { runs when the first operation is done, and return asyncOperation2(); returns the next Promise, so the following .then() waits for it. The }) on line 18 closes the handler..then(function(result2) { receives the result of the second operation, and return result2; passes it on. The }); on line 21 ends the chain. The code stays flat.} ends the function. Line 23 is a blank separator line.// Anti-pattern 2: Not returning promises in chains names the second mistake: forgetting return inside a .then().function badExample2() { starts a chain with asyncOperation1(). (It does not return the chain either, so the caller cannot wait for it.).then(function(result) { calls asyncOperation2(); but does not return it, as the comment // Missing return! points out. The chain therefore does not wait for it, and passes undefined to the next step..then(function(result) { receives undefined, so console.log(result); prints undefined, as the comment says. This is a very common silent bug.} ends the function. Line 34 is a blank separator line.// Better approach: Always return introduces the fixed version.function goodExample2() { returns the chain beginning with asyncOperation1(), so the caller can wait for it..then(function(result) { now uses return asyncOperation2();, which hands the Promise on to the next step (the comment says Return the promise)..then(function(result) { receives the real value, 'result2', so console.log(result); prints it (the comment says Correct value).} ends the function. Line 45 is a blank separator line.function asyncOperation1() { returns Promise.resolve('result1'), which creates a Promise that is already fulfilled with 'result1'. It is a stand-in for real asynchronous work.function asyncOperation2() { does the same, returning an already-fulfilled Promise with 'result2'. Line 49 is a blank separator line.console.log('Testing good example:'); prints Testing good example: immediately. Line 53 is a blank separator line.goodExample2(); runs the correct chain. Promise handlers run after the current code finishes, so result2 is printed just after this line. Note: the Test My Code window shows only what is printed while your code is running, so lines printed later by a timer or a Promise appear in your browser’s developer console (F12) instead of the output panel.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");
// Practice: Convert this callback code to promises is a comment. The same data flow is written twice, so that you can compare the two styles. Line 2 is a blank separator line.// Callback version labels the first half of the example.getUserCallback(userId, callback) uses the error-first style. After 100 ms it calls callback(null, { id: userId, name: 'Alice' }): no error, and a user object.getPostsCallback(userId, callback) delivers an array of two posts the same way, after 100 ms. Line 9 is a blank separator line.console.log('=== Callback Version ==='); prints a heading to separate the two outputs. Line 15 is a blank separator line.getUserCallback(1, function(err, user) { starts the first step, with an inline callback.if (err) return console.log('Error:', err); prints the error and leaves, if the user could not be loaded.console.log('User:', user.name); prints User: Alice. Line 20 is a blank line (it contains only spaces).getPostsCallback(user.id, function(err, posts) { needs the user’s id, so it is nested inside the first callback.if (err) return console.log('Error:', err); is the error check for the second step.console.log('Posts:', posts); prints the posts array.}); closes the getPostsCallback callback and its call.}); closes the getUserCallback callback and its call. Line 26 is a blank separator line.// Promise version labels the second half of the example.getUserPromise(userId) returns a Promise that resolves after 100 ms with the same user object. It takes no callback, and uses only resolve because this example never fails. Line 35 is a blank separator line.getPostsPromise(userId) returns a Promise that resolves after 100 ms with the array of posts. Line 43 is a blank separator line.console.log(''); prints an empty line, to separate the two outputs.console.log('=== Promise Version ==='); prints the second heading.getUserPromise(1) starts the first step and returns a pending Promise..then(function(user) { prints User: Alice and then return getPostsPromise(user.id);. Returning the next Promise is what keeps the steps sequential without nesting. The }) on line 50 closes the handler..then(function(posts) { receives the posts, and console.log('Posts:', posts); prints them..catch(function(error) { handles a rejection from either Promise with a single handler, and console.log('Error:', error); would print the reason. It is not triggered here.}); closes the catch handler and ends the chain. Line 57 is a blank separator line.console.log("Both versions registered"); runs immediately. The Promise version stays flat, shares one error handler, and is the style modern code prefers for multi-step asynchronous work. After about 100 ms and 200 ms the delayed lines of both versions appear. Note: the Test My Code window shows only what is printed while your code is running, so lines printed later by a timer or a Promise appear in your browser’s developer console (F12) instead of the output panel.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.
Click “Run Code” to see the output here.
Code Explanation
The complete example stays visible. Use Previous and Next to move through its logical blocks.
Select Next to begin exploring the code.