Callbacks and how they got out of hand
Callbacks solved the problem from the last lesson, and they solved it well enough that JavaScript was written this way for fifteen years. Then people started needing three things in a row, and it stopped being pleasant.
This lesson is short, and it is mostly here so that promises make sense as an answer rather than arriving as arbitrary syntax.
The pattern
Hand over a function; it gets called when the work is done.
function getOrder(id, callback) {
setTimeout(() => {
callback({ id, customer: 'Priya', plates: 3 });
}, 100);
}
getOrder(1, (order) => {
console.log(order.customer);
});
Priya
Perfectly clear. One thing, then one thing after it.
Error-first callbacks
A callback gets the result — but what about failure? There is no return, so
there is nowhere for an error to go. The convention that emerged, and which all
of Node used, is that the first argument is the error:
function getOrder(id, callback) {
setTimeout(() => {
if (id < 1) {
callback(new Error('No such order'));
return;
}
callback(null, { id, customer: 'Priya' });
}, 100);
}
getOrder(0, (error, order) => {
if (error) {
console.log('Failed:', error.message);
return;
}
console.log(order.customer);
});
Failed: No such order
null as the first argument means success. Every call needs that if (error)
check at the top, and nothing forces you to write it — forget it and you
carry on with an undefined order.
Where it falls apart
Now three things in order: find the customer, then their current order, then the delivery slot for it. Each needs the one before.
getCustomer(1, (error, customer) => {
if (error) {
showError(error);
return;
}
getOrder(customer.id, (error, order) => {
if (error) {
showError(error);
return;
}
getSlot(order.id, (error, slot) => {
if (error) {
showError(error);
return;
}
console.log(`${customer.name}: ${order.plates} plates at ${slot.time}`);
});
});
});
This is callback hell, and the shape on the page — drifting right, with the closing brackets stacked up at the bottom — is why it is also called the pyramid of doom.
Name what is actually wrong with it, because "it looks ugly" is not the real problem:
The error handling is repeated three times and must be repeated at every level. Miss one and a failure disappears silently.
Sequence is expressed by nesting. The natural way to read "do this, then this, then this" is a list, not a staircase. Adding a fourth step means re-indenting everything below it.
You cannot use try/catch. The callback runs later, on an empty stack —
the try block finished long ago. Module 5's error handling simply does not
reach here, which is the deepest problem of the three.
Nothing is reusable. That block cannot be broken up, because each step closes over the one before.
Doing things in parallel is worse
Sequential is the easy case. Now fetch three orders at once and continue when all three have arrived:
const orders = [];
let done = 0;
let failed = false;
[1, 2, 3].forEach((id, index) => {
getOrder(id, (error, order) => {
if (failed) return;
if (error) {
failed = true;
showError(error);
return;
}
orders[index] = order;
done += 1;
if (done === 3) {
console.log(orders);
}
});
});
A counter, a flag and an index, all hand-maintained, to express "wait for these
three". It is easy to get subtly wrong — and note orders[index] = order rather
than orders.push(order), because responses arrive in whatever order the network
delivers them, and pushing would scramble them.
Every bug in this shape is a bug in your bookkeeping, not in your logic.
Promise.all, two lessons from now, is one line.
The other problem: you lose control
When you pass a callback to somebody else's function, you are trusting it. A badly written library might call your callback twice, or never, or synchronously when you expected it later. Nothing in the pattern prevents any of that, and there is no way to check from the outside.
Promises fix this by construction: a promise settles once and stays settled.
Callbacks are not obsolete
Important, because this lesson reads as a hit piece.
Event handlers are callbacks and always will be. addEventListener takes a
function to call later, possibly many times, and there is nothing wrong with it —
promises are for one eventual result, which is not what a click is.
setTimeout, map, filter and sort all take callbacks and are perfectly
good.
The problem is specifically using callbacks for a sequence of one-off asynchronous results. That is what promises replaced. Everything else stands.
You will still meet callback-style APIs in older Node code and older libraries, and you can wrap one in a promise yourself — the next lesson shows how in three lines.
Check your work
A callback is a function you hand over to be called when the work finishes, because a function cannot return a value that has not arrived.
The error-first convention puts the error in the first argument and null
there on success. Every call site needs an if (error) check, and nothing
enforces it.
Callback hell is a pyramid caused by expressing sequence as nesting. Its
three real problems: error handling repeated at every level, indentation growing
with each step, and try/catch not working at all because the callback
runs later on an empty stack.
Running things in parallel needs hand-written bookkeeping — a counter, a flag, and writing results by index rather than pushing, since responses arrive in network order.
A callback gives no guarantee it is called once. A promise settles once and stays settled.
Callbacks are fine for events and for array methods. The problem is specifically sequences of one-off asynchronous results.
Practice
- Write
getOrder(id, callback)that calls back with an order after 100ms, and use it. - Convert it to the error-first convention and handle both paths.
- Forget the error check at one call site and pass a failing id. Note that
nothing warns you and the next line runs with
undefined. - Build the pyramid. Write three fake async functions and nest them three deep with error handling at every level. Then add a fourth step and notice what you have to re-indent.
- Prove
try/catchcannot help. Wrap a call to an async callback function intry/catch, make the callback throw, and watch thecatchnot fire. Say why, in terms of the call stack from the last lesson. - Write the parallel version with a counter and a flag. Then deliberately make
the second request slowest and confirm that using
pushscrambles the order while writing by index does not. - Make a callback that gets called twice by mistake and observe that nothing stops it.
- List three callbacks in your module 7 to-do list that are not a problem, and say what makes them different.
Next: promises — one object that represents a future value, settles exactly once,
and lets try/catch work again.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship