JavaScript Callbacks vs Promises: Full Tutorial
Compare JavaScript callbacks and Promises side by side. Learn how they handle async operations differently, the problems callbacks have, how Promises solve them, and when to use each pattern.
JavaScript is asynchronous by nature. Before Promises existed, callbacks were the only tool for handling async results. Promises arrived in ES6 and transformed how async code is written. Understanding the problems with callbacks that Promises solve — and how they solve them — is the foundation for understanding all modern async JavaScript patterns including async/await.
Callbacks Refresher
A callback is a function passed as an argument to another function, to be called once an async operation completes:
// Node.js-style error-first callback convention
function readFile(path, callback) {
// Simulated async file read
setTimeout(() => {
if (!path) {
callback(new Error("Path required"), null);
} else {
callback(null, `Contents of ${path}`);
}
}, 100);
}
// Usage
readFile("data.txt", (error, data) => {
if (error) {
console.error("Error:", error.message);
return;
}
console.log("Data:", data);
});The pattern: always check the error first, then use the data. This is the Node.js standard.
The Core Problems With Callbacks
Problem 1: Callback Hell (Nesting)
Sequential async tasks require deeply nested callbacks:
// Read file -> parse -> look up user -> fetch profile -> save
readFile("users.json", (err, data) => {
if (err) return handleError(err);
parseJSON(data, (err, users) => {
if (err) return handleError(err);
findUser(users, userId, (err, user) => {
if (err) return handleError(err);
fetchProfile(user.id, (err, profile) => {
if (err) return handleError(err);
saveResult(profile, (err) => {
if (err) return handleError(err);
console.log("All done!");
});
});
});
});
});
// The "Pyramid of Doom"This is callback hell, also called the "Pyramid of Doom." It is hard to read, hard to maintain, and the indentation rightward march signals a structure that scales badly.
Problem 2: Error Handling Is Manual and Error-Prone
With callbacks, every level must handle errors. It is easy to miss one:
function doStepA(callback) {
asyncA((err, result) => {
if (err) {
callback(err); // Must forward error manually
return; // Easy to forget this return!
}
callback(null, result);
});
}Problem 3: No Inversion of Control Guarantee
When you pass a callback to a third-party library, you are trusting it to:
- Call your callback exactly once (not zero, not twice)
- Call it asynchronously (not synchronously)
- Call it with the correct signature
Libraries sometimes violate these expectations, leading to bugs that are very hard to debug.
Problem 4: No Parallel Composition
Running multiple async operations in parallel and waiting for all to finish is cumbersome with callbacks:
// Parallel callbacks -- error-prone manual tracking
let results = {};
let completed = 0;
const total = 3;
function done(key, value) {
results[key] = value;
completed++;
if (completed === total) {
// All done
console.log(results);
}
}
fetchA((err, a) => done("a", a));
fetchB((err, b) => done("b", b));
fetchC((err, c) => done("c", c));
// No error handling for simplicity, but real code needs it for eachPromises: The Solution
A Promise represents the eventual result (or failure) of an async operation. It has three states:
| State | Meaning | Transition |
|---|---|---|
pending | Initial state, not yet settled | — |
fulfilled | Operation succeeded, has a value | From pending |
rejected | Operation failed, has a reason | From pending |
Once settled (fulfilled or rejected), a Promise is immutable — it never changes state again. This solves the "called multiple times" problem.
// Creating a Promise
function readFilePromise(path) {
return new Promise((resolve, reject) => {
if (!path) {
reject(new Error("Path required")); // Failure
return;
}
setTimeout(() => {
resolve(`Contents of ${path}`); // Success
}, 100);
});
}
// Consuming a Promise
readFilePromise("data.txt")
.then((data) => {
console.log("Data:", data);
})
.catch((error) => {
console.error("Error:", error.message);
});Side-by-Side Comparison
Sequential Operations
// CALLBACKS: Sequential with shared error handler
function doWorkCallback(done) {
stepA((errA, a) => {
if (errA) return done(errA);
stepB(a, (errB, b) => {
if (errB) return done(errB);
stepC(b, done);
});
});
}
// PROMISES: Sequential with .then() chaining
function doWorkPromise() {
return stepA()
.then((a) => stepB(a))
.then((b) => stepC(b));
// One .catch() at the end handles ALL errors from all steps
}Error Handling
// CALLBACKS: Repetitive per-level error checks
function processCallback(id, callback) {
fetchUser(id, (err, user) => {
if (err) return callback(err); // Level 1 error
fetchPosts(user, (err, posts) => {
if (err) return callback(err); // Level 2 error
processResults(posts, (err, result) => {
if (err) return callback(err); // Level 3 error
callback(null, result);
});
});
});
}
// PROMISES: One catch for the entire chain
function processPromise(id) {
return fetchUser(id)
.then((user) => fetchPosts(user))
.then((posts) => processResults(posts));
// Call .catch() at the usage site
}
processPromise(123)
.then((result) => console.log(result))
.catch((err) => console.error(err.message)); // Catches ANY rejection in chainParallel Execution
// CALLBACKS: Manual counter (brittle)
function parallelCallback(done) {
const results = {};
let pending = 3;
let failed = false;
function checkDone() {
if (--pending === 0 && !failed) done(null, results);
}
fetchA((err, a) => { if (err) { failed = true; return done(err); } results.a = a; checkDone(); });
fetchB((err, b) => { if (err) { failed = true; return done(err); } results.b = b; checkDone(); });
fetchC((err, c) => { if (err) { failed = true; return done(err); } results.c = c; checkDone(); });
}
// PROMISES: Promise.all handles it elegantly
function parallelPromise() {
return Promise.all([fetchA(), fetchB(), fetchC()])
.then(([a, b, c]) => ({ a, b, c }));
}Converting Callbacks to Promises
Most Node.js callback-based APIs can be wrapped in a Promise:
// Generic promisify utility
function promisify(fn) {
return function (...args) {
return new Promise((resolve, reject) => {
fn(...args, (error, result) => {
if (error) reject(error);
else resolve(result);
});
});
};
}
// Convert fs.readFile
const fs = require("fs");
const readFile = promisify(fs.readFile);
readFile("data.json", "utf8")
.then((data) => JSON.parse(data))
.then((obj) => console.log(obj))
.catch((err) => console.error(err));
// Node.js has this built-in:
const { promisify } = require("util");
const readFileAsync = promisify(fs.readFile);When to Still Use Callbacks
| Use Case | Prefer |
|---|---|
| Simple one-time async operation | Promise |
| Sequential async chain | Promise / async+await |
| Error handling across multiple steps | Promise |
| Event that fires many times | Callback / event listener |
| Streaming data (many events) | Callbacks / AsyncIterator |
| Performance-critical inner loops | Callback (no Promise overhead) |
Promises represent a single future value. For things that emit multiple values over time (like a click event or a stream), callbacks or observables are more appropriate.
Promises With async/await
Modern JavaScript code rarely uses .then() directly. async/await provides synchronous-looking syntax over Promises:
// Promise chains:
function loadUser(id) {
return fetchUser(id)
.then((user) => fetchPosts(user.id))
.then((posts) => ({ user, posts }))
.catch(handleError);
}
// async/await (same Promise behavior, different syntax):
async function loadUserAsync(id) {
try {
const user = await fetchUser(id);
const posts = await fetchPosts(user.id);
return { user, posts };
} catch (error) {
handleError(error);
}
}Rune AI
Key Insights
- Callbacks are pushed, Promises are pulled: With callbacks you surrender control; Promises return an object you interact with on your terms
- Promises settle exactly once: Fulfilled or rejected, the state never changes, solving the "called multiple times" problem inherent in callbacks
- One .catch() covers the chain: A single error handler at the end of a Promise chain catches rejections from every preceding step, unlike callbacks that need manual checking at each level
- async/await is Promise syntax sugar: It does not change the underlying mechanics but makes sequential async code read like synchronous code
- Callbacks still make sense for events: For operations that emit multiple values over time (DOM events, streams), callbacks or event emitters remain more appropriate than single-value Promises
Frequently Asked Questions
Are Promises always asynchronous?
Can I use both callbacks and Promises in the same codebase?
Do Promises have performance overhead compared to callbacks?
What happens if I forget to return a Promise in a .then() handler?
How are Promises better than the "inversion of control" problem with callbacks?
Conclusion
Callbacks are the original async primitive — functions passed to be called later. They work but suffer from nesting hell, repetitive error handling, and inversion of control issues. Promises solve these by representing a single future value as an object you can chain operations on, with unified error handling and parallel composition tools like Promise.all. Modern JavaScript uses async/await syntax built on top of Promises for the most readable async code.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.