Before I really understood Promises, I lived in what the JavaScript community jokingly calls “callback hell” — deeply nested functions, indentation creeping across the screen, and error handling scattered everywhere. Understanding callbacks first, and then seeing exactly what problem Promises were built to solve, was the turning point in how I write asynchronous JavaScript. Let me walk you through both, from the fundamentals to the internal mechanics.
What Is a Callback Function?
A callback is simply a function passed as an argument to another function, to be executed later — either immediately (synchronous callback) or after some operation completes (asynchronous callback).
Synchronous Callbacks
function processArray(arr, callback) {
const result = [];
for (const item of arr) {
result.push(callback(item));
}
return result;
}
const doubled = processArray([1, 2, 3], (n) => n * 2);
console.log(doubled); // [2, 4, 6]
Array methods like .map(), .filter(), and .forEach() all take synchronous callbacks — I use these every day without thinking of them as “callbacks” specifically, but that’s exactly what they are.
Asynchronous Callbacks
function fetchUser(id, callback) {
setTimeout(() => {
const user = { id, name: "Alice" };
callback(null, user);
}, 1000);
}
fetchUser(1, (error, user) => {
if (error) {
console.error(error);
return;
}
console.log(user); // { id: 1, name: "Alice" } — after 1 second
});
Notice the (error, user) signature — this is the error-first callback convention, which was the standard pattern in Node.js and most callback-based APIs before Promises became widespread. The first argument is always reserved for an error (or null if there wasn’t one).
The Problem: Callback Hell
Once I needed to chain multiple asynchronous operations where each one depended on the previous one’s result, callbacks quickly became unmanageable:
fetchUser(1, (err, user) => {
if (err) return console.error(err);
fetchPosts(user.id, (err, posts) => {
if (err) return console.error(err);
fetchComments(posts[0].id, (err, comments) => {
if (err) return console.error(err);
console.log(comments);
// ...and it keeps going deeper
});
});
});
This pyramid shape earned the nickname “callback hell” or the “pyramid of doom.” Beyond the visual mess, error handling had to be repeated at every level, and it was genuinely hard to reason about the flow of execution.
Enter Promises
A Promise is an object representing the eventual completion (or failure) of an asynchronous operation. It solved callback hell by letting me chain operations instead of nesting them.
The Three States of a Promise
| State | Meaning |
|---|---|
pending | Initial state, neither fulfilled nor rejected |
fulfilled | The operation completed successfully |
rejected | The operation failed |
A Promise is always in exactly one of these states, and once it moves to fulfilled or rejected, it’s settled — it can never change state again.
Creating a Promise
function fetchUserPromise(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id <= 0) {
reject(new Error("Invalid user ID"));
} else {
resolve({ id, name: "Alice" });
}
}, 1000);
});
}
The executor function (the function passed to new Promise()) runs immediately and synchronously. resolve and reject are functions I call to settle the promise.
Consuming a Promise
fetchUserPromise(1)
.then((user) => {
console.log("User:", user);
return user.id;
})
.then((id) => console.log("ID was:", id))
.catch((error) => console.error("Error:", error.message))
.finally(() => console.log("Done, regardless of outcome"));
Chaining Solves the Pyramid Problem
fetchUserPromise(1)
.then((user) => fetchPostsPromise(user.id))
.then((posts) => fetchCommentsPromise(posts[0].id))
.then((comments) => console.log(comments))
.catch((error) => console.error("Something failed:", error.message));
Notice how flat this is compared to the nested callback version, and I only need one .catch() at the end to handle errors from any step in the chain.
Promise Combinators
I use these constantly for coordinating multiple promises.
| Method | Behavior |
|---|---|
Promise.all() | Waits for all to fulfill; rejects immediately if any one rejects |
Promise.allSettled() | Waits for all to settle, regardless of outcome |
Promise.race() | Settles as soon as the first promise settles (fulfilled or rejected) |
Promise.any() | Settles as soon as the first promise fulfills; rejects only if all reject |
const p1 = fetchUserPromise(1);
const p2 = fetchUserPromise(2);
const p3 = fetchUserPromise(3);
Promise.all([p1, p2, p3]).then((users) => console.log(users));
Promise.race([p1, p2, p3]).then((firstUser) => console.log("First:", firstUser));
Promise.any([p1, p2, p3]).then((anyUser) => console.log("Any success:", anyUser));
Promise.allSettled([p1, p2, p3]).then((results) => {
results.forEach((r) => console.log(r.status, r.value ?? r.reason));
});
Converting Callback APIs to Promises
Many older APIs (and some Node.js core modules) still use callbacks. I “promisify” them so I can use .then()/async-await consistently across my codebase.
function promisify(fn) {
return function (...args) {
return new Promise((resolve, reject) => {
fn(...args, (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
};
}
function readFileCallback(path, callback) {
setTimeout(() => callback(null, `Contents of ${path}`), 500);
}
const readFilePromise = promisify(readFileCallback);
readFilePromise("data.txt").then((contents) => console.log(contents));
Node.js also ships a built-in utility for exactly this:
import { promisify } from "util";
import fs from "fs";
const readFileAsync = promisify(fs.readFile);
readFileAsync("data.txt", "utf8").then((data) => console.log(data));
Or, in modern Node.js, I just import the Promise-based version directly:
import fs from "fs/promises";
const data = await fs.readFile("data.txt", "utf8");
Internal Working: How Promises Resolve Under the Hood
This is the part that made Promises finally “click” for me. When I call .then() on a Promise, the callback I pass isn’t executed immediately or even synchronously after the promise settles — it’s scheduled as a microtask.
console.log("1");
Promise.resolve().then(() => console.log("2"));
console.log("3");
// Output: 1, 3, 2
Even though the Promise is already resolved, .then()‘s callback still gets deferred to the microtask queue, which only runs after the current synchronous code finishes executing. This is a hard rule in the spec — .then() callbacks are never called synchronously, even for an already-settled Promise. This guarantees predictable ordering regardless of whether an operation was actually asynchronous or not.
Microtasks also have priority over the next “macrotask” (like a setTimeout callback or a UI render) — the microtask queue is fully drained before the event loop moves on to the next macrotask.
console.log("Start");
setTimeout(() => console.log("Timeout"), 0);
Promise.resolve().then(() => console.log("Promise"));
console.log("End");
// Output: Start, End, Promise, Timeout
Even with a 0ms delay, the setTimeout callback runs after the Promise’s .then() callback, because macrotasks always wait for the microtask queue to empty first.
Promise Chaining Internals
Each call to .then() returns a new Promise. If the callback passed to .then() returns a plain value, that new Promise resolves with that value. If it returns another Promise, the chain “adopts” that Promise’s eventual state — this is what allows me to return fetchPostsPromise(user.id) inside a .then() and have the next .then() receive the resolved posts, not a nested Promise.
Practical, Real-World Patterns
A Simple Cache Wrapper Using Promises
const cache = new Map();
function getCached(key, fetcher) {
if (cache.has(key)) return Promise.resolve(cache.get(key));
return fetcher().then((value) => {
cache.set(key, value);
return value;
});
}
Debounced Async Search with Promise Cancellation Pattern
let currentController = null;
async function search(query) {
if (currentController) currentController.abort();
currentController = new AbortController();
try {
const response = await fetch(`/search?q=${query}`, {
signal: currentController.signal,
});
return await response.json();
} catch (error) {
if (error.name === "AbortError") return null;
throw error;
}
}
Best Practices
- I always attach a
.catch()(or wrap intry/catchwithasync/await) to every Promise chain — an unhandled rejection is a silent bug waiting to surface. - I prefer
Promise.all()when I need everything to succeed together, andPromise.allSettled()when partial failures are acceptable. - I avoid mixing callback style and Promise style in the same function — I pick one and convert as needed.
- I return Promises from
.then()callbacks instead of nesting new.then()chains inside them.
Common Mistakes to Avoid
- Forgetting to
returninside a.then()chain, which breaks the chain and causes the next.then()to receiveundefined. - Nesting
.then()calls instead of chaining them, recreating the callback pyramid problem with Promises. - Not handling rejected Promises, leading to silent failures or unhandled rejection warnings.
- Using
Promise.all()when partial failure should be tolerated — leading to a completely failed batch when only one item actually failed.
Debugging Tips
- I use
console.loginside every.then()step temporarily when a chain isn’t behaving as expected, to see exactly which step returns what. - Browser DevTools flag “Uncaught (in promise)” errors — I always investigate these immediately rather than ignoring them.
- I use the Node.js
--unhandled-rejections=strictflag during development so the process exits loudly instead of silently logging a warning.
FAQs
Q: Can a Promise settle more than once? A: No. Once a Promise is fulfilled or rejected, its state and value are locked in permanently.
Q: What happens if I call both resolve() and reject() inside an executor? A: Only the first call takes effect; subsequent calls are ignored.
Q: Is async/await a replacement for Promises? A: No, it’s built directly on top of Promises — every async function still returns a Promise under the hood.
Q: Why does .then() always run asynchronously, even for a resolved Promise? A: This is guaranteed by the spec to keep behavior consistent and predictable, regardless of whether the underlying operation was actually asynchronous.
Summary and Key Takeaways
- Callbacks are functions passed to other functions to run later; they work but don’t scale well for complex async chains.
- Promises represent a future value with three states: pending, fulfilled, rejected.
.then(),.catch(), and.finally()let me chain operations instead of nesting them.Promise.all,allSettled,race, andanygive me different strategies for coordinating multiple promises..then()callbacks always run as microtasks, which take priority over macrotasks likesetTimeout.async/awaitis syntactic sugar over the exact same Promise machinery described here.
Understanding Promises at this level, rather than just memorizing .then() syntax, is what let me confidently debug timing issues and race conditions in real production applications.