Callbacks are one of the first concepts you’ll meet when learning JavaScript, especially when dealing with events, timers, and network requests. A callback is simply a function you pass into another function so it can be called later. While the idea is simple, using callbacks
correctly helps you write clearer, more reliable code and prepares you for understanding Promises and async/await.
What You’ll Learn
- What callbacks are and why they’re useful
- The difference between synchronous and asynchronous callbacks
- How to write and use callbacks in everyday scenarios (events, timers, array methods)
- How Node.js style "error-first" callbacks work
- Common pitfalls (e.g., callback hell, multiple calls, lost this) and how to avoid them
- How callbacks relate to Promises and async/await
What Is a Callback?
A callback
is a function provided as an argument to another function. The receiving function chooses when to run it.
function greet(name, formatter) { // formatter is the callback
const formatted = formatter(name);
console.log(`Hello, ${formatted}!`);
}
function toUpper(text) {
return text.toUpperCase();
}
greet("Wisdom", toUpper); // → Hello, WISDOM!
Here, toUpper is passed into greet. greet uses the callback to change the name before printing it out.
Synchronous vs Asynchronous Callbacks
Synchronous callbacks run immediately, during the current function’s execution.
[1, 2, 3].map(function double(n) { // synchronous callback
return n * 2;
});
These callbacks are triggered later, following the completion of tasks such as network calls or timeouts.
console.log("A");
setTimeout(() => { // asynchronous callback
console.log("B (after 1s)");
}, 1000);
console.log("C");
// Order: A, C, then B
JavaScript relies on asynchronous callbacks to support its non-blocking nature in both the browser and Node.js.
Everyday Callback Patterns
Event Listeners
document.getElementById("btn").addEventListener("click", function handleClick() {
console.log("Button clicked!");
});
Timers
setTimeout(() => console.log("Runs once after 500ms"), 500);
const id = setInterval(() => console.log("Repeats every second"), 1000);
// clearInterval(id) to stop
Array Methods
const nums = [1, 2, 3, 4, 5];
const evens = nums.filter(n => n % 2 === 0); // callback decides keep or drop
const squares = nums.map(n => n * 2); // callback transforms
nums.forEach(n => console.log(n)); // callback performs side-effect
These built-ins make functional patterns natural and concise.
Node.js “Error-First” Callbacks
Many Node.js APIs (and older libraries) use the error-first convention: the callback receives an error as the first argument (if any), and the result as the second.
fs.readFile("data.txt", "utf8", function (err, content) {
if (err) {
console.error("Failed to read file:", err);
return;
}
console.log("File contents:", content);
});
Why: The caller can handle success and failure in one place.
Tip: Always return or else after handling an error to avoid executing the success logic accidentally.
Common Pitfalls (and How to Avoid Them)
Callback Hell (Deep Nesting)
Code with many nested callbacks is often unreadable and tough to work with.
getUser(id, user => {
getOrders(user.id, orders => {
getOrderDetails(orders[0], details => {
// ... more nesting ...
});
});
});
Avoid: Extract named functions or move to Promises/async/await.
function onDetails(details) { /* ... */ }
function onOrders(orders) { getOrderDetails(orders[0], onDetails); }
function onUser(user) { getOrders(user.id, onOrders); }
getUser(id, onUser);
Multiple or Missing Invocations
A callback might be called more than once or not at all; both are bugs.
Avoid: Guard with flags or use once-only utilities.
function doTask(callback) {
let called = false;
function once(err, result) {
if (called) return; called = true;
callback(err, result);
}
// call once(...) exactly once in all code paths
}
Lost this Context
When passing object methods as callbacks, this may be lost.
const counter = {
value: 0,
inc() { this.value++; }
};
setTimeout(counter.inc, 100); // ‘this’ is undefined.
setTimeout(counter.inc.bind(counter), 100); // correct
Inversion of Control
With callbacks, you hand control to another function/library. When a callback behaves unexpectedly, for example, running more than once, your program may fail. To avoid this, use reliable libraries that enforce predictable execution.
From Callbacks to Promises and Async/Await
Modern tools like Promises and async/await grew out of callbacks, offering more readable async code.
// Wrapping a Callback in a Promise
function readFileP(path, encoding = "utf8") {
return new Promise((resolve, reject) => {
fs.readFile(path, encoding, (err, data) => {
if (err) reject(err); else resolve(data);
});
});
}
// Usage with async/await
(async () => {
try {
const data = await readFileP("data.txt");
console.log(data);
} catch (e) {
console.error(e);
}
})();
Takeaway: Callbacks provide the foundation that makes learning Promises and async/await far more straightforward.
Best Practices Checklist
- Prefer named callbacks for readability and reuse
- Keep callback functions small and single-purpose
- In Node.js, follow the error-first pattern consistently
- Ensure callbacks are called exactly once in all code paths
- Avoid deep nesting: extract functions or switch to Promises/async
- Mind
this
binding: use .bind, arrow functions, or class fields
Conclusion
Callbacks are fundamental to JavaScript. Callbacks
allow you to share behavior, react to events, and manage asynchronous tasks. When you learn their core patterns, avoid common mistakes, and see how they link to Promises and async/await, your JavaScript will become cleaner, more reliable, and ready for real-world use in both the browser and Node.js.
You can reach out to me via LinkedIn