Asynchrony with Callbacks
Prerequisites: you should be comfortable with functions, closures, and the call stack from earlier chapters. This chapter assumes you know that JavaScript runs one synchronous task at a time and that functions are values you can pass around.
Why callbacks exist
JavaScript has a single call stack. Reading a file or waiting on a network would block everything if done synchronously, so the platform gives you non-blocking APIs that accept a callback: a function the platform calls later, when the work finishes. Passing a function and returning immediately inverts control — your code no longer decides when it runs. That trade-off is the root of both the power and the pain of callback-based code.
const fs = require('fs');
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) {
console.error('read failed:', err.message);
return;
}
console.log(JSON.parse(data));
});
console.log('requested'); // logs first, not lastThe error-first convention
Node's ecosystem standardized on (err, ...) as the first parameter. A callback that doesn't check err first silently swallows failures. Some APIs (browser events, Array.prototype.sort's comparator) use different signatures — always check the contract before assuming error-first.
Failure modes
- Callback hell / rightward drift: sequencing async steps nests callbacks, hurting readability and making early returns awkward.
- Forgotten errors: an unhandled error in a callback throws asynchronously; no surrounding
try/catchcan catch it, because the throw happens long after that block has exited. - Called twice, or never: hand-rolled APIs may invoke the callback on both success and error paths. Defensive code uses a
doneflag or deduplicates. - Zalgo: an API that calls the callback synchronously sometimes and asynchronously other times breaks invariants. Rule: always release asynchronously, even for cached values.
- Lost
thisand lost try/catch: callbacks detached from their object lose binding (covered in thethischapter), and exceptions inside them escape any caller.
Parallel vs. sequential
Sequential steps nest; independent work can launch concurrently and synchronize with a counter:
function parallel(tasks, final) {
const results = new Array(tasks.length);
let remaining = tasks.length;
if (remaining === 0) return final(null, results);
tasks.forEach((task, i) => {
task((err, value) => {
if (err) return final(err); // first error wins; later calls ignored
results[i] = value;
if (--remaining === 0) final(null, results);
});
});
}Note the deliberate double-call guard: after an error, final may be invoked again by another task failing. Production code (or Promise.all, in the next chapter) must handle that.
Show diagram source
sequenceDiagram
participant Main as Main script
participant API as Async API
participant Q as Task Queue
Main->>API: readFile(path, cb)
API-->>Main: returns immediately
Main->>Main: remaining synchronous work
API->>Q: enqueue cb(result)
Main-->>Q: stack empties
Q->>Main: run cb()Mitigations before promises
Naming inner functions instead of anonymous nesting, extracting per-step functions, and control-flow libraries like async.eachSeries all predate promises. They helped, but they cannot compose return values or propagate errors implicitly — which is exactly the gap promises close in the next chapter.
Exercise
Why can't a try/catch block around an async API call catch an error thrown inside its callback?
Show answer
The callback runs later, on an empty stack, from the event loop — long after the try block has exited. Asynchronous throws can only be handled inside the callback itself (or by a mechanism like promises that carries the error forward).
Exercise
In parallel above, what bug appears if a task calls its callback twice on success?
Show answer
remaining decrements below its intended count and final fires early or twice, corrupting results ordering. Guard each task so its callback runs at most once.
Spotted a mistake, or code that does not work?