DEV Community

Cover image for Callbacks in JavaScript: Why They Exist
Pratham
Pratham

Posted on

Callbacks in JavaScript: Why They Exist

Functions that call other functions — and why that's the foundation of everything async.


Here's something that blew my mind when I first learned it: in JavaScript, functions are values. You can store them in variables, put them in arrays, pass them as arguments to other functions, and even return them from functions.

That last one — passing a function as an argument — is the entire concept behind callbacks. And once you understand callbacks, you understand how setTimeout works, how addEventListener works, how array methods like map() and filter() work, and how JavaScript handles asynchronous operations.

Callbacks are everywhere. I just didn't realize it until the ChaiCode Web Dev Cohort 2026 connected the dots. Let me show you what I mean.


Functions Are Values in JavaScript

Before we talk about callbacks, we need to establish this fundamental truth: functions in JavaScript are first-class citizens. That means they're treated just like any other value — numbers, strings, objects.

// Storing a function in a variable
const greet = function (name) {
  return `Hello, ${name}!`;
};

// Storing functions in an array
const operations = [
  (a, b) => a + b,
  (a, b) => a - b,
  (a, b) => a * b,
];

console.log(operations[0](5, 3)); // 8
console.log(operations[2](5, 3)); // 15
Enter fullscreen mode Exit fullscreen mode

If a function is just a value, then you can pass it to another function as an argument. And that's exactly what a callback is.


What Is a Callback Function?

A callback is a function that you pass into another function as an argument, and the receiving function calls it back at some point during its execution.

The name literally tells you what it does: "call me back when you're ready."

The Simplest Callback

function processUserInput(callback) {
  const name = "Pratham";
  callback(name);
}

processUserInput((name) => {
  console.log(`Welcome, ${name}!`);
});
// "Welcome, Pratham!"
Enter fullscreen mode Exit fullscreen mode

Let's trace what happens:

  1. We call processUserInput and pass it a function (the callback)
  2. Inside processUserInput, name is set to "Pratham"
  3. The callback is called with name as the argument
  4. Our passed function runs: console.log("Welcome, Pratham!")

Visual: Function Calling Another Function

processUserInput(callback)
         │
         ↓
  ┌─────────────────────┐
  │  processUserInput()  │
  │                     │
  │  const name = ...   │
  │  callback(name)  ──────→  Our function runs!
  │        ↑            │        ↓
  │   "call me back"    │   console.log("Welcome, Pratham!")
  └─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The key idea: the caller decides what to do. The receiving function decides when to do it.


Passing Functions as Arguments — More Examples

You've actually been using callbacks all along. Let me prove it.

Array Methods Are Callbacks

const numbers = [1, 2, 3, 4, 5];

// The function inside map() is a CALLBACK
const doubled = numbers.map((num) => num * 2);
console.log(doubled); // [2, 4, 6, 8, 10]

// The function inside filter() is a CALLBACK
const evens = numbers.filter((num) => num % 2 === 0);
console.log(evens); // [2, 4]

// The function inside forEach() is a CALLBACK
numbers.forEach((num) => console.log(num));
// 1, 2, 3, 4, 5
Enter fullscreen mode Exit fullscreen mode

Every time you pass a function to map(), filter(), or forEach(), you're using a callback. You're saying: "Here's what I want done to each element. You handle when and how to loop."

Event Listeners Are Callbacks

// The function is a callback — it runs when the event happens
document.querySelector("#btn").addEventListener("click", () => {
  console.log("Button clicked!");
});

// You're saying: "When a click happens, CALL BACK this function."
Enter fullscreen mode Exit fullscreen mode

setTimeout and setInterval Are Callbacks

// The function runs AFTER 2 seconds — it's called back later
setTimeout(() => {
  console.log("2 seconds passed!");
}, 2000);
Enter fullscreen mode Exit fullscreen mode

In all of these cases, you're not calling the function yourself. You're passing it to something else, and that something else calls it at the right time.


Why Callbacks Are Used in Asynchronous Programming

This is where callbacks become essential — not just convenient.

Remember from the sync vs async article: JavaScript is single-threaded. It can't wait around for slow tasks. So instead of blocking, it says: "Start this task. Here's a function to run when you're done. I'll keep going."

That function is a callback.

Synchronous vs Async Callback

// SYNCHRONOUS callback — runs immediately
function calculate(a, b, operation) {
  return operation(a, b);
}

const result = calculate(10, 5, (a, b) => a + b);
console.log(result); // 15 — runs right now, no waiting

// ASYNCHRONOUS callback — runs later
console.log("Before");

setTimeout(() => {
  console.log("Inside callback — runs later!");
}, 1000);

console.log("After");

// Output: Before → After → Inside callback (1 second later)
Enter fullscreen mode Exit fullscreen mode

Synchronous callbacks run immediately within the function. Asynchronous callbacks are scheduled to run later — after a timer, after an API response, after a user action.

Real-World Async Example: Simulated Data Fetching

function fetchUser(userId, callback) {
  console.log(`Fetching user ${userId}...`);

  // Simulate API delay
  setTimeout(() => {
    const user = { id: userId, name: "Pratham", role: "developer" };
    callback(user); // "Here's your data — I'm calling you back!"
  }, 2000);
}

console.log("Start");

fetchUser(1, (user) => {
  console.log(`Got user: ${user.name} (${user.role})`);
});

console.log("Continuing with other work...");
Enter fullscreen mode Exit fullscreen mode

Output:

Start
Fetching user 1...
Continuing with other work...
Got user: Pratham (developer)   ← 2 seconds later
Enter fullscreen mode Exit fullscreen mode

The flow:

1. "Start" prints
2. fetchUser() is called → sets up a 2-second timer
3. "Continuing with other work..." prints (JavaScript didn't wait!)
4. After 2 seconds, the callback fires with the user data
Enter fullscreen mode Exit fullscreen mode

Callback Usage in Common Scenarios

1. Reading Files (Node.js)

const fs = require("fs");

fs.readFile("data.txt", "utf8", (error, data) => {
  if (error) {
    console.log("Error reading file:", error);
    return;
  }
  console.log("File contents:", data);
});

console.log("This runs while the file is being read...");
Enter fullscreen mode Exit fullscreen mode

2. Error-First Callbacks (Node.js Convention)

In Node.js, callbacks follow a convention: the first parameter is always the error (or null if no error):

function getUser(id, callback) {
  setTimeout(() => {
    if (id <= 0) {
      callback(new Error("Invalid user ID"), null);
    } else {
      callback(null, { id, name: "Pratham" });
    }
  }, 1000);
}

// Usage
getUser(1, (error, user) => {
  if (error) {
    console.log("Error:", error.message);
    return;
  }
  console.log("User:", user.name);
});
// "User: Pratham"

getUser(-1, (error, user) => {
  if (error) {
    console.log("Error:", error.message);
    return;
  }
  console.log("User:", user.name);
});
// "Error: Invalid user ID"
Enter fullscreen mode Exit fullscreen mode

3. Custom Iteration with Callbacks

function repeat(times, callback) {
  for (let i = 1; i <= times; i++) {
    callback(i);
  }
}

repeat(3, (n) => console.log(`Iteration ${n}`));
// Iteration 1
// Iteration 2
// Iteration 3

repeat(5, (n) => console.log("".repeat(n)));
// ⭐
// ⭐⭐
// ⭐⭐⭐
// ⭐⭐⭐⭐
// ⭐⭐⭐⭐⭐
Enter fullscreen mode Exit fullscreen mode

The repeat function controls when (the loop). The callback controls what (the action). Same function, completely different behavior — just by passing a different callback.

4. Confirmation Patterns

function confirmAction(message, onConfirm, onCancel) {
  const answer = true; // simulating user clicking "OK"

  if (answer) {
    onConfirm();
  } else {
    onCancel();
  }
}

confirmAction(
  "Delete this item?",
  () => console.log("Item deleted! 🗑️"),
  () => console.log("Action cancelled."),
);
// "Item deleted! 🗑️"
Enter fullscreen mode Exit fullscreen mode

The Basic Problem: Callback Nesting (Callback Hell)

Callbacks work great for simple, one-level async operations. But what happens when you need to do async things in sequence — where each step depends on the result of the previous one?

The Scenario

You need to:

  1. Fetch a user
  2. Use that user's ID to fetch their orders
  3. Use the order to fetch shipping details

Each step depends on the previous step's result.

The Nested Code

getUser(1, (error, user) => {
  if (error) {
    console.log("Error:", error);
    return;
  }
  console.log("Got user:", user.name);

  getOrders(user.id, (error, orders) => {
    if (error) {
      console.log("Error:", error);
      return;
    }
    console.log("Got orders:", orders.length);

    getShippingDetails(orders[0].id, (error, shipping) => {
      if (error) {
        console.log("Error:", error);
        return;
      }
      console.log("Shipping to:", shipping.address);

      // Need more steps? Nest even deeper...
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Nested Callback Execution Flow

getUser(1, callback)
    │
    └──→ success → getOrders(user.id, callback)
                        │
                        └──→ success → getShippingDetails(orderId, callback)
                                            │
                                            └──→ success → finally do something!

Each level pushes the code further RIGHT →→→
This is "Callback Hell" or the "Pyramid of Doom"
Enter fullscreen mode Exit fullscreen mode

Why This Is a Problem

1. READABILITY — The code keeps indenting right. With 5+ levels, it's 
   nearly impossible to follow.

2. ERROR HANDLING — You're writing the same if (error) check at every 
   single level. Repetitive and easy to miss.

3. MAINTAINABILITY — Adding a new step means wrapping everything in 
   another layer of nesting. Removing a step means carefully 
   restructuring the entire chain.

4. DEBUGGING — When something breaks deep in the nesting, finding 
   the source is like debugging inside a Russian doll.
Enter fullscreen mode Exit fullscreen mode

The Visual

Code with callbacks:

getUser(1, (err, user) => {
  ·getOrders(user.id, (err, orders) => {
  ··getShipping(orders[0].id, (err, ship) => {
  ···sendEmail(user.email, ship.status, (err, result) => {
  ····updateDB(result, (err, updated) => {
  ·····console.log("FINALLY DONE!");  // 5 levels deep 😵
  ····});
  ···});
  ··});
  ·});
});

This is the "Pyramid of Doom" →→→→→
Enter fullscreen mode Exit fullscreen mode

This is exactly why Promises and async/await were invented — to flatten this nesting and make async code readable. But that's for the next article. Understanding why callbacks have this problem is the first step.


Let's Practice: Hands-On Assignment

Part 1: Write a Simple Callback

function greetUser(name, callback) {
  const message = `Hello, ${name}!`;
  callback(message);
}

greetUser("Pratham", (msg) => console.log(msg));
// "Hello, Pratham!"

greetUser("Arjun", (msg) => console.log(msg.toUpperCase()));
// "HELLO, ARJUN!"
Enter fullscreen mode Exit fullscreen mode

Part 2: Async Callback with setTimeout

function delayedGreeting(name, delay, callback) {
  console.log(`Preparing greeting for ${name}...`);
  setTimeout(() => {
    callback(`Hey ${name}, welcome to ChaiCode! 🍵`);
  }, delay);
}

delayedGreeting("Pratham", 2000, (message) => {
  console.log(message);
});
console.log("This prints while waiting...");

// "Preparing greeting for Pratham..."
// "This prints while waiting..."
// "Hey Pratham, welcome to ChaiCode! 🍵"  (2 seconds later)
Enter fullscreen mode Exit fullscreen mode

Part 3: Callback with Error Handling

function divide(a, b, callback) {
  if (b === 0) {
    callback(new Error("Cannot divide by zero!"), null);
  } else {
    callback(null, a / b);
  }
}

divide(10, 2, (err, result) => {
  if (err) return console.log("Error:", err.message);
  console.log("Result:", result);
});
// "Result: 5"

divide(10, 0, (err, result) => {
  if (err) return console.log("Error:", err.message);
  console.log("Result:", result);
});
// "Error: Cannot divide by zero!"
Enter fullscreen mode Exit fullscreen mode

Part 4: Experience Callback Nesting

function step1(callback) {
  setTimeout(() => {
    console.log("Step 1 complete");
    callback("data from step 1");
  }, 1000);
}

function step2(input, callback) {
  setTimeout(() => {
    console.log(`Step 2 received: ${input}`);
    callback("data from step 2");
  }, 1000);
}

function step3(input, callback) {
  setTimeout(() => {
    console.log(`Step 3 received: ${input}`);
    callback("final result");
  }, 1000);
}

// Nested callbacks — feel the nesting!
step1((result1) => {
  step2(result1, (result2) => {
    step3(result2, (finalResult) => {
      console.log(`Done! ${finalResult}`);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. A callback is a function passed as an argument to another function, to be "called back" at the right time.
  2. Functions in JavaScript are first-class values — you can pass them around just like numbers or strings. This is what makes callbacks possible.
  3. You've been using callbacks all along — in map(), filter(), addEventListener(), setTimeout(), and more.
  4. Callbacks are essential for async programming — they let JavaScript hand off slow tasks and say "call this function when you're done."
  5. Callback nesting (callback hell) is the main drawback — sequential async operations create deeply nested, hard-to-read code. Promises and async/await solve this.

Wrapping Up

Callbacks are the original way JavaScript handles asynchronous operations. They're simple, powerful, and they're the foundation that Promises and async/await are built on. Understanding why callbacks exist — and where they break down — gives you the full context for why modern async patterns were invented.

I'm working through all of this in the ChaiCode Web Dev Cohort 2026 under Hitesh Chaudhary and Piyush Garg. Callbacks were the "aha" moment where I stopped seeing functions as just blocks of code and started seeing them as values that can be passed around. That mental shift unlocks everything that comes next.

Connect with me on LinkedIn or visit PrathamDEV.in. Next up: Promises — the solution to callback hell.

Happy coding! 🚀


Written by Pratham Bhardwaj | Web Dev Cohort 2026, ChaiCode

Top comments (0)