Lesson 1 of 5
Callbacks & the Error-First Convention
"Callbacks suffer from inversion of control in that they implicitly give control over to another party." — Kyle Simpson, You Don't Know JS: Async & Performance
Part I described the machine. This Part is about the code you write on top of it, and it starts where Node started: with a function you hand to somebody else, to be called when they are ready.
That arrangement is older than Node and older than JavaScript, and on its own it is unremarkable. What makes it worth a chapter is the thing Simpson names above — you are not calling a function, you are surrendering control of your program's continuation to someone else. Everything that follows in this Part is a response to the problems that surrender creates. Promises are a response. async/await is a response to promises. Understanding why they exist requires understanding what they were fixing, and a great deal of the Node ecosystem still runs on the thing being fixed.
The convention that emerged is error-first: the callback's first parameter is an error, or null when there is none. It is a convention rather than a language feature, which is the entire source of both its reach and its fragility. Nothing enforces it. Nothing checks that a callback runs once, or runs asynchronously, or runs at all. The compiler has no opinion. Each of those unenforced guarantees is a bug class, and this chapter is largely a tour of them — because they are precisely the guarantees promises later made structural.
What is wrong with a function that returns its result synchronously when it has the value cached, and asynchronously when it has to fetch it? It is faster in the common case, and the caller passes a callback either way.
It is one of the most reliable bug generators in JavaScript, and it has a name — releasing Zalgo. The problem is that the caller's code around the call runs in a different order depending on which path was taken. Variables assigned after the call are already assigned when the callback is async, and still undefined when it is sync. So the function works perfectly during development, when the cache is cold and every call is asynchronous, and fails the moment a cache hit occurs — in production, under load, intermittently.
The rule is absolute and costs nothing: an API that takes a callback must invoke it asynchronously, always, even when the answer is already known. One line — deferring the call by a turn of the loop — buys a consistency the caller can actually reason about. The demonstration below is four lines long and the bug it produces is genuinely hard to find.
The convention, and the cost of not following it
Error-first is simple enough to state in a sentence: the first argument is an error or null, the rest are results. Its weakness is equally simple — nothing makes you look at the first argument.
The careless handler does not crash. It receives undefined where it expected an object, reads a property off it, gets undefined again, and carries on — writing that undefined into a database or a response body. The error was delivered correctly and simply not examined, which is the defining weakness of the convention: an unchecked error is indistinguishable from no error until much later and somewhere else.
Three rules make error-first tolerable in practice.
The error check is the first statement in the callback. Not somewhere in the middle. If it is not the first line, a reviewer cannot tell at a glance whether it exists.
return when you handle the error. if (err) callback(err); without a return continues into the success path as well, calling the callback twice with contradictory arguments. This is such a common bug that return callback(err) is idiomatic even where the return value is meaningless.
Errors must be Error objects. callback("something failed") loses the stack trace, and if (err) is then testing a string — which fails silently for the empty string. Reject anything that calls back with a string, a number, or true.
A courier who returns from a delivery and says either "here is the signed receipt" or "here is why it failed" — handing you an envelope where the first sheet is always the problem report, blank if there was none.
The system works, but it depends entirely on you reading the first sheet. Nothing stops you flipping straight to the receipt, finding a blank page, and filing that blank page as though it were a signature. And nobody will notice for weeks, because a blank receipt looks exactly like a receipt that has not been processed yet. Promises are the version where the courier refuses to hand you anything until you have said, explicitly, what should happen if the delivery failed.
Zalgo: the function that is sometimes synchronous
The single most damaging thing a callback API can do is be inconsistent about when it calls you back.
Read the two outputs against each other. On the cache miss the callback runs after load has finished, so handle is assigned. On the cache hit the callback runs during load, before the next line executes, so handle is still null. The calling code is identical and correct in both cases; the API changed the rules underneath it.
Why this is so hard to debug in practice:
It passes every test. Tests usually start with an empty cache, so every call takes the async path.
It fails intermittently in production, on the second request for the same resource, under load — the conditions least amenable to a debugger.
The stack trace points at the caller, which is correct code. Nothing in the trace indicates that the timing of the callback is the variable.
The fix is one line, in the API rather than the caller:
// Not runnable here — process.nextTick is Node-only. `queueMicrotask` or
// `setImmediate` work equally well; the point is to defer by at least a turn.
if (cache.has(id)) return process.nextTick(() => callback(null, cache.get(id)));Always asynchronous, even when the answer is already in hand. The cost is one turn of the event loop; the benefit is that the caller's code has one behaviour instead of two.
The name comes from a 2013 post by Isaac Schlueter — then Node's project lead and the author of npm — titled "Designing APIs for Asynchrony", which the community immediately renamed after its central warning: don't release Zalgo. Zalgo was an internet meme about summoning something uncontrollable, and the joke landed because the failure really does feel like that: the code is fine, the API is fine, and the program behaves differently on Tuesday.
What makes the post worth remembering is not the meme but the argument. Schlueter's case was that an API's contract includes its timing, and that varying timing for performance is a breaking change disguised as an optimisation. That idea is now built into the language: a promise's .then callback is always scheduled as a microtask, even when the promise is already resolved. Promise.resolve(5).then(fn) does not call fn synchronously, and that is not an oversight — it is Zalgo being ruled out at the specification level.
This is a good example of a convention hardening into a guarantee. For four years the rule was a blog post that experienced developers knew and everyone else discovered the hard way. Promises turned it into something the runtime enforces, which is why the bug in this section is nearly impossible to write with promises and trivial to write with callbacks.
The pyramid, and why it is not merely ugly
"Callback hell" is usually presented as an aesthetic complaint about indentation. The indentation is the symptom; the real cost is that error handling is duplicated at every level, and omitting one instance is silent.
Four steps require four identical error branches, and a fifth step requires a fifth. Every one is an opportunity to forget the return, to handle the error and continue anyway, or to omit the check entirely — and none of those mistakes produces a compile error or a test failure on the happy path.
The waterfall helper is thirty years old in spirit and about eight lines long, and it is what the async library packaged for the ecosystem. It gives a single error path for the whole sequence, which is exactly the property promise chains provide natively with one .catch() at the end.
Two structural problems remain even with helpers, and they are why promises won.
A callback pyramid cannot be composed. You cannot return the sequence from a function and let the caller decide what to do with it — the only way out is another callback, so the callback-ness propagates up through every layer of your codebase.
A callback has no value. There is nothing to store, pass, or return that represents "the eventual result". A promise is a value representing a future result, and that single change is what makes composition possible.
Three questions when reading unfamiliar callback code.
Does every callback start with an error check? Scan the first line of each. A missing check is invisible in review unless you are looking for exactly this.
Does every error path return? if (err) callback(err); without a return falls through and calls back twice. Search for if (err) not followed by return or a block.
Can this API call back synchronously? Look for early returns before the async operation — a cache hit, a validation failure, an empty input. Each is a candidate Zalgo path, and each should be deferred.
The contract callbacks cannot enforce
A promise has three guarantees built into it: it settles once, it settles asynchronously, and it settles either fulfilled or rejected. A callback has none of them, and each absence is a bug you will meet.
The second violation is worth studying, because it is genuinely confusing when met in the wild. A TypeError in your success handler — reading a property of undefined, say — gets caught by the library, reported as though the operation failed, and delivered back to the very handler that produced it. The logs then show a database call failing with Cannot read properties of undefined, and an hour disappears into the database.
The safe shape for a library is to invoke the callback outside the try, or to rethrow asynchronously so the error surfaces as an uncaught exception rather than being laundered into a result. And on your side: do not throw from a callback. There is nowhere for the exception to go that is useful.
Which leaves the third guarantee: a callback might simply never run. A library with an early return that forgets to call back leaves your program waiting forever, with no error, no timeout, and no stack. There is no defence at the call site beyond wrapping every call in a timeout, which nobody does. A promise cannot do this to you silently, because an unresolved promise awaited in an async function is at least visible to a heap dump — and unhandled rejection tracking catches the error case outright.
Callback code is not a historical curiosity — it is the substrate a great deal of production Node still stands on. Every EventEmitter, every stream, the entire http module, and a long tail of npm packages published before 2018 use callbacks, so "I only write async/await" holds right up until you need a library that predates it.
The most common real incident here is the double callback under load. A retry wrapper handles a timeout by calling back with an error, forgets to return, and then the original operation completes and calls back again with success. Under light load the timeout never fires and the bug is invisible. Under heavy load it fires constantly, and downstream code receives two contradictory results for the same request — so a payment gets recorded as both failed and succeeded, and the reconciliation job finds a discrepancy nobody can reproduce. The fix is once() at the boundary, and the reason it is so often absent is that the code is correct on the happy path and correct on the error path, and only wrong when both happen.
The practical guidance for new code is unambiguous: convert at the boundary. Wrap the callback API in a promise once, at the point where it enters your code, and let everything above that line be async/await. Do not let callback style propagate upward — the whole cost of callbacks is that they are contagious.
Escaping: turning a callback into a promise
Converting is mechanical, which is why it can be done generically. Building promisify is the clearest possible demonstration of what a promise adds.
Look at what changed. The error is now caught by try/catch rather than checked by if, so forgetting to handle it is loud rather than silent — an unhandled rejection is reported, whereas an unchecked err argument is not. The results compose: Promise.all runs two operations concurrently in one line, which with callbacks requires a counter, a results array, and a once guard to avoid calling back twice.
Three practical notes.
Node ships this. util.promisify handles the same conversion, respects a util.promisify.custom symbol for APIs whose callback shape is unusual, and is preferable to hand-rolling.
Many APIs now offer promises directly. fs/promises, timers/promises, and dns/promises exist precisely so conversion is unnecessary. Check before wrapping.
Multi-value callbacks are the awkward case. A callback invoked as cb(null, a, b) has no natural promise equivalent, since a promise resolves to one value. The wrapper above returns an array; util.promisify returns only the first value unless told otherwise. This is a rare shape, and it is worth knowing why it is rare — the promise model quietly pushed APIs toward returning one thing.
Common Mistakes
Not checking
errat all. The error is delivered and ignored, andundefinedflows onward as though it were data.Handling the error without
return.if (err) callback(err);falls through into the success path and calls back twice.Calling back with a string instead of an
Error. No stack trace, andif (err)is now testing truthiness of a string.Releasing Zalgo. An API that answers synchronously on a cache hit and asynchronously otherwise makes the caller's code order-dependent on a cache.
Throwing from inside a callback. There is nowhere useful for it to go, and a defensive library may launder it into a fake operation error.
Assuming the callback will definitely run. An early return that forgets to call back hangs the caller forever, silently.
Letting callback style propagate upward. Convert at the boundary; otherwise every caller above it must also be callback-based.
Hand-rolling parallel callback control flow. A counter plus a results array plus a completion guard is three chances to be wrong;
Promise.allis one line.Wrapping an API that already has a promise version. Check
fs/promisesand friends before reaching forpromisify.Trusting a third-party callback to be called once. Wrap it in
once()at any boundary where a double call would corrupt state.
Interview Corner
null when the operation succeeded, and any results come after it. The important thing is that it is only a convention — nothing in the language enforces it, and nothing forces me to look at that first argument. If I ignore it, the failure is silent: the result parameter is undefined, I read a property off it and get undefined again, and that value flows into a response or a database as though it were real data. Nothing throws, so the problem surfaces much later and somewhere unrelated. The habits I would follow are that the error check is the first statement in the callback so a reviewer can see it at a glance, that I always return after handling an error — otherwise execution falls through into the success path and the callback gets invoked twice — and that errors are always real Error objects rather than strings, so I keep the stack trace.process.nextTick, queueMicrotask or setImmediate so the callback is always asynchronous. This is also why the promise specification requires .then callbacks to be scheduled as microtasks even when the promise is already settled: Zalgo is ruled out at the language level rather than left to discipline.return, and then the original operation completes and calls back again with success. Under light load the timeout never fires, so the bug is invisible; under heavy load it fires constantly. That also explains why it is unreproducible — it requires both paths to happen, which is a race rather than an input. I would grep for if (err) without a return, and for any place we pass a callback into a third-party library, since nothing enforces that a callback is invoked once. The immediate mitigation is a once() guard at that boundary so the second invocation is dropped, plus making the downstream write idempotent with an idempotency key, which fixes the corruption even if a double call slips through elsewhere. The structural fix is to convert that API to a promise at the boundary — a promise settles exactly once by specification, so the entire bug class disappears rather than being guarded against.async/await..then is always asynchronous, even on a settled promise.Hands-on
Four exercises. Exercise 1 is the conversion you will actually write; exercise 3 is the control-flow helper the ecosystem built before promises existed.
Summary
- A callback is an inversion of control — you hand another party the continuation of your program, and everything difficult about callbacks follows from that.
- Error-first is a convention, not a guarantee. Nothing forces you to inspect the first argument, and an unchecked error is silent.
- Always
returnafter handling an error, or execution falls through and the callback runs twice. - Errors must be
Errorobjects — strings lose the stack and break truthiness checks. - Never call back synchronously on some paths and asynchronously on others. That is Zalgo, and it makes the caller's behaviour depend on a cache.
- Promises rule Zalgo out at the specification level:
.thenis always scheduled as a microtask, even on a settled promise. - The pyramid's real cost is duplicated error handling, one branch per level, each a chance to omit a check silently.
- A callback has no value, so it cannot be returned, stored or composed — which is why callback style propagates upward through a codebase.
- Callbacks guarantee nothing about arity or timing: they may run twice, or never.
once()guards the first; nothing guards the second. - Convert at the boundary. Wrap the callback API once, and keep everything above it
async/await.
This chapter described the problem. The next describes the answer the language settled on: a promise is a value representing a future result, with a small state machine behind it — pending, fulfilled, rejected — that can only move once and can only move in one direction. That single constraint eliminates most of this chapter's bug classes by construction rather than by discipline, and understanding the state machine is what makes the difference between using promises and reasoning about them.
What strikes me about the callback era is how many of its problems were solved by removing capability rather than adding it. A promise can do strictly less than a callback: it settles once, it settles asynchronously, and it carries exactly one value or one error. Every one of those is a restriction, and the restrictions are the feature. Callbacks could be called twice, could be called synchronously, could be called never, and could carry any number of values — and each of those freedoms turned out to be a bug class rather than an affordance.
That is a pattern worth carrying beyond JavaScript. When an abstraction is painful, the instinct is to add helpers — and the ecosystem did exactly that first, with control-flow libraries providing waterfall, series and parallel. Those helpers were genuinely useful and they did not fix the problem, because the problem was not missing functionality. It was missing guarantees. Promises did not add a feature to callbacks; they took a shape that had no rules and gave it rules.
The part I would push back on is the confidence with which people now dismiss callbacks as a solved historical mistake. They remain the substrate: every EventEmitter, every stream, the http module, and a large body of still-maintained packages. An engineer who cannot read callback code, or who does not recognise a missing return after an error check, is not equipped to debug production Node — and the bugs in that layer are exactly the ones that only appear under load, which is to say the expensive ones.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.