Skip to content
JavaScript js functions 4 min read

Callback Functions

A callback is simply a function you pass to another function so that it can be called back later — either right away or at some point in the future. Because JavaScript treats functions as first-class values, you can hand one off the same way you pass a number or a string. Callbacks are the mechanism behind array iteration, event handling, timers, and every older asynchronous API. Understanding them is the first step toward mastering promises and async/await.

What makes a callback

Any function that accepts another function as an argument is a higher-order function, and the function it receives is the callback. The higher-order function decides when and with what arguments to invoke the callback.

function greet(name, formatter) {
  return `Hello, ${formatter(name)}!`;
}

const shout = (str) => str.toUpperCase();

console.log(greet("ada", shout));

Output:

Hello, ADA!

Here formatter is the callback. Notice we pass shout without parentheses — we hand over the function itself, not its result. Adding () would call it immediately and pass the return value instead.

Gotcha: setTimeout(doWork(), 1000) runs doWork now and schedules its return value. You almost always want setTimeout(doWork, 1000) — the bare reference.

Synchronous callbacks

A synchronous callback runs immediately, in order, before the higher-order function returns. The array iteration methods are the most common example: you describe what to do with each element, and the method handles the loop.

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

const doubled = nums.map((n) => n * 2);
const evens = nums.filter((n) => n % 2 === 0);
const total = nums.reduce((sum, n) => sum + n, 0);

console.log(doubled);
console.log(evens);
console.log(total);

Output:

[ 2, 4, 6, 8, 10 ]
[ 2, 4 ]
15

Each callback receives well-defined arguments. For most array methods that is (element, index, array):

["a", "b", "c"].forEach((item, index) => {
  console.log(`${index}: ${item}`);
});

Output:

0: a
1: b
2: c

Writing the logic as a callback keeps it reusable and declarative — the iteration lives in map/filter, while your intent lives in the function you pass.

Asynchronous callbacks

An asynchronous callback is stored and invoked later, after the current code finishes — when a timer fires, an event occurs, or I/O completes. The function returns immediately, and the callback runs on a future tick of the event loop.

console.log("start");

setTimeout(() => {
  console.log("…1 second later");
}, 1000);

console.log("end");

Output:

start
end
…1 second later

Even though setTimeout appears second, its callback prints last because it is deferred. Event listeners follow the same pattern — register a callback once, and the browser invokes it every time the event happens:

const button = document.querySelector("#save");

button.addEventListener("click", (event) => {
  console.log("Saved by", event.target.id);
});

In Node.js, classic APIs use the error-first callback convention: the first parameter is an error (or null), and the result follows.

import { readFile } from "node:fs";

readFile("./config.json", "utf8", (err, data) => {
  if (err) {
    console.error("Failed to read file:", err.message);
    return;
  }
  console.log(data);
});

Synchronous vs asynchronous at a glance

AspectSynchronous callbackAsynchronous callback
When it runsImmediately, inlineLater, on a future event-loop tick
Blocks following codeYesNo
Typical examplesmap, filter, sort, forEachsetTimeout, events, fs.readFile
Error handlingtry/catch worksPass error to the callback (error-first)

The road to callback hell

Asynchronous callbacks compose poorly. When one async step depends on the previous, you end up nesting callbacks inside callbacks — the deeply indented “pyramid of doom” known as callback hell.

loginUser("ada", (err, user) => {
  if (err) return handle(err);
  getProfile(user.id, (err, profile) => {
    if (err) return handle(err);
    getPosts(profile.handle, (err, posts) => {
      if (err) return handle(err);
      console.log(posts); // finally!
    });
  });
});

Each level repeats error handling, drifts further right, and is hard to read or refactor. This exact pain motivated Promises and async/await, which flatten the same flow into linear, readable steps. We cover that transition in the async section of these docs.

Tip: Before reaching for callbacks for async work, ask whether a Promise-based API exists. Most modern browser and Node APIs (fetch, fs/promises) return promises you can await.

Best Practices

  • Pass the function reference (doWork), not a call (doWork()), unless you intentionally want the return value.
  • Keep callbacks small and named when reused; inline arrow functions are fine for short, one-off logic.
  • Follow the error-first convention for Node-style callbacks, and always handle the error branch.
  • Prefer arrow functions for callbacks so this is inherited from the surrounding scope.
  • For dependent async steps, avoid deep nesting — extract named functions or, better, switch to promises and async/await.
  • Don’t perform heavy, blocking work inside synchronous callbacks like forEach; it freezes everything until it returns.
Last updated June 1, 2026
Was this helpful?