JavaScript Callbacks
A callback is simply a function that you pass into another function as an argument, so that the receiving function can “call back” and run it later — either immediately, or after some work (like a timer, a network request, or a file read) finishes. Callbacks are the original building block of asynchronous JavaScript, and they still show up everywhere: array methods, event listeners, Node.js APIs, and inside the machinery of Promises and async/await themselves.
Overview / How it works
In JavaScript, functions are first-class values. That means a function can be stored in a variable, put inside an array or object, and — most importantly for this lesson — passed into another function as an argument, just like a number or a string. When a function receives another function as a parameter and invokes it at some point, that parameter is called a callback.
Callbacks come in two flavors, and the difference matters a lot:
- Synchronous callbacks run immediately, during the execution of the outer function, before that function returns. Examples:
array.forEach(callback),array.map(callback),array.sort(compareFn). - Asynchronous callbacks are stored somewhere and invoked later, after the current script has finished running, typically once some operation completes. Examples:
setTimeout(callback, delay), DOM event handlers, Node’s file system callbacks, network responses.
Understanding which kind you’re dealing with is essential, because it changes the order in which your code actually executes. With a synchronous callback, the code reads top to bottom and behaves exactly as written. With an asynchronous callback, JavaScript’s event loop is involved: the engine registers the callback, keeps running the rest of your script, and only invokes the callback once the call stack is empty and the operation (a timer expiring, data arriving, etc.) has completed. This is why a console.log placed after a setTimeout call can print before the timeout’s own callback runs — the outer script doesn’t wait around.
Because callbacks are just references to functions, JavaScript doesn’t need any special syntax to support them — no async keyword, no special object. Any function can accept a callback parameter and call it (or not call it, or call it multiple times — nothing stops you, which is also where bugs sneak in, covered below).
Syntax
There’s no dedicated callback syntax; it’s just a function accepting a function:
function outerFunction(arg1, arg2, callback) {
// ... do some work ...
callback(resultOrError);
}
outerFunction(a, b, function (result) {
// this runs when outerFunction decides to call it
});
| Part | Meaning |
|---|---|
callback parameter |
A placeholder name in the outer function’s signature for whatever function is passed in |
| Callback invocation | callback(...) inside the outer function’s body — this is where control actually transfers |
| Arguments passed to callback | Decided entirely by the outer function; the caller must know this “contract” to use the callback correctly |
| Anonymous vs named function | You can pass an inline arrow/anonymous function, or a separately declared named function — both work identically |
A very common convention in Node.js-style APIs is the error-first callback pattern, where the callback’s first parameter is reserved for an error (or null if none), and the second parameter is the actual result:
function doWork(callback) {
// callback(error, result)
}
Examples
Example 1: A basic synchronous callback
function greet(name, callback) {
console.log(`Hi, ${name}!`);
callback();
}
function afterGreeting() {
console.log('Nice to meet you.');
}
greet('Ava', afterGreeting);
Output:
Hi, Ava!
Nice to meet you.
Here afterGreeting is passed to greet without parentheses — passing afterGreeting() would call it immediately and pass its return value instead of the function itself. Inside greet, the callback runs synchronously, right in the middle of greet‘s own execution, so the output order matches the order the code is written in.
Example 2: An asynchronous callback with setTimeout
console.log('Start');
function fetchData(callback) {
setTimeout(() => {
const data = { id: 1, name: 'Widget' };
callback(data);
}, 1000);
}
fetchData((data) => {
console.log('Received:', data);
});
console.log('End');
Output:
Start
End
Received: { id: 1, name: 'Widget' }
Notice that 'End' prints before 'Received: ...', even though fetchData is called before console.log('End') in the source. setTimeout hands its callback off to the browser/Node timer system and returns immediately, letting the rest of the script continue. Only after the whole synchronous script finishes — and the 1000ms timer expires — does the event loop pull the callback off the queue and run it.
Example 3: The error-first callback pattern
function readConfig(shouldFail, callback) {
setTimeout(() => {
if (shouldFail) {
callback(new Error('Config file not found'));
return;
}
callback(null, { theme: 'dark', version: '1.2.0' });
}, 500);
}
readConfig(false, (err, config) => {
if (err) {
console.error('Failed to load config:', err.message);
return;
}
console.log('Config loaded:', config);
});
readConfig(true, (err, config) => {
if (err) {
console.error('Failed to load config:', err.message);
return;
}
console.log('Config loaded:', config);
});
Output:
Config loaded: { theme: 'dark', version: '1.2.0' }
Failed to load config: Config file not found
Both calls schedule a 500ms timer, so their callbacks fire in the order they were scheduled (first in, first out for equal delays) — the successful call’s callback runs first, then the failing one’s. Inside each callback, checking err first is the standard error-first convention: if err is truthy, handle the failure and stop; otherwise, use the result.
How it works step by step / Under the hood
For an asynchronous callback like setTimeout, here’s what the JavaScript engine and runtime actually do:
- The engine executes your script’s top-level code synchronously, pushing and popping function calls on the call stack as usual.
- When it hits
setTimeout(callback, delay), the call stack doesn’t wait. The runtime (not the JS engine itself — this is a browser/Node API) starts a timer and immediately returns control, poppingsetTimeoutoff the stack. - Your script keeps running every remaining synchronous line, including anything after the
setTimeoutcall. - Once the call stack is completely empty (the whole synchronous script has finished), the event loop checks whether any timers have expired.
- When the timer’s delay has elapsed, the runtime places the callback into a task queue (the “macrotask” queue for timers).
- The event loop pulls the callback from that queue and pushes it onto the now-empty call stack, executing it just like any normal function call.
This is also why a delay of 0 in setTimeout(fn, 0) doesn’t run fn immediately — it still has to wait for the current call stack to empty and for its turn in the queue, which is why it always runs after any synchronous code that follows it.
Common Mistakes
Mistake 1: Forgetting to stop execution after calling the callback on an error path
If you call the callback inside an if block but forget to return afterward, execution falls through and the callback can be invoked a second time:
function processOrder(order, callback) {
if (!order.id) {
callback(new Error('Missing order id'));
}
callback(null, `Order ${order.id} processed`);
}
processOrder({}, (err, result) => {
if (err) {
console.error(err.message);
return;
}
console.log(result);
});
Output:
Missing order id
Order undefined processed
Because there’s no return after the first callback(...) call, the function keeps running and calls the callback again — this time with a nonsensical success result. The fix is to always return immediately after invoking a callback on an error branch:
function processOrder(order, callback) {
if (!order.id) {
callback(new Error('Missing order id'));
return;
}
callback(null, `Order ${order.id} processed`);
}
processOrder({}, (err, result) => {
if (err) {
console.error(err.message);
return;
}
console.log(result);
});
Output:
Missing order id
Mistake 2: Ignoring errors entirely
Skipping the error-first convention and writing callback((result) => { ... }) style code without ever checking for an error parameter means failures silently produce undefined results deep inside your logic instead of surfacing a clear message — always check the error argument first, even if you think the operation “can’t fail.”
Mistake 3: Callback hell
When several asynchronous steps each depend on the previous one’s result, nesting callback inside callback inside callback quickly becomes hard to read and maintain:
function step1(callback) {
setTimeout(() => {
console.log('Step 1 done');
callback();
}, 100);
}
function step2(callback) {
setTimeout(() => {
console.log('Step 2 done');
callback();
}, 100);
}
function step3(callback) {
setTimeout(() => {
console.log('Step 3 done');
callback();
}, 100);
}
step1(() => {
step2(() => {
step3(() => {
console.log('All steps complete');
});
});
});
Output:
Step 1 done
Step 2 done
Step 3 done
All steps complete
This works correctly, but every extra step indents further to the right (the “pyramid of doom”), makes error handling repetitive (you’d need an error check in every level), and makes the control flow hard to follow. This exact problem is what Promises and async/await were introduced to solve — they let you express the same sequential async logic without nesting.
Best Practices
- Always follow one consistent callback “shape” in your own APIs — the error-first
(err, result)convention is a strong default for anything that can fail. - Guard every branch that calls the callback with a
returnright after, so the callback can never accidentally fire twice. - Never call a callback synchronously in some cases and asynchronously in others within the same function — pick one, so callers can reason reliably about timing (this is called maintaining a “predictable” or “zalgo-free” API).
- For anything beyond two or three chained async steps, prefer Promises or
async/awaitover nested callbacks to avoid callback hell. - Name callback parameters descriptively (
onSuccess,onError,done) instead of always calling themcb, especially when a function accepts more than one. - Document (in a comment or type signature) exactly what arguments your callback will be invoked with — the caller has no way to know otherwise.
- Avoid capturing large or mutable outer variables inside a callback closure unless you intend for the callback to see their latest value when it eventually runs.
Practice Exercises
- Exercise 1: Write a function
double(number, callback)that callscallbackwith the number multiplied by two. Call it with a callback that logs the result. - Exercise 2: Write a function
delayedSquare(number, callback)that usessetTimeout(500ms) to call the callback with the number squared. Log'Calculating...'immediately after calling it, before the result arrives, and confirm your understanding of the output order. - Exercise 3: Write a function
validateAge(age, callback)using the error-first pattern: ifageis negative, callcallback(new Error('Invalid age'))and return; otherwise callcallback(null, age). Make sure it can never call the callback twice.
Summary
- A callback is just a function passed as an argument to another function, to be invoked later.
- Synchronous callbacks (like
array.forEach) run immediately, in order, before the outer function returns. - Asynchronous callbacks (like
setTimeoutor event handlers) run later, after the event loop picks them up once the call stack is empty. - The error-first convention,
callback(err, result), is the standard way Node-style APIs report success or failure through a callback. - Deeply nested callbacks lead to “callback hell” — hard-to-read, hard-to-maintain pyramids of code — which Promises and
async/awaitwere built to solve. - Always guard against calling a callback more than once, and always check for an error before using a result.
