Skip to main content

Command Palette

Search for a command to run...

Understanding Callback Functions in JavaScript

Updated
•6 min read•View as Markdown
P
Backend developer exploring AI agents, backend systems, and architectural rabbit holes. I enjoy understanding how things work under the hood and occasionally over-engineering side projects for fun

From "Functions as Values" to "Callback Hell" (With Fixes)

1. Functions Are Values

In JavaScript, you can treat functions like any other variable. This is one of the language's most powerful features.

const shout = function(message) {
  return message.toUpperCase();
};

// Pass function as argument
function runTwice(action, value) {
  action(value);
  action(value);
}

runTwice(shout, "hello"); // "HELLO", "HELLO"

Functions can be stored, passed around, and executed at any time—even with different contexts or data.


2. What Is a Callback?

A callback is a function passed into another function to be executed later—either immediately (synchronous) or after some operation completes (asynchronous).

function greetUser(name, callback) {
  const message = `Hello, ${name}`;
  callback(message);  // ← executing the callback
}

greetUser("Alice", (msg) => console.log(msg));
// Output: "Hello, Alice"

The key idea: you give a function to another function, and that function calls yours at the right moment.


3. Synchronous vs Asynchronous Callbacks

Understanding the difference is crucial:

Type Example Behavior
Synchronous array.map(callback) Callback runs immediately, blocks until done
Asynchronous setTimeout(callback, 1000) Callback runs later, non‑blocking

Synchronous Callback Example:

const numbers = [1, 2, 3];
const doubled = numbers.map((n) => n * 2);
console.log(doubled); // [2, 4, 6]

The callback runs for each element immediately.

Asynchronous Callback Example:

setTimeout(() => {
  console.log("This runs after 1 second");
}, 1000);

console.log("This runs immediately");
// Output:
// "This runs immediately"
// (1 second later) "This runs after 1 second"

4. Important Misconception: Array Methods and Async

Array.map is synchronous. You cannot make it "async" by passing an async function.

// ❌ This does NOT work as expected
const results = array.map(async (item) => {
  return await someAsyncOperation(item);
});
// `results` is an array of Promises, not the actual values!
// ✅ For async operations on arrays, use Promise.all
const results = await Promise.all(
  array.map(async (item) => {
    return await someAsyncOperation(item);
  })
);

5. Execution Order & The Event Loop

JavaScript has a single-threaded event loop. Understanding the order things execute is critical:

console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");

function doSomething(cb) {
  console.log("D");
  cb();
}

doSomething(() => console.log("E"));
console.log("F");

Actual output:

A
C
D
E
F
B

Why this order?

  1. A → synchronous
  2. B → queued in the macrotask queue (setTimeout)
  3. C → synchronous
  4. D → synchronous (inside function call)
  5. E → synchronous (callback invoked immediately)
  6. F → synchronous
  7. B → runs after all synchronous code and the call stack is empty

The key: setTimeout(..., 0) doesn't run immediately—it's queued, and the event loop only picks it up after all synchronous code finishes.


6. The this Problem in Callbacks

When you pass a regular function as a callback, it loses its original this context:

const obj = {
  name: "CallbackUser",
  greet: function() {
    setTimeout(function() {
      console.log("Hello, " + this.name); // this = global/window/undefined
    }, 100);
  }
};

obj.greet(); // "Hello, undefined"

Fix #1: Arrow Function (inherits this)

const obj = {
  name: "CallbackUser",
  greet: function() {
    setTimeout(() => {
      console.log("Hello, " + this.name); // this = obj
    }, 100);
  }
};

obj.greet(); // "Hello, CallbackUser"

Fix #2: .bind(this)

setTimeout(function() {
  console.log("Hello, " + this.name);
}.bind(this), 100);

Arrow functions are usually preferred because they're cleaner and automatically inherit this.


7. Callback Hell

When asynchronous operations depend on each other, nesting grows deeper:

readFile("a.txt", (err, data) => {
  if (err) throw err;
  processData(data, (err, result) => {
    if (err) throw err;
    saveResult(result, (err) => {
      if (err) throw err;
      console.log("Done");
    });
  });
});

This creates several problems:

  1. Readability – The "pyramid of doom" is hard to follow
  2. Error handling duplication – Every level needs if (err) checks
  3. Inversion of control – You must trust readFile to call your callback correctly
  4. Code reuse – Hard to extract and reuse intermediate steps

Modern Solution: Promises + async/await

async function processFiles() {
  try {
    const data = await readFile("a.txt");
    const result = await processData(data);
    await saveResult(result);
    console.log("Done");
  } catch (err) {
    console.error(err);
  }
}

processFiles();

This is cleaner, easier to read, and error handling is centralized.


8. Event Emitter Pattern: Multiple Callbacks

If you need to register multiple callbacks for an event, store them in an array:

class SimpleEmitter {
  constructor() {
    this.handlers = [];
  }

  on(callback) {
    this.handlers.push(callback);
  }

  emit(data) {
    for (const callback of this.handlers) {
      try {
        callback(data);
      } catch (err) {
        console.error("Callback error:", err);
        // Continue with next callback—don't let one failure break others
      }
    }
  }
}

// Usage
const emitter = new SimpleEmitter();

emitter.on((data) => console.log("Handler 1:", data));
emitter.on((data) => console.log("Handler 2:", data));

emitter.emit("Hello!");
// Output:
// Handler 1: Hello!
// Handler 2: Hello!

Key point: Catch errors individually so one broken callback doesn't break the others.


9. Synchronous Code Inside an Async Callback

A common point of confusion: code after an async callback still runs, even though the callback isn't done yet.

function runWithCallback(cb) {
  console.log("Before callback");
  cb();
  console.log("After callback");
}

runWithCallback(() => {
  console.log("Inside callback");
  setTimeout(() => console.log("Inside setTimeout inside callback"), 0);
});

console.log("End of script");

Output:

Before callback
Inside callback
After callback
End of script
(1 second later) Inside setTimeout inside callback

The setTimeout inside the callback does not block execution. The function returns immediately, and the rest of the synchronous code continues.


Quick Reference: Key Concepts

Concept Explanation
Callback A function passed to another function to run later
Synchronous callback Runs immediately, blocks further execution
Asynchronous callback Runs later, non-blocking
Event loop Processes synchronous code first, then queued callbacks
this binding Regular functions lose context; use arrow functions or .bind()
Callback hell Deep nesting from dependent async operations
Modern alternative Promises and async/await

Best Practices

  1. Use arrow functions in callbacks to preserve this
  2. Avoid callback hell – use Promises or async/await
  3. Handle errors individually in event emitter patterns
  4. Understand the event loop – know that setTimeout(..., 0) doesn't run immediately
  5. Consider the problem – sometimes a simple synchronous callback is the right choice

Callbacks are fundamental to JavaScript. While they can become messy with deep nesting, understanding them helps you write cleaner, more maintainable code—especially when combined with modern tools like Promises and async/await.

2 views