Promises — from callback hell to a real, composable abstraction

A Promise isn't just a nicer way to write a callback — it's a genuine object representing a value that doesn't exist yet, with real states and guarantees that callbacks alone never had, which is exactly what makes chaining and combining async operations reliable.

Intermediate

3 min read

The problem promises actually solve: nested callbacks with no shared error handling

// "Callback hell" — deeply nested, and error handling has to be repeated at EVERY level
getUser(userId, (err, user) => {
  if (err) return handleError(err);
  getPosts(user.id, (err, posts) => {
    if (err) return handleError(err);
    getComments(posts[0].id, (err, comments) => {
      if (err) return handleError(err);
      console.log(comments);
    });
  });
});

Before promises, chaining several dependent async operations meant nesting callbacks progressively deeper, with error handling manually repeated at every single level (miss one if (err) check and an error silently vanishes) — a genuinely real, common source of bugs and unreadable code in pre-promise JavaScript, widely nicknamed "callback hell" or "the pyramid of doom."

A Promise's three states, and why the transition only ever happens once

const promise = new Promise((resolve, reject) => {
  fetchData((err, data) => {
    if (err) reject(err);   // moves to "rejected" — PERMANENTLY, can never change again
    else resolve(data);      // moves to "fulfilled" — PERMANENTLY, can never change again
  });
});

Every Promise starts pending, and transitions exactly once, to either fulfilled (with a value) or rejected (with a reason) — never both, and never back to pending once settled. This one-way, one-time transition is a real, enforced guarantee the Promise object itself provides, not just a convention — calling resolve after reject has no effect at all, which is a structural improvement over a plain callback, where nothing stops a poorly-written function from calling its callback twice or with inconsistent arguments.

Chaining: the same nested logic, now flat and with ONE error handler

getUser(userId)
  .then((user) => getPosts(user.id))
  .then((posts) => getComments(posts[0].id))
  .then((comments) => console.log(comments))
  .catch((err) => handleError(err)); // catches an error from ANY step above, not just the last one

Each .then() returns a new Promise, which is what makes chaining work — and critically, a single .catch() at the end catches a rejection from any preceding step in the chain, not just the immediately preceding one, because a rejection propagates forward through .then() calls until it finds a .catch() (or an equivalent second argument to .then()). This is the structural fix for callback hell's repeated error-handling problem: one handler, covering the entire chain, instead of one per nesting level.

Promise.all: running multiple promises concurrently, waiting for all of them

const [user, posts, settings] = await Promise.all([
  fetchUser(id),
  fetchPosts(id),
  fetchSettings(id),
]); // all THREE requests start immediately, run concurrently — waits for the SLOWEST one

Promise.all takes an array of promises, starts all of them essentially simultaneously (each is already "in flight" the moment it's created, before Promise.all is even called), and resolves with an array of their results once every one has fulfilled — or rejects immediately as soon as any one rejects. This is the direct fix for the sequential-await "waterfall" problem covered elsewhere in this platform's data-fetching content: three independent requests that don't depend on each other's results should run concurrently, not one after another.

Promise.race and Promise.allSettled: the other real, common combinators

const first = await Promise.race([fetchFromPrimary(), fetchFromBackup()]); // whichever settles FIRST wins
 
const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
// results: [{status: "fulfilled", value: ...}, {status: "rejected", reason: ...}, ...]
// NEVER rejects itself — waits for every promise, reporting each outcome individually

Promise.race settles as soon as the first promise in the array settles (fulfilled or rejected), useful for a timeout pattern or racing a primary source against a fallback. Promise.allSettled — unlike Promise.all — never rejects itself; it waits for every promise to finish regardless of outcome and reports each one's individual status, which is the right choice when partial failure among several independent requests is acceptable and each result needs to be inspected individually, rather than the whole batch failing because one request did.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What real, structural guarantee does a Promise provide that a plain callback never had?

2. Why does a single `.catch()` at the end of a `.then()` chain catch errors from ANY step in the chain, not just the last one?

3. What's the key difference between `Promise.all` and `Promise.allSettled`?