Understanding Callbacks in JavaScript
How functions become arguments — and why that changes everything about how you write async code.
Functions are values In most languages, functions are special constructs you define and call. In JavaScript, they're something more: functions are values, just like strings, numbers, and arrays.
That means you can assign a function to a variable, store it in an object, put it in an array — and most importantly for this article, pass it to another function as an argument.
javascript
// Functions can be assigned to variables const greet = function(name) { console.log(Hello, ${name}!); };
greet("Faisal"); // Hello, Faisal!
// Functions can be stored in arrays or objects const actions = [greet, console.log]; actions; // Hello, World!
This property — where functions can be treated like any other value — is called first-class functions. It's what makes JavaScript's callback pattern possible.
What is a callback function? A callback is a function you pass to another function, with the expectation that the receiving function will call it at some point.
The name makes sense: you're handing off a function and saying "call me back when you're ready." The function that receives it decides when to invoke it.
javascript
function runTwice(fn) { fn(); // call the callback once fn(); // call it again }
function sayHi() { console.log("Hi!"); }
runTwice(sayHi); // Hi! // Hi!
Notice: when passing sayHi as an argument, there are no parentheses. Writing runTwice(sayHi) passes the function itself. Writing runTwice(sayHi()) would call it immediately and pass its return value (undefined) instead — a very common bug.
Diagram — Function calling another function EXECUTION FLOW 1 Define the callback function sayHi() { console.log("Hi!"); } 2 Pass it as an argument (no parentheses) runTwice(sayHi) 3 Outer function invokes it when ready fn() ← your sayHi runs here Why callbacks exist: async programming JavaScript runs on a single thread. If it waited for every network request, file read, or database query to complete before moving on, your application would freeze completely.
Callbacks solve this. Instead of waiting, you say: "start this task, and when it finishes, call this function with the result." The rest of your code keeps running in the meantime.
javascript
console.log("1 — Start");
setTimeout(function() { console.log("3 — Done after 2 seconds"); }, 2000);
console.log("2 — End");
// Console output: 1 — Start 2 — End 3 — Done after 2 seconds The callback (the function inside setTimeout) doesn't run immediately. JavaScript registers it, moves on to print "2 — End", and only invokes the callback once the 2-second timer fires. This is the event loop in action.
Passing functions as arguments There are three common styles for writing callbacks. All three are equivalent — it's a matter of readability and context.
javascript — three equivalent styles
// 1. Named function (defined elsewhere) function handleClick() { console.log("Clicked!"); } button.addEventListener("click", handleClick);
// 2. Anonymous function expression (defined inline) button.addEventListener("click", function() { console.log("Clicked!"); });
// 3. Arrow function (concise, modern syntax) button.addEventListener("click", () => { console.log("Clicked!"); });
The parentheses trap. Pass handleClick, not handleClick(). Adding parentheses calls the function immediately and passes its return value (usually undefined) — not the function itself. This is one of the most common beginner bugs.
Callbacks in common scenarios , Array methods map, filter, forEach, reduce all accept a callback that runs on each element.
Event listeners Browser events — clicks, keypresses, scroll — are handled entirely through callbacks.
Timers setTimeout and setInterval schedule callbacks to run after a delay or on a loop.
Fetch / XHR Older APIs used callbacks to handle HTTP responses when data arrived.
Array methods
javascript
const numbers = [1, 2, 3, 4, 5];
// map — transform each element const doubled = numbers.map(n => n * 2); // [2, 4, 6, 8, 10]
// filter — keep elements that pass the test const evens = numbers.filter(n => n % 2 === 0); // [2, 4]
// forEach — run a side effect on each element numbers.forEach(n => console.log(n)); // 1, 2, 3, 4, 5
Event listeners
javascript
const btn = document.getElementById("submit-btn");
btn.addEventListener("click", (event) => { event.preventDefault(); console.log("Form submitted!"); });
// The callback receives the event object automatically window.addEventListener("resize", () => { console.log("Window is now", window.innerWidth, "px wide"); });
Node.js file system (classic callback style)
javascript
const fs = require("fs");
// callback receives (error, data) — a Node.js convention fs.readFile("config.json", "utf8", function(err, data) { if (err) { console.error("Failed to read:", err.message); return; } console.log("File contents:", data); });
The callback nesting problem Callbacks work fine for a single async operation. The trouble starts when you need to chain several of them — each one depending on the result of the previous.
javascript — callback hell
login(user, function(err, session) { if (err) return handleError(err);
getProfile(session.id, function(err, profile) { if (err) return handleError(err);
getSettings(profile.id, function(err, settings) {
if (err) return handleError(err);
render(settings, function(err, result) {
if (err) return handleError(err);
console.log("Finally done ✓");
});
});
}); });
Diagram — Nested callback execution flow login(user, callback ↓ getProfile(session.id, callback ↓ getSettings(profile.id, callback ↓ render(settings, function(err, result) { console.log("Finally done ✓"); depth 1 depth 2 depth 3 depth 4 This is called callback hell (or the pyramid of doom). Each async step adds a layer of nesting, and the code grows to the right faster than it grows down. A few specific problems emerge:
Readability. Logic is scattered across multiple indentation levels. It's hard to follow what depends on what. Error handling. Each callback must handle its own error, creating repetitive boilerplate. Modification. Adding a step in the middle requires restructuring deep nesting. Reuse. Deeply nested callbacks are difficult to extract and test independently. What comes after callbacks? The callback nesting problem is exactly what motivated Promises and async/await. Promises flatten the pyramid into a readable chain. Async/await makes that chain look almost synchronous. Understanding callbacks deeply is the prerequisite for both — and now you have that foundation.
Key takeaways JavaScript functions are first-class values — they can be passed around like any other data. A callback is a function passed to another function, to be called at a later point. Callbacks power async programming: respond to timers, events, and data without blocking the thread. Pass the function reference (fn), not the invocation (fn()), when using callbacks. Common uses: setTimeout, addEventListener, Array.map, Array.filter. Deeply nested callbacks create callback hell — the problem that Promises and async/await solve.
