Skip to main content

Command Palette

Search for a command to run...

Callbacks in JavaScript: Why They Exist

Updated
14 min read
Callbacks in JavaScript: Why They Exist
H
CS undergrad | Tech enthusiast | Focusing on Web Dev • DSA • ML | Building skills for real-world impact

Callbacks are everywhere in JavaScript. You use them without thinking about it. But understanding what they are and why they exist will make you a better programmer.

This is about functions as values, why callbacks exist, how to use them, and where they start to cause problems.


Functions Are Values

Before understanding callbacks, you need to understand that functions in JavaScript are values. Like strings or numbers, you can store them, pass them around, and use them later.

Functions Are Objects

// A function is a value
const greet = function() {
  console.log("Hello");
};

// You can store it
const myFunction = greet;

// You can pass it to another function
someFunction(greet);

// You can return it from another function
return greet;

Functions are first-class objects. This is the foundation for callbacks.

Storing Functions

const add = function(a, b) {
  return a + b;
};

// You can store it in a variable
const operation = add;

// Call it later
operation(2, 3); // 5

Passing Functions as Arguments

// A function that takes another function as an argument
function run(fn) {
  fn();
}

// Pass a function to it
run(function() {
  console.log("Running!");
});

// Output: Running!

The function you pass is not called immediately. It's passed as a value and called inside run().


What Is a Callback?

A callback is a function that you pass to another function, and that function calls it at some point.

You → Pass a function → Another function → Calls your function
          (callback)

The other function "calls you back" with the callback.

Simple Example

function greetUser(name, callback) {
  console.log("Hello, " + name);
  callback();  // Call the callback function
}

greetUser("Alice", function() {
  console.log("Callback executed!");
});

// Output:
// Hello, Alice
// Callback executed!

Here's what happens:

  1. You call greetUser() with a name and a callback function
  2. greetUser() logs the greeting
  3. greetUser() calls the callback function
  4. Your callback runs

The callback is called "back" by greetUser().

Callback with Parameters

The function that calls your callback can pass data to it:

function greetUser(name, callback) {
  const greeting = "Hello, " + name;
  callback(greeting);  // Pass data to callback
}

greetUser("Alice", function(message) {
  console.log(message);
});

// Output: Hello, Alice

The callback receives message as a parameter. The calling function decides what to pass.


Why Callbacks Exist

Callbacks exist to handle operations that don't finish immediately. They exist for asynchronous programming.

Synchronous vs Asynchronous

Synchronous: Code runs line by line. Wait for each line to finish before moving to the next.

console.log("1");
console.log("2");
console.log("3");

// Output:
// 1
// 2
// 3

Each line finishes before the next starts.

Asynchronous: Some operations take time. You don't want to wait (block) for them to finish. You tell the program what to do when they finish.

console.log("1");
setTimeout(function() {
  console.log("2");
}, 1000);  // Wait 1 second
console.log("3");

// Output:
// 1
// 3
// 2 (after 1 second)

Line 3 runs before line 2 because line 2 takes time.

Real-World Examples of Asynchronous Operations

Fetching data from a server:

// Fetching data takes time
// You don't want to freeze your app waiting for it
fetch("/api/users")
  .then(response => response.json())
  .then(data => {
    // Handle the data when it arrives
    console.log(data);
  });

Reading a file:

// Reading from disk takes time
const fs = require("fs");

fs.readFile("myfile.txt", function(err, data) {
  // Handle the file when it's read
  console.log(data);
});

User interactions:

// You don't know when the user will click
button.addEventListener("click", function() {
  console.log("Button clicked!");
});

Timers:

// You want something to happen later
setTimeout(function() {
  console.log("After 2 seconds");
}, 2000);

In all these cases, you use a callback to say: "When this operation finishes, call this function."


How Callbacks Work

The Flow

Your code
    ↓
You call a function and pass a callback
    ↓
The function does something asynchronous
    ↓
When it's done, the function calls your callback
    ↓
Your callback runs with the result

Example: Reading a File

const fs = require("fs");

// You pass a callback to readFile
fs.readFile("data.txt", function(err, data) {
  // This function is called when the file is read
  if (err) {
    console.log("Error:", err);
  } else {
    console.log("File contents:", data);
  }
});

console.log("Reading file...");
console.log("This runs immediately");

// Output:
// Reading file...
// This runs immediately
// File contents: (content of data.txt)

Notice: The two console.logs before the file is read both execute immediately. The callback runs later when the file is actually read.

Example: setTimeout

console.log("Start");

setTimeout(function() {
  console.log("After 1 second");
}, 1000);

console.log("End");

// Output:
// Start
// End
// After 1 second
  1. setTimeout() is called with a callback
  2. setTimeout() returns immediately (doesn't wait)
  3. Your code continues and logs "End"
  4. After 1 second, the callback is executed

Callbacks in Common Scenarios

1. Array Methods

JavaScript array methods like map, filter, and forEach use callbacks.

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

// forEach with a callback
numbers.forEach(function(num) {
  console.log(num * 2);
});

// Output:
// 2
// 4
// 6
// 8
// 10

The callback is called once for each item in the array.

// map with a callback
const doubled = numbers.map(function(num) {
  return num * 2;
});

console.log(doubled); // [2, 4, 6, 8, 10]
// filter with a callback
const evenNumbers = numbers.filter(function(num) {
  return num % 2 === 0;
});

console.log(evenNumbers); // [2, 4]

2. Event Listeners

When something happens (like a click), you want to run code. You pass a callback.

const button = document.getElementById("myButton");

button.addEventListener("click", function() {
  console.log("Button was clicked!");
});

When the button is clicked, the callback runs.

document.addEventListener("keydown", function(event) {
  console.log("Key pressed:", event.key);
});

3. setTimeout and setInterval

Execute code after a delay or repeatedly.

// After a delay
setTimeout(function() {
  console.log("3 seconds later");
}, 3000);

// Repeatedly
setInterval(function() {
  console.log("Every 2 seconds");
}, 2000);

4. Reading Files

const fs = require("fs");

fs.readFile("users.json", "utf8", function(err, data) {
  if (err) {
    console.log("Error reading file");
    return;
  }
  
  const users = JSON.parse(data);
  console.log(users);
});

The callback receives the error (if any) and the data.

5. Making HTTP Requests

// Old way with callbacks
fetch("/api/users")
  .then(function(response) {
    return response.json();
  })
  .then(function(data) {
    console.log(data);
  })
  .catch(function(error) {
    console.log(error);
  });

(Note: Modern fetch uses Promises/async-await, but the concept is still callbacks.)

6. Database Queries

const db = require("sqlite3");

db.get("SELECT * FROM users WHERE id = ?", [1], function(err, row) {
  if (err) {
    console.log("Error:", err);
    return;
  }
  
  console.log("User:", row);
});

The callback receives the error and the result.


Passing Callbacks to Functions

Named Function as Callback

function handleClick() {
  console.log("Clicked!");
}

button.addEventListener("click", handleClick);

You can pass a named function.

Anonymous Function as Callback

button.addEventListener("click", function() {
  console.log("Clicked!");
});

Or an anonymous function.

Arrow Function as Callback

button.addEventListener("click", () => {
  console.log("Clicked!");
});

Or an arrow function (modern JavaScript).

Callbacks with Parameters

function processArray(arr, callback) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i], i);  // Pass value and index
  }
}

processArray([10, 20, 30], function(value, index) {
  console.log(`Index \({index}: \){value}`);
});

// Output:
// Index 0: 10
// Index 1: 20
// Index 2: 30

Callbacks with Multiple Operations

function fetchUserAndPosts(userId, onSuccess, onError) {
  // Simulate fetching data
  setTimeout(function() {
    if (userId > 0) {
      const user = { id: userId, name: "John" };
      onSuccess(user);  // Call success callback
    } else {
      onError("Invalid user ID");  // Call error callback
    }
  }, 1000);
}

fetchUserAndPosts(
  1,
  function(user) {
    console.log("Success:", user);
  },
  function(error) {
    console.log("Error:", error);
  }
);

The Callback Problem: Nested Callbacks

As your code grows, callbacks can become nested, creating what's called "callback hell" or "the pyramid of doom."

Simple Nesting

getData(function(a) {
  getMoreData(a, function(b) {
    console.log(b);
  });
});

Two levels of nesting. Still readable.

Deeper Nesting

getUser(userId, function(err, user) {
  if (err) {
    console.log("Error getting user");
    return;
  }
  
  getPostsByUser(user.id, function(err, posts) {
    if (err) {
      console.log("Error getting posts");
      return;
    }
    
    getComments(posts[0].id, function(err, comments) {
      if (err) {
        console.log("Error getting comments");
        return;
      }
      
      console.log(comments);
    });
  });
});

Now we have three levels of nesting. The code looks like a pyramid. Each callback is nested inside the previous one.

Why This Is a Problem

  1. Hard to read - Your eye has to follow indentation and closing braces
  2. Error handling is complex - You have to handle errors at each level
  3. Hard to reuse - Each callback is buried inside another
  4. Hard to test - You can't easily test individual callbacks
  5. Easy to make mistakes - Closing braces and parentheses are confusing

Visual Problem

getUser(function(user) {              ← Level 1
  getPostsByUser(user.id, function() {  ← Level 2
    getComments(postId, function() {    ← Level 3
      getData(commentId, function() {   ← Level 4
        doSomething();                  ← Deep nesting
      });
    });
  });
});

The pyramid grows →→→→→→→→→→→

Callback Execution Flow

Here's how nested callbacks actually execute:

Step 1: Call getUser
    ↓
Step 2: Wait for user data
    ↓
Step 3: User arrives, callback runs
    ↓
Step 4: Call getPostsByUser
    ↓
Step 5: Wait for posts
    ↓
Step 6: Posts arrive, callback runs
    ↓
Step 7: Call getComments
    ↓
Step 8: Wait for comments
    ↓
Step 9: Comments arrive, callback runs
    ↓
Step 10: Do something with comments

Each operation waits for the previous one to finish. Your code follows this chain of callbacks.


Callbacks vs Promises vs Async-Await

Callbacks are the foundation, but they have problems. JavaScript offers better solutions.

Callbacks (Traditional)

getUser(userId, function(err, user) {
  if (err) return console.log(err);
  
  getPosts(user.id, function(err, posts) {
    if (err) return console.log(err);
    console.log(posts);
  });
});

Nested, hard to read.

Promises (Better)

getUser(userId)
  .then(user => getPosts(user.id))
  .then(posts => console.log(posts))
  .catch(err => console.log(err));

Chains instead of nesting. Easier to read.

Async-Await (Best)

async function showPosts() {
  try {
    const user = await getUser(userId);
    const posts = await getPosts(user.id);
    console.log(posts);
  } catch (err) {
    console.log(err);
  }
}

Looks like synchronous code. Easiest to read.

All three use callbacks underneath. Promises and async-await just handle them in a cleaner way.


Complete Example: File Processing with Callbacks

const fs = require("fs");

// Read a file, process it, and write results
function processFile(inputPath, outputPath, callback) {
  // Step 1: Read the file
  fs.readFile(inputPath, "utf8", function(err, data) {
    if (err) {
      return callback(err);  // Return error to callback
    }
    
    // Step 2: Process the data
    const processed = data.toUpperCase();
    
    // Step 3: Write the result
    fs.writeFile(outputPath, processed, function(err) {
      if (err) {
        return callback(err);
      }
      
      // Step 4: Call the callback with success
      callback(null, "File processed successfully");
    });
  });
}

// Use the function
processFile("input.txt", "output.txt", function(err, message) {
  if (err) {
    console.log("Error:", err);
  } else {
    console.log(message);
  }
});

Notice:

  • Callbacks are nested (file reading inside, file writing inside that)
  • Errors are passed to the callback as the first argument
  • Success message is passed as the second argument
  • The pattern is: callback(error, result)

Practice Assignment

1. Create a simple callback function:

function greet(name, callback) {
  // Your code here
  // Call the callback with a greeting message
}

greet("Alice", function(message) {
  console.log(message);  // Should print a greeting
});

2. Pass a function to array methods:

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

// Use forEach with a callback
numbers.forEach(function(num) {
  // Print each number
});

// Use map with a callback
const squared = numbers.map(function(num) {
  // Return the square of each number
});

// Use filter with a callback
const evens = numbers.filter(function(num) {
  // Return true if even
});

3. Create an event listener:

const button = document.getElementById("myButton");

button.addEventListener("click", function() {
  // Your callback code here
  console.log("Button clicked");
});

4. Use setTimeout with a callback:

// Create a function that calls a callback after a delay
function delayedAction(milliseconds, callback) {
  // Your code here
  // Use setTimeout to call the callback after the delay
}

delayedAction(2000, function() {
  console.log("This runs after 2 seconds");
});

5. Identify the callback hell problem:

// Look at this code - count how many levels of nesting
fetchUser(1, function(user) {
  fetchPosts(user.id, function(posts) {
    fetchComments(posts[0].id, function(comments) {
      fetchAuthor(comments[0].userId, function(author) {
        console.log(author);
      });
    });
  });
});

// Answer: How deep is the nesting?

Common Mistakes

Mistake 1: Forgetting to Call the Callback

// WRONG - callback is defined but never called
function fetchData(callback) {
  const data = { name: "John" };
  // Forgot to call callback!
}

// RIGHT - callback is called with data
function fetchData(callback) {
  const data = { name: "John" };
  callback(data);  // Call it!
}

Mistake 2: Not Handling Errors

// WRONG - errors are ignored
function readFile(path, callback) {
  fs.readFile(path, function(err, data) {
    callback(data);  // What about err?
  });
}

// RIGHT - pass errors to callback
function readFile(path, callback) {
  fs.readFile(path, function(err, data) {
    callback(err, data);  // Pass both error and data
  });
}

Mistake 3: Calling Callback Synchronously When It Should Be Async

// WRONG - callback runs immediately, defeating the purpose
function fetchData(callback) {
  const data = { name: "John" };
  callback(data);  // Runs immediately
}

// RIGHT - callback runs after an async operation
function fetchData(callback) {
  setTimeout(function() {
    const data = { name: "John" };
    callback(data);  // Runs after 1ms
  }, 1);
}

Mistake 4: Passing Arguments Incorrectly

// WRONG - callback will be called immediately
button.addEventListener("click", handleClick(userId));

// RIGHT - pass the function, not the result
button.addEventListener("click", function() {
  handleClick(userId);
});

// Also right - if handleClick takes a parameter
function handleClick(e) {
  // e is the event object
}
button.addEventListener("click", handleClick);

Mistake 5: Not Understanding Function Scope

// WRONG - i changes before callback runs
for (var i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i);  // Prints 3, 3, 3
  }, 1000);
}

// RIGHT - use let (block scoping)
for (let i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i);  // Prints 0, 1, 2
  }, 1000);
}

Quick Recap

  • A callback is a function you pass to another function as an argument.

  • The other function calls it at some point (immediately or later).

  • Functions are values in JavaScript. You can pass them around like any other data.

  • Callbacks are used for asynchronous operations - when something takes time and you want to do something when it's done.

  • Common callback scenarios:

    • File operations (reading, writing)
    • Network requests (fetching data)
    • Event listeners (clicks, keypresses)
    • Timers (setTimeout, setInterval)
    • Array methods (map, filter, forEach)
  • The error-first callback pattern: callback(error, result)

    • First argument is an error (or null if no error)
    • Second argument is the result
  • Callback hell happens when you nest callbacks too deeply.

    • Hard to read
    • Hard to maintain
    • Hard to handle errors
  • Solutions exist: Promises and async-await solve callback hell.

  • Callbacks are the foundation - understanding them helps you understand Promises and async-await.

Callbacks are fundamental to JavaScript. Master them and async programming becomes much clearer.

Happy coding! 🚀


If you enjoyed this article, check out my other blogs on this profile. 🔗 Connect with me: LinkedIn | GitHub | X (Twitter)