Promises vs callbacks
The Short Answer
Callbacks are functions passed to other functions to be called later when an async operation completes. Promises are objects that represent the eventual result of an async operation — they give you a structured way to handle success and failure. Promises solve the readability, error handling, and composability problems that make callbacks painful at scale.
How Callbacks Work
A callback is simply a function you pass as an argument to another function. The receiving function calls it when the work is done. The Node.js convention is "error-first" — the first parameter is always the error (or null if successful), and the second is the result.
// Error-first callback pattern (Node.js convention)
function readFile(
path: string,
callback: (error: Error | null, data: string | null) => void
) {
// ... async work happens ...
// On success:
callback(null, fileContents);
// On failure:
callback(new Error('File not found'), null);
}
// Usage
readFile('/config.json', (error, data) => {
if (error) {
console.error('Failed:', error.message);
return;
}
console.log('Got data:', data);
});
This works fine for a single operation. The problem emerges when you need to chain multiple async operations that depend on each other.
The Callback Hell Problem
When async operations depend on each other (fetch user → fetch their orders → fetch order details), callbacks nest inside callbacks. Each level adds indentation, error handling, and cognitive load. This is "callback hell" — the code forms a rightward-drifting pyramid that's hard to read, debug, and maintain.
// Three dependent async operations with callbacks
getUser(userId, (err, user) => {
if (err) {
handleError(err);
return;
}
getOrders(user.id, (err, orders) => {
if (err) {
handleError(err);
return;
}
getOrderDetails(orders[0].id, (err, details) => {
if (err) {
handleError(err);
return;
}
// Finally have the data — 3 levels deep
displayOrder(details);
});
});
});
Notice how error handling is duplicated at every level, the happy path is buried in indentation, and adding a fourth operation means another level of nesting. This doesn't scale.
How Promises Fix This
A Promise is an object with three possible states: pending (operation in progress), fulfilled (completed successfully), or rejected (failed). Instead of passing callbacks into functions, the function returns a Promise that you chain .then() and .catch() onto. This flattens the nesting into a linear chain.
// Same three operations — flat chain instead of nested callbacks
getUser(userId)
.then(user => getOrders(user.id))
.then(orders => getOrderDetails(orders[0].id))
.then(details => displayOrder(details))
.catch(err => handleError(err)); // One catch handles ALL errors
The same logic reads top-to-bottom with no nesting. Each .then() receives the resolved value from the previous step. And a single .catch() at the end handles errors from any step in the chain — no more duplicated error handling.
Key Advantages of Promises
| Aspect | Callbacks | Promises |
|---|---|---|
| Readability | Nested pyramid — hard to follow | Flat chain — reads top to bottom |
| Error handling | Manual check at every level | Single .catch() handles all errors |
| Composability | Difficult to run in parallel | Promise.all(), Promise.race() built in |
| Inversion of control | You give your callback to someone else | You control when to call .then() |
| State | No way to inspect status | Pending → fulfilled/rejected (inspectable) |
| Guarantees | Callback might be called twice or never | Resolves/rejects exactly once |
Parallel Execution
One of the biggest wins with Promises is built-in support for parallel operations. With callbacks, running multiple async operations in parallel and waiting for all of them requires manual bookkeeping (counters, result arrays). With Promises, Promise.all() handles it cleanly.
// ❌ Parallel with callbacks — manual bookkeeping
let results = [];
let completed = 0;
[url1, url2, url3].forEach((url, i) => {
fetchData(url, (err, data) => {
if (err) return handleError(err);
results[i] = data;
completed++;
if (completed === 3) processAll(results);
});
});
// ✅ Parallel with Promises — clean and declarative
const results = await Promise.all([
fetchData(url1),
fetchData(url2),
fetchData(url3),
]);
processAll(results);
The Inversion of Control Problem
With callbacks, you hand your function to someone else's code and trust they'll call it correctly — once, with the right arguments, at the right time. This is "inversion of control" and it's risky. A buggy library might call your callback twice, or never, or synchronously when you expected async.
Promises eliminate this problem with guarantees: a Promise resolves or rejects exactly once, .then() callbacks are always called asynchronously (even if the Promise is already resolved), and the resolved value is immutable. You're back in control.
Why Interviewers Ask This
This question tests whether you understand JavaScript's async evolution and the specific problems each pattern solves. Interviewers want to hear about callback hell, error propagation, inversion of control, and composability — not just "Promises are newer." It shows you understand why the language evolved and can articulate the tradeoffs between different async patterns.
Quick Revision Cheat Sheet
Callbacks: Functions passed as arguments — simple but nest badly
Promises: Objects representing future values — chainable, composable
Error handling: Callbacks: manual at each level. Promises: single .catch()
Parallel ops: Promise.all() vs manual counters with callbacks
Guarantees: Promises resolve exactly once, always async, immutable value