JAVASCRIPT / PROMISES AND ASYNC PATTERNS
Callback patterns and nested callback pain
Read, write, and untangle error-first callback code: why data dependencies force nesting, why try/catch and return stop working, and how to flatten it.
What you will learn
- Read fn(args, cb) as continuation-passing: you hand your next step to the callee
- Explain why dependent steps force nesting and duplicated if (err) return checks
- Flatten a callback pyramid into named handlers with explicitly threaded state
- Spot callbacks fired twice, never, or synchronously and the bugs each causes
Understanding Callback patterns and nested callback pain
A callback-based API takes the rest of your program as an argument. Instead of const user = getUser(7), you write getUser(7, handleUser), and the function you passed is invoked by code you do not control, at a moment you do not choose. The mental model is inversion: with a return value the callee hands data back to you, while with a callback you hand your continuation down to the callee, which decides when it runs, what arguments it gets, and whether it runs at all.
Nesting is not a style failure, it is what data dependency forces. The result of step one arrives as a parameter of step one's callback, and that parameter is the only place the name exists, so the call to step two must be written inside it. Three dependent steps therefore produce three levels of indentation, each level re-declaring err and shadowing the outer one. The language's own composition tools stop reaching across that boundary: return exits the callback rather than the enclosing function, a try/catch around the call has already finished by the time the operation fails, and a for loop cannot pause between iterations.
Because failures do not arrive as thrown exceptions, callback APIs use a convention instead: the first parameter is an error or null, and every handler starts with if (err) return done(err). The convention is social, not enforced, so nothing stops an implementation from calling your callback twice, skipping it on one branch, or invoking it synchronously on a cache hit. Naming handlers, returning early on error, and funnelling every outcome into one terminal function make callback code readable, but the sequencing still lives in names and hand-threaded state rather than in the language, which is the specific pain promises exist to remove.
function getUser(id, cb) {
setTimeout(() => {
if (id !== 7) return cb(new Error('no user ' + id));
cb(null, { id: 7, name: 'Ada', orgId: 'acme' });
}, 10);
}
function getOrg(orgId, cb) {
setTimeout(() => cb(null, { id: orgId, plan: 'pro' }), 10);
}
function getSeats(org, cb) {
setTimeout(() => cb(null, org.plan === 'pro' ? 25 : 3), 10);
}
getUser(7, (err, user) => {
if (err) return console.log('failed:', err.message);
getOrg(user.orgId, (err, org) => {
if (err) return console.log('failed:', err.message);
getSeats(org, (err, seats) => {
if (err) return console.log('failed:', err.message);
console.log(user.name, 'is in', org.id, 'with', seats, 'seats');
});
});
});
console.log('request dispatched');A callback inverts control, so ordering, error routing, and result passing must be rebuilt by hand instead of using return, try/catch, and ordinary nesting.
Worked examples
A callback result cannot be returned
Shows why one async leaf turns every function above it into a callback-taking function.
function readValue(cb) {
setTimeout(() => cb(null, 42), 0);
}
function getValueWrong() {
let result;
readValue((err, value) => { result = value; });
return result;
}
console.log('wrong:', getValueWrong());
function getValueRight(cb) {
readValue((err, value) => {
if (err) return cb(err);
cb(null, value);
});
}
getValueRight((err, value) => console.log('right:', value));Example explained
Line 1return result runs while the timer is still pending, so result is the undefined it was initialised to.
Line 2The assignment result = value does happen, just after the caller has already read the variable.
Line 3getValueRight cannot return the value either; it can only accept another callback and pass the value onward.
Line 4That is the mechanism by which callback style spreads upward through a codebase.
try/catch does not reach into the callback
Demonstrates that an async failure must be delivered as the first callback argument, not thrown.
function parseLater(text, cb) {
setTimeout(() => {
let data;
try {
data = JSON.parse(text);
} catch (e) {
return cb(e);
}
cb(null, data);
}, 0);
}
try {
parseLater('{oops}', (err, data) => {
console.log('callback err.name:', err && err.name);
console.log('callback data:', data);
});
} catch (e) {
console.log('outer catch saw:', e.name);
}
console.log('try block finished');Example explained
Line 1The outer try only wraps the call that schedules the timer, and it finishes before JSON.parse ever runs.
Line 2return cb(e) is the only exit route, so the failure travels as data on the first parameter.
Line 3data is undefined because cb(e) was called with a single argument, which is why the err check must come first.
Line 4The catch block is dead code for asynchronous failures and prints nothing.
Flattening the pyramid with named handlers
Rewrites three dependent steps with zero nesting and shows what that costs.
function stepOne(cb) { setTimeout(() => cb(null, 2), 0); }
function stepTwo(n, cb) { setTimeout(() => cb(null, n * 3), 0); }
function stepThree(n, cb) { setTimeout(() => cb(null, n + 1), 0); }
const acc = {};
function onOne(err, a) {
if (err) return done(err);
acc.a = a;
stepTwo(a, onTwo);
}
function onTwo(err, b) {
if (err) return done(err);
acc.b = b;
stepThree(b, onThree);
}
function onThree(err, c) {
if (err) return done(err);
done(null, acc.a + acc.b + c);
}
function done(err, total) {
if (err) return console.log('failed:', err.message);
console.log('total:', total);
}
stepOne(onOne);Example explained
Line 1Every handler sits at the top level, so indentation stays flat however many steps you add.
Line 2acc exists only because the handlers no longer close over each other's parameters; flattening costs you the shared scope.
Line 3onThree reads acc.a, a value produced two steps earlier that the nested version got free from its closure.
Line 4Each handler still repeats if (err) return done(err), because callbacks offer no way to skip straight to the failure path.
Important notes
Error-first (err, value) is a convention, not a language rule; many browser APIs instead use separate onload/onerror callbacks or event listeners, so check each API's callback shape before assuming err is the first argument.
Invoke your callback asynchronously on every path, including cache hits; a function that is sometimes synchronous makes the caller's execution order depend on data, and the resulting bugs only appear once the cache is warm.
Common mistakes
Assigning inside a callback and returning that variable from the enclosing function: the function returned before the callback ran, so callers silently receive undefined.
Omitting return before cb(err) so execution continues into the success call: the callback runs twice, and downstream code handles an error and then a bogus value, producing duplicate writes or a second HTTP response.
Wrapping the async call in try/catch to handle its failures: the try block completes first, the catch never runs, and the error surfaces as an unhandled exception inside the timer or I/O callback.
Try it yourself
Change, predict, then run
Write getPrice(id, cb), applyDiscount(price, cb), and formatTotal(n, cb) as three setTimeout-based error-first functions, nest them so the final string prints, then rewrite the same flow with named handlers and no nesting deeper than one level. Make getPrice(-1, cb) fail and confirm exactly one line is logged.
Open the JavaScript workspaceCheck your understanding
Three error-first functions must run in order, each using the previous result. Why does the direct callback version end up nested three levels deep instead of flat?
- The event loop only runs a callback if it was registered inside the previous callback
- Error-first callbacks require a separate try/catch block at each level
- Each result exists only as a parameter of the callback it was delivered to, so the next call must be made inside that scope
- setTimeout cannot be called more than once from the same function
Show answer
The indentation follows the data dependency: the user object is a parameter of the first callback, invisible anywhere else, so the call that needs it has to live there. The event loop option is tempting because nesting does produce the observed order, but ordering comes from when each callback is invoked, not from where it is written, which is exactly why the flat named-handler version preserves the same order with no nesting at all.