Callback Functions and Promises in JavaScript

Callback Functions and Promises in JavaScript

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

StateMeaning
pendingInitial state, neither fulfilled nor rejected
fulfilledThe operation completed successfully
rejectedThe 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.

MethodBehavior
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 in try/catch with async/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, and Promise.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

  1. Forgetting to return inside a .then() chain, which breaks the chain and causes the next .then() to receive undefined.
  2. Nesting .then() calls instead of chaining them, recreating the callback pyramid problem with Promises.
  3. Not handling rejected Promises, leading to silent failures or unhandled rejection warnings.
  4. 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.log inside 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=strict flag 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, and any give me different strategies for coordinating multiple promises.
  • .then() callbacks always run as microtasks, which take priority over macrotasks like setTimeout.
  • async/await is 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.

References

Total
0
Shares

Leave a Reply

Previous Post
Understanding Asynchronous Programming in JavaScript

Understanding Asynchronous Programming in JavaScript

Next Post
Async/Await Syntax in JavaScript

Async/Await Syntax in JavaScript

Related Posts