Asynchrony with Callbacks
This chapter assumes you are comfortable with functions, closures, this binding, the call stack, and array iteration methods from earlier chapters. We work in Node.js and the browser interchangeably, calling out runtime differences where they matter. async_callbacks sits between Functions and Promises in the topic plan, so we treat callbacks as an execution model in their own right, not merely as a historical stepping stone to await.
Why JavaScript is single-threaded, and what that forces
JavaScript runs your code on one call stack. There is no thread suspension: while a function is executing, nothing else in your program runs. This eliminates entire classes of race conditions on shared data, but it creates a hard constraint — a long-running computation blocks everything, including UI rendering and I/O processing.
The consequence is that JavaScript's I/O model is cooperative and event-driven. You never "wait" for the network or the filesystem. Instead you hand the runtime a function — the callback — and the runtime invokes it later, when the result is ready. The runtime re-enters your code from the event loop, which we cover in depth in its own chapter; here we focus on how callbacks shape program structure and failure handling.
// Node.js: asynchronous file read with the error-first callback convention
const fs = require("fs");
fs.readFile("config.json", "utf8", (err, data) => {
if (err) {
console.error("read failed:", err.code); // e.g. ENOENT
return;
}
console.log("parsed:", JSON.parse(data));
});
console.log("read dispatched"); // logs firstNote the ordering: the log at the bottom prints first. readFile returns immediately; the callback is queued and invoked when the operating system completes the read.
Callback conventions you must know
Two decades of callback use crystallized into conventions that surviving APIs still follow. Internalize them; you will read this style in any mature codebase.
- Error-first signature: Node-style callbacks take
(err, result), witherrbeingnullorundefinedon success. Forgetting theif (err) returnguard is the classic defect: execution falls through and reads a garbageresult. - Success-only signatures: Browser DOM events (
onclick,addEventListener) pass only the event. There is no failure channel — the handler is invoked on the trigger, and errors inside it become unhandled exceptions routed towindow.onerror. - Node promises vs. callback APIs: Modern Node exposes both (
fs/promisesvsfs). Callback APIs remain the substrate; promise APIs are layered over them.
Deferred invocation: setTimeout is not "sleep"
A common error is treating setTimeout(fn, 0) as a way to run "next". It schedules a macrotask with at least the given delay — the delay is a floor, not a guarantee. If you need ordering with promise callbacks, timeouts are unreliable; microtasks (promise reactions) always run before the next macrotask. We defer the full scheduling model to the Event Loop chapter, but the takeaway here is: callbacks impose no ordering guarantee beyond "after the current stack unwinds", and timeout callbacks are not precisely timed.
Inversion of control
The defining property of callbacks is inversion of control: you hand your function to a callee you do not control, and it decides whether, when, and how many times to call it. Every callback defect traces back to one of these unmet assumptions:
- Called zero times: the callee hit an error path without invoking your callback, so your code never resumes. Your program hangs.
- Called more than once: double-invocation silently corrupts state or double-charges a payment. This is the most dangerous failure because it looks like success.
- Called never or forever later: there is no cancellation in the callback model. A dropped callback is an unrecoverable leak of any resources the closure retains (see Memory chapter).
- Called synchronously: an API that invokes your callback immediately is nearly always a bug source, because it inverts the ordering guarantees of the surrounding code.
function once(fn) {
let called = false;
let result;
return (...args) => {
if (called) return result;
called = true;
result = fn(...args);
return result;
};
}
// Defensive wrapper: guarantees single invocation regardless of caller behavior.Defensive wrappers like this exist because you cannot trust the callee's discipline. Promises solve this class of problems structurally — a promise settles exactly once — which is precisely why the ecosystem migrated.
Callback hell: what actually goes wrong
"Nesting" is not the real problem — indentation is cosmetic. The real problems are, in order of severity:
- Error handling must be threaded manually through every level. There is no propagation mechanism: each callback must re-check and re-forward
err. - Parallel work is awkward. Running N operations concurrently and combining results requires manual counting, with all its race-prone bookkeeping.
- Composition destroys the stack. Errors thrown asynchronously lose their originating stack; you get a stack fragment from the event-loop dispatch, not the logical call site.
function getUser(id, cb) {
db.query("SELECT * FROM users WHERE id = ?", [id], (err, rows) => {
if (err) return cb(err); // manual propagation, level 1
if (!rows.length) return cb(new Error("not found"));
getPermissions(rows[0], (err, perms) => {
if (err) return cb(err); // manual propagation, level 2
cb(null, { user: rows[0], perms });
});
});
}Diagram
Show diagram source
sequenceDiagram
participant S as Main script
participant R as Runtime / event loop
participant IO as Async I/O (OS)
S->>IO: readFile(path, cb)
IO-->>S: returns immediately
Note over S: stack unwinds; event loop runs other work
IO->>R: read complete
R->>S: invokes cb(err, data)
Note over S: callback executes as a fresh stack frameTrade-offs: when callbacks are still the right tool
Callbacks are not obsolete. Decision criteria:
- Event handlers (
addEventListener, streams'data/end,requestAnimationFrame): a callback is the native idiom. A promise models a single future value; a recurring event is not one, and wrapping it in one is a modeling error. - Signals and one-off notifications in environments without promise support (embedded JS engines, older tooling).
- Everything with a success/failure shape: prefer promises or
async/await. They give you single-settlement semantics, propagation viatry/catchandcatch(), and composability.
| Concern | Callbacks | Promises/await |
|---|---|---|
| Settlement guarantee | None — callee discipline | Exactly once, by construction |
| Error propagation | Manual, per-level | Automatic along the chain |
| Parallel composition | Manual counters | `Promise.all` |
| Recurring events | Natural fit | Wrong tool |
| Cancellation | None (leaks closures) | `AbortSignal` |
Callbacks and closures: the retention trap
A callback that outlives its creating scope is a closure held by the callee. Everything it captures — including large objects captured transitively — stays alive as long as the callee holds it. A listener registered on a long-lived object and never removed retains its entire scope chain. This is the dominant JavaScript memory-leak pattern; the Memory chapter revisits it with heap-analysis techniques.
What carries forward
Two ideas from this chapter are load-bearing for everything ahead. First, an asynchronous callback executes as a fresh stack frame dispatched from the event loop — synchronous code has fully unwound by then, which is why throw inside a callback cannot be caught by a surrounding try. Second, promises are a discipline imposed on callbacks: single settlement, automatic propagation, composability. When you read await next, you are reading syntax over that discipline.
Exercise
A third-party library accepts a callback and your telemetry shows it sometimes fires twice. What is the failure mode, and how do promises change it?
Show answer
Double invocation violates the single-call assumption and silently corrupts any state the callback mutates — a consequence of inversion of control with no settlement contract. A promise settles exactly once by construction: further resolve/reject calls are ignored, so double-settlement cannot re-run reaction logic.
Exercise
Why can't a try/catch wrapping an async call catch an error thrown inside the callback?
Show answer
The try block finishes executing long before the callback runs. The callback is invoked later as a new stack frame from the event loop, so any throw happens outside the original try's dynamic extent and becomes an uncaught exception (or a promise rejection, in promise APIs).
Exercise
You need to react to every message on a websocket. Is this a promise-shaped problem or a callback-shaped one? Why?
Show answer
Callback-shaped. A promise models one future value with single settlement; a message stream delivers many values over time. The native idioms are event callbacks (or async iteration — for await ... on an async iterator — which is the promise-world's answer to streams, covered later).
Spotted a mistake, or code that does not work?