A callback is simply a function passed into another function as an argument, to be called (invoked) later — either immediately, or after some work finishes. This is possible because JavaScript treats functions as "first-class" values: they can be passed around and stored in variables just like a number or a string.

Callbacks are the foundation that asynchronous JavaScript was originally built on, which makes them worth understanding even though modern code often reaches for Promises instead.

A Simple Synchronous Callback

function processArray(arr, callback) {
  const result = [];
  for (const item of arr) {
    result.push(callback(item));
  }
  return result;
}

function double(n) {
  return n * 2;
}

console.log(processArray([1, 2, 3], double));

Output:

[ 2, 4, 6 ]

Here, double is the callback — processArray doesn't know or care what it does, it just calls it on each item. This is the same idea behind built-in array methods like map(), filter(), and reduce(), which all accept a callback function too.

Asynchronous Callbacks

Callbacks become especially important for operations that take time — a network request, a file read, or even just a deliberate delay. Instead of the program pausing and waiting, you hand over a callback that JavaScript will run once the operation finishes, while the rest of your code keeps running in the meantime.

console.log("1. Starting...");

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

console.log("2. This runs immediately");

Output:

1. Starting...
2. This runs immediately
3. This runs after 1 second

Notice the order: line 2 prints before line 3, even though it appears after it in the code. setTimeout doesn't block execution — it schedules the callback to run later and lets the rest of the script continue immediately.

The Problem: Callback Hell

Things get messy once you need several async steps to happen in sequence, each depending on the last — you end up nesting callbacks inside callbacks:

getUser(id, (user) => {
  getOrders(user.id, (orders) => {
    getOrderDetails(orders[0].id, (details) => {
      console.log(details);
      // ...and it keeps growing to the right
    });
  });
});

This deeply nested, rightward-drifting shape is nicknamed "callback hell" — it's hard to read, hard to add error handling to, and easy to get wrong. This exact pain point is what Promises were introduced to solve, and later async/await made asynchronous code read almost like ordinary, top-to-bottom synchronous code.