2 min read
On this page

Callbacks & Promises

Callbacks: The Original Async Pattern

A callback is a function passed to another function, to be called when an operation completes.

function fetchUserData(userId, callback) {
  setTimeout(() => {
    callback(null, { id: userId, name: "Alice" });
  }, 1000);
}

fetchUserData(42, function(error, user) {
  if (error) { console.error(error); return; }
  console.log(user.name);  // "Alice"
});

Callback Hell

When async operations depend on each other, callbacks nest deeper and deeper.

getUser(userId, function(err, user) {
  if (err) { handleError(err); return; }
  getOrders(user.id, function(err, orders) {
    if (err) { handleError(err); return; }
    getOrderDetails(orders[0].id, function(err, details) {
      if (err) { handleError(err); return; }
      displayDetails(details);
    });
  });
});

Unreadable, hard to debug, and every level needs its own error handling. This is the pyramid of doom.

Promises

A promise represents a value that may not exist yet. It is in one of three states: pending, fulfilled, or rejected. It settles exactly once.

Creating a Promise

function fetchUserData(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (userId <= 0) { reject(new Error("Invalid ID")); return; }
      resolve({ id: userId, name: "Alice" });
    }, 1000);
  });
}

Consuming with .then(), .catch() & .finally()

fetchUserData(42)
  .then(user => {
    console.log(user.name);
    return fetchOrders(user.id);
  })
  .then(orders => fetchOrderDetails(orders[0].id))
  .then(details => displayDetails(details))
  .catch(error => console.error("Failed:", error.message))
  .finally(() => hideLoadingSpinner());

One .catch() handles errors from any step. .finally() runs regardless of outcome.

Chaining Transforms

.then() can return a plain value, and the next .then() receives it.

fetchUserData(42)
  .then(user => user.name)
  .then(name => name.toUpperCase())
  .then(upper => console.log(upper));  // "ALICE"

Promise Combinators

Promise.all()

Wait for all to fulfill. Rejects immediately if any one rejects. Runs in parallel.

const [user, orders, settings] = await Promise.all([
  fetchUser(1), fetchOrders(1), fetchSettings(1)
]);
// Three 1-second requests complete in ~1 second, not 3

Promise.race()

Resolves with the first promise that settles. Useful for timeouts.

function fetchWithTimeout(url, ms) {
  return Promise.race([
    fetch(url).then(r => r.json()),
    new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), ms))
  ]);
}

Promise.allSettled()

Wait for all to settle regardless of outcome. Never short-circuits.

const results = await Promise.allSettled([
  fetch("/api/users"), fetch("/api/orders"), fetch("/api/broken")
]);
results.forEach((r, i) => {
  if (r.status === "fulfilled") console.log(`${i}: success`);
  else console.log(`${i}: failed — ${r.reason.message}`);
});

Promise.any()

Resolves with the first fulfilled promise. Rejects only if all reject.

// Try multiple CDN mirrors, use whichever responds first
const response = await Promise.any([
  fetch("https://cdn1.example.com/lib.js"),
  fetch("https://cdn2.example.com/lib.js"),
  fetch("https://cdn3.example.com/lib.js"),
]);
console.log("Got response from:", response.url);

If all promises reject, Promise.any rejects with an AggregateError containing all the reasons.

Creating Promises

Wrapping Callback APIs

Convert callback-based functions to return promises.

function readFilePromise(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, "utf-8", (err, data) => {
      if (err) reject(err); else resolve(data);
    });
  });
}

readFilePromise("/config.json")
  .then(data => JSON.parse(data))
  .then(config => console.log(config.appName))
  .catch(error => console.error(error.message));

Promise.resolve() & Promise.reject()

Create already-settled promises for caching or starting chains.

const cache = new Map();
function fetchUser(id) {
  if (cache.has(id)) return Promise.resolve(cache.get(id));
  return fetch(`/api/users/${id}`)
    .then(r => r.json())
    .then(user => { cache.set(id, user); return user; });
}

Common Pitfalls

Forgetting to return inside .then(). The next .then() receives undefined.

// Bug: missing return
fetchUser(1).then(user => { fetchOrders(user.id); }).then(orders => {
  console.log(orders);  // undefined
});
// Fix: return the promise
fetchUser(1).then(user => { return fetchOrders(user.id); });

Nesting .then() calls. This recreates callback hell. Chain flat instead.

Not handling rejections. Unhandled rejections crash Node.js and warn in browsers. Always end with .catch().

Using Promise.all when partial failure is acceptable. Use Promise.allSettled instead.

Sequential promises when parallel is possible. If requests do not depend on each other, use Promise.all.

Key Takeaways

  • Callbacks were the original async pattern. Nested callbacks become unreadable.
  • A promise is pending, then settles as fulfilled or rejected, exactly once.
  • .then() chains enable flat, readable sequential async code. Always return from .then().
  • .catch() at the end handles errors from any step in the chain.
  • Promise.all runs in parallel, rejects on any failure. Promise.allSettled waits for all.
  • Promise.race resolves with the first settled promise. Useful for timeouts.
  • Always handle rejections. Unhandled promise rejections are bugs.