Episode 1 — Fundamentals / 1.17 — Asynchronous Programming JavaScript
1.17.b — Callbacks and Callback Problems
In one sentence: A callback is a function passed to another function for later execution — simple and powerful for basic async tasks, but callback hell, inversion of control, and poor error handling make callbacks alone insufficient for complex async flows.
Navigation: ← 1.17.a — Introduction to Asynchrony · 1.17.c — Understanding Promises →
1. What is a callback function?
A callback is any function you pass as an argument to another function, which then invokes it at some point.
function doSomething(callback) {
console.log("Doing work...");
callback(); // invoke the callback
}
doSomething(function () {
console.log("Work is done!");
});
// Output: "Doing work...", "Work is done!"
The term comes from the idea of "calling back" — you hand off a function and say: "Call me back when you're done."
2. Synchronous callbacks vs asynchronous callbacks
Not all callbacks are async. The distinction is crucial:
2.1 Synchronous callbacks
These run immediately within the calling function's execution, before it returns.
// Array.forEach — synchronous callback
const nums = [1, 2, 3];
nums.forEach(function (n) {
console.log(n);
});
console.log("Done");
// Output: 1, 2, 3, Done — all synchronous
// Array.map — synchronous callback
const doubled = [1, 2, 3].map(n => n * 2);
console.log(doubled); // [2, 4, 6] — callback ran for each element immediately
Other synchronous callbacks: Array.filter(), Array.reduce(), Array.sort().
2.2 Asynchronous callbacks
These are invoked later, after some async operation completes.
// setTimeout — asynchronous callback
console.log("Before");
setTimeout(function () {
console.log("Inside timeout");
}, 1000);
console.log("After");
// Output: Before, After, Inside timeout (after ~1s)
| Type | When callback runs | Examples |
|---|---|---|
| Synchronous | Immediately, blocking | forEach, map, filter, sort, reduce |
| Asynchronous | Later, after an event or delay | setTimeout, addEventListener, fs.readFile |
3. Using callbacks with common async APIs
3.1 setTimeout
function delayedGreeting(name) {
setTimeout(function () {
console.log("Hello, " + name + "!");
}, 2000);
}
delayedGreeting("Alice"); // prints after 2 seconds
3.2 Event listeners
document.getElementById("btn").addEventListener("click", function (event) {
console.log("Button clicked!", event.target);
});
// The callback runs every time the button is clicked — not immediately
3.3 Node.js file reading
const fs = require("fs");
fs.readFile("data.txt", "utf8", function (err, data) {
if (err) {
console.error("Error reading file:", err);
return;
}
console.log("File contents:", data);
});
console.log("This runs before the file is read");
4. Callback hell / pyramid of doom
When multiple async operations depend on each other sequentially, callbacks nest deeper and deeper:
// Fetch user, then their posts, then comments on the first post,
// then the profile of the first commenter
getUser(userId, function (err, user) {
if (err) {
console.error("Error fetching user:", err);
return;
}
getPosts(user.id, function (err, posts) {
if (err) {
console.error("Error fetching posts:", err);
return;
}
getComments(posts[0].id, function (err, comments) {
if (err) {
console.error("Error fetching comments:", err);
return;
}
getUserProfile(comments[0].userId, function (err, profile) {
if (err) {
console.error("Error fetching profile:", err);
return;
}
console.log("Commenter profile:", profile);
// Imagine more levels...
});
});
});
});
Problems with this pattern:
| Problem | Why it hurts |
|---|---|
| Readability | Code moves to the right, hard to follow the logic |
| Maintainability | Adding a step means re-indenting everything |
| Error handling | Every level needs its own if (err) check |
| Debugging | Stack traces are hard to follow through nested closures |
The visual shape of the code — a sideways pyramid — gives this pattern its name: pyramid of doom.
getUser(id, function (err, user) {
getPosts(user.id, function (err, posts) {
getComments(posts[0].id, function (err, comments) {
getProfile(comments[0].userId, function (err, profile) {
// ← deep nesting, hard to maintain
});
});
});
});
5. Inversion of control
When you pass a callback to a third-party function, you hand over control of when (and whether) your code runs. This is called inversion of control (IoC).
// You trust thirdPartyLibrary to:
// 1. Call your callback exactly once
// 2. Call it with the right arguments
// 3. Not call it too early or too late
// 4. Handle errors before calling you
thirdPartyLibrary.process(data, function (result) {
chargeCustomerCreditCard(result.amount); // dangerous if called twice!
});
Risks of inversion of control:
| Risk | What could happen |
|---|---|
| Callback called too many times | Credit card charged twice |
| Callback never called | User waits forever, no error shown |
| Callback called too early | Before your setup code finishes |
| Callback called with wrong arguments | Unexpected undefined or wrong data type |
| Errors swallowed silently | No way to know something went wrong |
Promises solve IoC by giving you the control back — you observe the result instead of handing off your code.
6. Error handling in callbacks: the error-first pattern
Node.js established the error-first callback convention (also called "errback"):
// Convention: callback(error, result)
// If error is null/undefined → success
// If error is truthy → something went wrong
function fetchData(url, callback) {
// Simulated async operation
setTimeout(function () {
if (!url) {
callback(new Error("URL is required"), null);
return;
}
callback(null, { data: "some data from " + url });
}, 1000);
}
// Usage
fetchData("https://api.example.com", function (err, result) {
if (err) {
console.error("Failed:", err.message);
return; // ← must return to avoid running success code
}
console.log("Success:", result.data);
});
Rules of the error-first pattern:
- The first argument to the callback is always the error (or
nullon success). - The remaining arguments are the success data.
- The caller must check
errbefore using the result. - Return immediately after handling the error to avoid executing success logic.
Limitation: This is a convention, not enforcement. Nothing prevents a library from breaking these rules.
7. Attempting to flatten callbacks (named functions)
One partial fix for callback hell is extracting named functions:
function handleProfile(err, profile) {
if (err) return console.error(err);
console.log("Profile:", profile);
}
function handleComments(err, comments) {
if (err) return console.error(err);
getUserProfile(comments[0].userId, handleProfile);
}
function handlePosts(err, posts) {
if (err) return console.error(err);
getComments(posts[0].id, handleComments);
}
function handleUser(err, user) {
if (err) return console.error(err);
getPosts(user.id, handlePosts);
}
getUser(userId, handleUser);
This flattens the indentation but introduces new problems:
- Functions are defined in reverse order of execution (hard to follow the flow).
- Sharing data between steps requires closures or passing extra context.
- Still no centralized error handling.
8. Why callbacks alone are insufficient
| Limitation | Consequence |
|---|---|
| No composability | Cannot easily combine async results (parallel execution) |
| No chaining | Sequential steps require nesting or extracting named functions |
| No built-in error propagation | Each level handles errors independently |
| Inversion of control | Trust issues with third-party code |
| No cancellation | Cannot cancel a pending callback-based operation |
| No standard interface | Every library can define its own callback signature |
These problems motivated the creation of Promises (see 1.17.c), which provide a standardized, composable, chainable abstraction for async operations.
Key takeaways
- A callback is a function passed to another function for later invocation.
- Synchronous callbacks (
forEach,map) run immediately; async callbacks (setTimeout, event listeners) run later. - Callback hell occurs when sequential async operations create deeply nested, hard-to-maintain code.
- Inversion of control means you trust external code to call your callback correctly — a risky proposition.
- The error-first pattern (
callback(err, result)) is a convention, not a guarantee. - Callbacks are foundational but insufficient alone for complex async flows — Promises and async/await build on top of them.
Explain-It Challenge
Explain without notes:
- What is the difference between a synchronous callback and an asynchronous callback? Give one example of each.
- Show what callback hell looks like and explain why it is a problem.
- What does inversion of control mean in the context of callbacks, and why is it dangerous?
Navigation: ← 1.17.a — Introduction to Asynchrony · 1.17.c — Understanding Promises →