JavaScriptMedium

Promises vs callbacks

01

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.

02

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.

callback-pattern.tstypescript
// 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.

03

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.

callback-hell.tstypescript
// 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.

04

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.

promise-chain.tstypescript
// 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.

05

Key Advantages of Promises

AspectCallbacksPromises
ReadabilityNested pyramid — hard to followFlat chain — reads top to bottom
Error handlingManual check at every levelSingle .catch() handles all errors
ComposabilityDifficult to run in parallelPromise.all(), Promise.race() built in
Inversion of controlYou give your callback to someone elseYou control when to call .then()
StateNo way to inspect statusPending → fulfilled/rejected (inspectable)
GuaranteesCallback might be called twice or neverResolves/rejects exactly once
06

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-execution.tstypescript
// ❌ 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);
07

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.

08

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