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.

JavaScriptintermediate
12 min read

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:

javascriptjavascript
// 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:

javascriptjavascript
// 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:

javascriptjavascript
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:

javascriptjavascript
// 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 each

Promises: The Solution

A Promise represents the eventual result (or failure) of an async operation. It has three states:

StateMeaningTransition
pendingInitial state, not yet settled
fulfilledOperation succeeded, has a valueFrom pending
rejectedOperation failed, has a reasonFrom pending

Once settled (fulfilled or rejected), a Promise is immutable — it never changes state again. This solves the "called multiple times" problem.

javascriptjavascript
// 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

javascriptjavascript
// 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

javascriptjavascript
// 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 chain

Parallel Execution

javascriptjavascript
// 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:

javascriptjavascript
// 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 CasePrefer
Simple one-time async operationPromise
Sequential async chainPromise / async+await
Error handling across multiple stepsPromise
Event that fires many timesCallback / event listener
Streaming data (many events)Callbacks / AsyncIterator
Performance-critical inner loopsCallback (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:

javascriptjavascript
// 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

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
RunePowered by Rune AI

Frequently Asked Questions

Are Promises always asynchronous?

The Promise resolution itself (.then() callbacks) is always asynchronous even if the Promise resolves synchronously. This is by design for consistency. However, the executor function (the function passed to `new Promise(...)`) runs synchronously. Callbacks depend on the implementation — a poorly designed API might call its callback synchronously, leading to inconsistent behavior.

Can I use both callbacks and Promises in the same codebase?

Yes, and it is common during migration from callback-style to Promise-style code. Use `promisify` to wrap callback-based functions into Promises as needed. Just avoid mixing them within a single function — pick one style per function for clarity.

Do Promises have performance overhead compared to callbacks?

Yes, Promises have some overhead because they create objects, schedule microtasks, and enforce asynchrony. In practice, this overhead is negligible for most applications. For extremely performance-sensitive code (like tight loops processing thousands of operations per second), callbacks may be preferable.

What happens if I forget to return a Promise in a .then() handler?

If a `.then()` handler returns `undefined` (no return statement), the next `.then()` in the chain receives `undefined` as its argument. The chain continues with a resolved Promise of `undefined`. This is a common bug where the programmer forgot to `return innerFetch(...)` and the next step gets no data.

How are Promises better than the "inversion of control" problem with callbacks?

With [callbacks](/tutorials/programming-languages/javascript/what-is-a-callback-function-in-js-full-tutorial), you hand your callback to a third party and trust it to be called correctly. Promises reverse this: instead of passing something to them, they give something back (the Promise object) that you control. You subscribe to the Promise with `.then()`. The Promise spec guarantees your handler is called exactly once, asynchronously, in a microtask.

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.