What is a Callback Function in JS? Full Tutorial

Learn what callback functions are in JavaScript and how they work. Covers synchronous callbacks with array methods, asynchronous callbacks with setTimeout and events, error-first callbacks, callback patterns, and when to use callbacks vs promises.

JavaScriptbeginner
11 min read

A callback function is a function passed as an argument to another function, which then calls it at the right time. When you pass handleClick to addEventListener, or pass an arrow function to .map(), you are using callbacks. They are one of the most fundamental patterns in JavaScript because the language relies on them for asynchronous operations, event handling, and array transformations.

This tutorial explains what callbacks are, shows synchronous and asynchronous examples, covers the error-first pattern from Node.js, and addresses the problem known as callback hell.

What Makes a Function a Callback

A callback is not a special type of function. It is any function passed as an argument to another function:

javascriptjavascript
function greet(name) {
  console.log(`Hello, ${name}!`);
}
 
function processUserInput(callback) {
  const name = "Alice";
  callback(name); // calling the callback
}
 
processUserInput(greet);
// Hello, Alice!

The function greet becomes a callback when it is passed to processUserInput. The receiving function decides when and how to call it.

Named vs Anonymous Callbacks

javascriptjavascript
// Named callback
function double(n) {
  return n * 2;
}
const results1 = [1, 2, 3].map(double);
 
// Anonymous callback
const results2 = [1, 2, 3].map(function (n) {
  return n * 2;
});
 
// Arrow function callback
const results3 = [1, 2, 3].map((n) => n * 2);
 
// All produce [2, 4, 6]
StyleReusableReadableDebuggable
Named functionYesHighStack trace shows name
Anonymous functionNoMediumStack trace shows anonymous
Arrow functionNoHigh for short logicStack trace shows anonymous

Synchronous Callbacks

Synchronous callbacks run immediately, before the calling function returns. Array methods are the most common example:

Array Method Callbacks

javascriptjavascript
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
 
// .filter() calls the callback for each element
const evens = numbers.filter((n) => n % 2 === 0);
console.log(evens); // [2, 4, 6, 8, 10]
 
// .map() transforms each element using the callback
const doubled = numbers.map((n) => n * 2);
console.log(doubled); // [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
 
// .reduce() accumulates a result using the callback
const sum = numbers.reduce((total, n) => total + n, 0);
console.log(sum); // 55
 
// .forEach() runs the callback for each element (no return value)
numbers.forEach((n) => console.log(n));

Custom Synchronous Callback

javascriptjavascript
function repeat(times, callback) {
  for (let i = 0; i < times; i++) {
    callback(i);
  }
}
 
repeat(3, (index) => {
  console.log(`Iteration ${index}`);
});
// Iteration 0
// Iteration 1
// Iteration 2

Sort with a Callback

javascriptjavascript
const users = [
  { name: "Charlie", age: 35 },
  { name: "Alice", age: 28 },
  { name: "Bob", age: 32 },
];
 
// The comparison callback determines sort order
users.sort((a, b) => a.age - b.age);
 
console.log(users);
// [{ name: "Alice", age: 28 }, { name: "Bob", age: 32 }, { name: "Charlie", age: 35 }]
Synchronous Callbacks Run in Order

Synchronous callbacks execute in sequence. The code after the function call does not run until all callbacks have finished. This is different from asynchronous callbacks, which are scheduled for later.

Asynchronous Callbacks

Asynchronous callbacks run later, after some operation completes. JavaScript uses them for timers, events, network requests, and file operations.

setTimeout and setInterval

javascriptjavascript
console.log("Before");
 
setTimeout(() => {
  console.log("Inside timeout (after 1 second)");
}, 1000);
 
console.log("After");
 
// Output order:
// Before
// After
// Inside timeout (after 1 second)

The callback passed to setTimeout does not run immediately. JavaScript schedules it and continues executing the code below.

javascriptjavascript
// setInterval calls the callback repeatedly
let count = 0;
const intervalId = setInterval(() => {
  count++;
  console.log(`Tick ${count}`);
 
  if (count >= 3) {
    clearInterval(intervalId);
    console.log("Stopped");
  }
}, 1000);
// Tick 1 (after 1s)
// Tick 2 (after 2s)
// Tick 3 (after 3s)
// Stopped

Event Listeners

javascriptjavascript
// DOM event callbacks
document.getElementById("btn").addEventListener("click", (event) => {
  console.log("Button clicked!");
  console.log(`Target: ${event.target.tagName}`);
});
 
// The callback runs only when the user clicks the button
// It could be called 0 times, 1 time, or 100 times

Multiple Callbacks: Success and Error

javascriptjavascript
function fetchUser(id, onSuccess, onError) {
  // Simulating an API call
  setTimeout(() => {
    if (id > 0) {
      onSuccess({ id, name: "Alice", email: "alice@test.com" });
    } else {
      onError(new Error("Invalid user ID"));
    }
  }, 500);
}
 
fetchUser(
  1,
  (user) => console.log("Got user:", user),
  (error) => console.log("Error:", error.message)
);
// Got user: { id: 1, name: "Alice", email: "alice@test.com" }
 
fetchUser(
  -1,
  (user) => console.log("Got user:", user),
  (error) => console.log("Error:", error.message)
);
// Error: Invalid user ID

The Error-First Callback Pattern

Node.js established a convention where callbacks always receive an error as the first argument:

javascriptjavascript
function readConfig(path, callback) {
  // Simulated file read
  setTimeout(() => {
    if (path === "/valid/config.json") {
      callback(null, { theme: "dark", lang: "en" });
    } else {
      callback(new Error(`File not found: ${path}`), null);
    }
  }, 100);
}
 
// Error-first pattern: always check error first
readConfig("/valid/config.json", (error, data) => {
  if (error) {
    console.log("Failed:", error.message);
    return;
  }
  console.log("Config:", data);
});
// Config: { theme: "dark", lang: "en" }
 
readConfig("/missing/file.json", (error, data) => {
  if (error) {
    console.log("Failed:", error.message);
    return;
  }
  console.log("Config:", data);
});
// Failed: File not found: /missing/file.json
PositionContainsWhen present
First argumentError or nullAlways
Second argumentResult dataOnly when error is null
Always Check the Error

In error-first callbacks, always check if the error argument is truthy before using the result. Skipping the error check can lead to using null or undefined data and hiding bugs.

How JavaScript Manages Async Callbacks

When JavaScript encounters an async callback (like setTimeout), it does not stop and wait. It hands the timer to the browser or Node.js runtime and continues running the next line. When the timer finishes, the callback is placed in a queue. The event loop picks it up and runs it when the call stack is empty:

javascriptjavascript
console.log("1: Start");
 
setTimeout(() => {
  console.log("2: Timeout callback");
}, 0); // even with 0ms delay
 
console.log("3: End");
 
// Output:
// 1: Start
// 3: End
// 2: Timeout callback

Even a 0ms timeout runs after the current code finishes because the callback goes through the event loop queue.

javascriptjavascript
// Visualizing the sequence
console.log("A");
 
setTimeout(() => console.log("B"), 0);
setTimeout(() => console.log("C"), 0);
 
console.log("D");
 
// A, D, B, C
// Synchronous code runs first, then queued callbacks in order

Callback Hell: The Nesting Problem

When multiple asynchronous operations depend on each other, callbacks nest deeper and deeper:

javascriptjavascript
// Each step depends on the previous result
getUser(userId, (error, user) => {
  if (error) {
    handleError(error);
    return;
  }
  getOrders(user.id, (error, orders) => {
    if (error) {
      handleError(error);
      return;
    }
    getOrderDetails(orders[0].id, (error, details) => {
      if (error) {
        handleError(error);
        return;
      }
      getShipping(details.shipmentId, (error, tracking) => {
        if (error) {
          handleError(error);
          return;
        }
        displayTrackingInfo(tracking);
      });
    });
  });
});

This pyramid of nested callbacks is hard to read, debug, and maintain. Solutions include:

SolutionApproachComplexity
Named functionsExtract each callback into a named functionLow
PromisesChain .then() calls instead of nestingMedium
async/awaitWrite async code that looks synchronousLow

Fixing with Named Functions

javascriptjavascript
function handleTracking(error, tracking) {
  if (error) return handleError(error);
  displayTrackingInfo(tracking);
}
 
function handleDetails(error, details) {
  if (error) return handleError(error);
  getShipping(details.shipmentId, handleTracking);
}
 
function handleOrders(error, orders) {
  if (error) return handleError(error);
  getOrderDetails(orders[0].id, handleDetails);
}
 
function handleUser(error, user) {
  if (error) return handleError(error);
  getOrders(user.id, handleOrders);
}
 
getUser(userId, handleUser);

The logic is the same, but the code is flat and each step has a descriptive name.

Practical Patterns

Retry with Callback

javascriptjavascript
function retryOperation(operation, maxRetries, callback) {
  let attempts = 0;
 
  function attempt() {
    attempts++;
    operation((error, result) => {
      if (!error) {
        callback(null, result);
        return;
      }
 
      if (attempts >= maxRetries) {
        callback(new Error(`Failed after ${maxRetries} attempts: ${error.message}`));
        return;
      }
 
      console.log(`Attempt ${attempts} failed, retrying...`);
      setTimeout(attempt, 1000 * attempts); // exponential-ish backoff
    });
  }
 
  attempt();
}

Callback-Based Array Processing

javascriptjavascript
function processItems(items, processFn, callback) {
  const results = [];
  let completed = 0;
 
  if (items.length === 0) {
    callback(null, []);
    return;
  }
 
  items.forEach((item, index) => {
    processFn(item, (error, result) => {
      if (error) {
        callback(error);
        return;
      }
 
      results[index] = result;
      completed++;
 
      if (completed === items.length) {
        callback(null, results);
      }
    });
  });
}
 
// Usage
processItems(
  [1, 2, 3],
  (num, cb) => setTimeout(() => cb(null, num * 2), 100),
  (error, results) => console.log(results) // [2, 4, 6]
);

Middleware Pattern

javascriptjavascript
function runMiddleware(middlewares, context, done) {
  let index = 0;
 
  function next(error) {
    if (error) {
      done(error);
      return;
    }
 
    if (index >= middlewares.length) {
      done(null, context);
      return;
    }
 
    const middleware = middlewares[index++];
    middleware(context, next);
  }
 
  next();
}
 
// Usage
const middlewares = [
  (ctx, next) => { ctx.timestamp = Date.now(); next(); },
  (ctx, next) => { ctx.user = "Alice"; next(); },
  (ctx, next) => { ctx.authorized = true; next(); },
];
 
runMiddleware(middlewares, {}, (error, result) => {
  console.log(result);
  // { timestamp: 1738746000000, user: "Alice", authorized: true }
});

Common Mistakes

Forgetting to Return After Error

javascriptjavascript
// BUG: code continues after error
function process(data, callback) {
  if (!data) {
    callback(new Error("No data"));
    // Missing return! Code below still runs
  }
  // This line runs even when data is null
  const result = data.toUpperCase(); // TypeError!
  callback(null, result);
}
 
// FIX: always return after calling back with an error
function process(data, callback) {
  if (!data) {
    return callback(new Error("No data")); // return stops execution
  }
  const result = data.toUpperCase();
  callback(null, result);
}

Calling the Callback Multiple Times

javascriptjavascript
// BUG: callback called twice
function search(items, query, callback) {
  items.forEach((item) => {
    if (item === query) {
      callback(null, item); // called for EVERY match!
    }
  });
  callback(new Error("Not found")); // also called even if found
}
 
// FIX: use a flag or return early
function search(items, query, callback) {
  const found = items.find((item) => item === query);
  if (found) {
    callback(null, found);
  } else {
    callback(new Error("Not found"));
  }
}
Rune AI

Rune AI

Key Insights

  • A callback is any function passed as an argument: it describes usage, not structure
  • Synchronous callbacks run immediately: array methods like map and filter use sync callbacks
  • Async callbacks run later via the event loop: timers, events, and I/O use async callbacks
  • Error-first pattern: always check the first argument for errors before using results
  • Avoid deep nesting: use named functions, promises, or async/await to flatten callback chains
RunePowered by Rune AI

Frequently Asked Questions

What is the difference between a callback and a regular function?

There is no structural difference. A callback is a regular function that happens to be passed as an argument to another function. Any function can be a callback when used in that way. The term describes how the function is used, not how it is defined.

When should I use callbacks instead of promises?

Use callbacks for event listeners (like `addEventListener`), array methods (like `map`, `filter`, `forEach`), and simple one-time operations. Use promises or async/await for sequences of asynchronous operations, [error handling](/tutorials/programming-languages/javascript/how-to-read-and-understand-javascript-stack-traces) chains, and anywhere you need to avoid deep nesting.

Can async functions be used as callbacks?

Yes. You can pass an `async` function as a callback: `array.map(async (item) => await process(item))`. However, the calling function may not await the promise. `forEach` with async callbacks does not wait for them to complete. Use `Promise.all` with `map` instead for parallel async processing.

Why does my callback sometimes run before I expect it to?

If the callback is synchronous (like in `map` or `filter`), it runs immediately. If you expected it to run later, you may need to wrap it in `setTimeout` or use it with an actually asynchronous operation. Mixed sync/async behavior in a function is a common source of bugs.

How do I convert callback-based code to promises?

Wrap the callback function in a `new Promise`: `function readFilePromise(path) { return new Promise((resolve, reject) => { readFile(path, (err, data) => { if (err) reject(err); else resolve(data); }); }); }`. Node.js also provides `util.promisify()` to do this automatically.

Conclusion

Callback functions are functions passed as arguments to other functions. They are the foundation of event handling, array processing, and asynchronous programming in JavaScript. Synchronous callbacks (array methods, sort comparisons) run immediately in sequence. Asynchronous callbacks (timers, events, network requests) are scheduled by the runtime and run later through the event loop. The error-first pattern (callback(error, result)) standardizes error handling. When callbacks nest too deeply, extract named functions, or migrate to promises and async/await for cleaner control flow.