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)runsdoWorknow and schedules its return value. You almost always wantsetTimeout(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
| Aspect | Synchronous callback | Asynchronous callback |
|---|---|---|
| When it runs | Immediately, inline | Later, on a future event-loop tick |
| Blocks following code | Yes | No |
| Typical examples | map, filter, sort, forEach | setTimeout, events, fs.readFile |
| Error handling | try/catch works | Pass 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 canawait.
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
thisis 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.