Topic 10.4
Callbacks and Callback Hell
In one line
A callback is a function you pass to another function so it can call you back when a job finishes; it's the oldest way to handle async results, with an error-first convention, but nesting them gets messy and you must trust the code that calls them.
Think of it like this
Leaving your phone number at a shop. The shop doesn't have your size, so you leave your number: "call me when it arrives". You go home and get on with your day. That number is a callback. But you're trusting the shop: they might call twice, call the wrong number, or never call at all. And if you need the shoe, then the matching socks, then a gift box, each from a different shop that calls you before you can ask the next one, it gets complicated fast.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Callback
- A function you hand to other code so it can call it later.
- Async callback
- A callback that runs later, after something finishes, not right away.
- Error-first callback
- A callback whose first parameter is an error (or
null) and whose second is the result:(err, data). - Callback hell
- Deeply nested callbacks, each inside the last, that become hard to read and to handle errors in.
- Inversion of control
- Giving your next step to someone else's code and trusting it to call it correctly.
- Promisify
- Wrap a callback-style function so it returns a promise instead.
Step by step
01A function that calls you back
loadUser pretends to talk to a server (with a timer). It takes an id and a callback, and calls the callback later with an error or the user.
function loadUser(id, callback) {
setTimeout(() => {
if (id <= 0) {
callback(new Error("Invalid id: " + id));
} else {
callback(null, { id, name: "User " + id });
}
}, 100);
}02Handle the error first
Check err first and return, so the success code below only runs when there is data. Forgetting the return is a classic bug: you log the error and then crash reading user.name.
loadUser(7, (err, user) => {
if (err) {
console.log("Could not load:", err.message);
return;
}
console.log("Hello,", user.name);
});03Steps that depend on each other nest
Each step needs the previous result, so it goes inside the previous callback, with its own error check.
loadUser(7, (err, user) => {
if (err) return showError(err);
loadOrders(user.id, (err, orders) => {
if (err) return showError(err);
loadDetails(orders[0], (err, details) => {
if (err) return showError(err);
show(details); // three levels deep
});
});
});04Flatten with named functions
Giving each step a name and passing it along removes the pyramid. The control flow is the same, so error handling and trust problems remain, but it's much easier to read.
loadUser(7, gotUser);
function gotUser(err, user) {
if (err) return showError(err);
loadOrders(user.id, gotOrders);
}
function gotOrders(err, orders) {
if (err) return showError(err);
loadDetails(orders[0], gotDetails);
}
function gotDetails(err, details) {
if (err) return showError(err);
show(details);
}05Protect yourself from bad callers
If you must use a library you don't trust to call you exactly once, wrap your callback so only the first call counts. Promises give you this rule for free.
Try it yourself
- 1
Break the error-first rule
In the first example, remove the
return;insideif (err). Predict what the second request logs now, then run it. - 2
Make a step fail
In the callback-hell example, change the second
later(...)so its callback receives an error:setTimeout(() => callback(new Error("orders down")), 50)needs a new helper. Writefunction failLater(callback) { ... }and use it. Which steps print? - 3
Catch it in the right place
In the last example, move the
try...catchinside the timer callback around thethrow. Predict the output, then check.
Code & diagrams
The results arrive later, inside the callback. The same `handle` function deals with both outcomes because the error always comes first.
Preview
Console
Nothing logged yet.Expected console output
Both requests started
Loaded: { id: 7, name: 'User 7' }
Failed: Invalid id: -1Three steps, three levels of nesting and three copies of the error check. Topic 10.6 rewrites this as a flat promise chain, and Topic 10.8 with `await`.
Preview
Console
Nothing logged yet.Expected console output
Got user Asha
Got 2 orders
First order total: 250`once` uses a closure (Topic 3.6) to remember whether it already ran. A promise settles only once by design, so this guard comes built in.
Preview
Console
Nothing logged yet.Expected console output
Without guard: charged 100
Without guard: charged 100
With once(): charged 200When the callback throws, the `try` is no longer on the call stack, so nothing catches it. Async errors must be passed along (as `err`) or handled with promises.
Preview
Console
Nothing logged yet.Expected console output
try block finished without errors
Uncaught Error: thrown inside the timer callbackBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use the result outside the callback
Write let user; loadUser(7, (err, u) => { user = u; }); console.log(user.name);.
Break #2
Forget to return after an error
In an error-first callback, write if (err) console.log(err.message); with no return, then console.log(user.name);.
Myth vs fact
Myth
Callbacks are always asynchronous.
Fact
A callback is only a function you pass. map, filter and forEach call theirs synchronously; setTimeout and event listeners call theirs later.
Myth
Callbacks are obsolete now that we have promises.
Fact
Promises suit one-off results. Repeating events (clicks, messages, setInterval) still use callbacks, and so do array methods. Many promise-based APIs are built on callbacks underneath.
Myth
Wrapping an async call in try...catch catches its errors.
Fact
Only errors thrown while the try block is running are caught. A callback that runs later throws on a fresh call stack, outside your try.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Node.js ships
util.promisify(fn)to turn any error-first callback function into a promise-returning one, and most core modules have promise versions (fs/promises). In the browser, you write the wrapper yourself:new Promise((resolve, reject) => fn(arg, (err, data) => err ? reject(err) : resolve(data))). - ▸
An API should call its callback always synchronously or always asynchronously, never both. Mixed behaviour ("Zalgo") makes ordering bugs that only appear with a cache hit. If a result is already available, defer the callback with
queueMicrotaskto keep it consistent; promises do this automatically. - ▸
Callbacks hold on to whatever their closure captures. An event listener or interval that's never removed keeps its closure (and maybe large DOM trees) in memory: a common leak in single-page apps. Use
{ once: true },AbortControllersignals onaddEventListener, and clear intervals on teardown.
Remember this
- 1
A callback is a function passed as an argument to another function, to be called later. You've used them already:
addEventListener("click", handler),setTimeout(fn, 100),array.map(fn)(Topic 3.7).map's callback runs synchronously, right away. An async callback runs later, after something finishes, so the result is only available inside it. - 2
An async function written in the callback style takes the callback as its last argument and returns nothing useful. Everything that needs the result goes inside the callback. Code after the call runs straight away, before the result exists.
- 3
Error-first callbacks are the convention Node.js made standard: the callback's first parameter is an error (or
nullif all went well) and the result comes second:(err, data) => { ... }. Always checkerrfirst andreturnafter handling it, so you don't go on to usedatathat isn't there. You can't usetry...catcharound the outer call to catch async errors: by the time the error happens, thattryblock has long finished. - 4
Callback hell (or the "pyramid of doom") is what happens when each step needs the previous result: get the user, then their orders, then the first order's details. Each step nests inside the last, the code drifts to the right, every level needs its own error check, and running steps in parallel or adding a timeout becomes very hard. Naming the functions and keeping them flat helps, but doesn't fix the deeper problems.
- 5
The deeper problem is inversion of control: you hand your continuation to someone else's code and trust it. A buggy or untrusted library might call your callback twice (charging a card twice), never, too early (synchronously sometimes and asynchronously other times, a bug nicknamed "releasing Zalgo"), or swallow your errors. You can guard against each case (for example, a
oncewrapper), but you have to remember every time. - 6
Promises (Topic 10.5) were created to fix this. A promise is a value you hold that can settle only once, always calls you back asynchronously, passes errors along a chain, and can be combined (Topic 10.7). Callbacks are still the right tool for events that happen many times (clicks, messages), and many older APIs still use them. You can wrap a callback API in a promise; this is called promisifying.
Explain it without notes
What is an error-first callback, and why does that convention exist?
What is callback hell, and what problems does it cause beyond indentation?
Explain inversion of control with callbacks, and how promises address it.
Why can't try...catch around setTimeout catch an error thrown in its callback?
Practice
Write divideLater(a, b, callback) that, after 50 ms, calls callback(new Error("Cannot divide by zero")) if b is 0, otherwise callback(null, a / b). Call it for 10 / 2 and 1 / 0.
Wrap divideLater in divideAsync(a, b) that returns a promise, and use it with .then and .catch.
Trade-offs
- ↔
Callbacks have zero overhead and fit repeating events perfectly; promises add a small cost per step but give one-time settling, error propagation and combinators for one-off results.
- ↔
Flattening callbacks with named functions is a cheap readability fix for old code; converting to promises and
async/awaitis more work but fixes error handling and trust as well.
Done when you can
I can write and call a function that takes an async callback.
I follow the error-first convention and return after handling an error.
I can recognise callback hell and flatten it with named functions.
I can explain inversion of control and guard a callback with
once.I know
try...catchdoesn't catch errors thrown in later callbacks.