Callback Patterns and Error-First Convention
Every Async Pattern Sits on Top of Callbacks
Promises, async/await, streams - all of them ultimately bottom out at a callback. Somewhere in the C++ binding layer, a function pointer gets stored, libuv finishes some work, and a JavaScript function gets called with the result. That JavaScript function is a callback. The entire async model of Node.js was built around this mechanism before anything fancier existed, and the plumbing hasn't changed.
A callback is a function you pass as an argument to another function, to be called later. That's it. There's no special syntax, no keyword, no class. It's just a function reference handed off to someone else's code. What makes it interesting is when and how it gets called - and what breaks when you get those two things wrong.
const fs = require("fs");
fs.readFile("/etc/hostname", "utf8", (err, data) => {
if (err) throw err;
console.log(data.trim());
});You're passing an anonymous arrow function as the third argument to fs.readFile. Node stores that function reference, hands the read operation to libuv, and returns immediately. Your code keeps running. Later - maybe a millisecond, maybe fifty - the event loop picks up the completed I/O result and calls your function with either an error or the file contents.
The word "later" is doing real work in that sentence. The callback doesn't run synchronously inside readFile. It runs on a future iteration of the event loop, after the call stack that set it up has already unwound. This distinction between synchronous and asynchronous execution is the entire foundation of everything in this chapter.
And here's the thing most explanations skip: Node's core APIs validate the callback argument synchronously. If you accidentally pass undefined instead of a function, readFile throws a TypeError immediately - before any I/O gets queued. But that validation only checks the type. Node confirms the argument is a function and stores a reference to it. It doesn't inspect the function body, check for error handling, or verify that your callback actually does anything useful with the result. The function reference is opaque. What happens inside it is entirely your problem.
Synchronous vs Asynchronous Callbacks
The term "callback" applies to both synchronous and asynchronous functions passed as arguments. The distinction between the two is probably the single most important thing to internalize before going further.
const numbers = [3, 1, 4, 1, 5];
numbers.forEach((n) => {
console.log(n);
});
console.log("done");The function passed to forEach is a callback. But it's synchronous - it runs immediately, inline, on the same call stack. Every invocation of your callback completes before forEach returns. By the time console.log("done") runs, all five numbers have already been logged. The callback and the calling code share the same execution context. If the callback throws, the exception propagates up through forEach, through your calling code, and can be caught by a surrounding try/catch. Everything behaves the way you'd expect from sequential execution.
Now compare that with fs.readFile. When you pass a callback to readFile, the function returns instantly. Your callback hasn't been called yet. It gets called later, in a completely different call stack, after libuv signals that the I/O operation completed. The original call frame is gone. The synchronous flow has moved on.
This is the split that defines everything about async programming in Node. Synchronous callbacks are just regular function calls with extra indirection. Asynchronous callbacks are deferred execution - the function runs at a later time, in a later tick, on a fresh call stack. And this split is exactly where error handling gets complicated.
It's easy to tell which kind you're dealing with in most cases. Any callback passed to a Node core API that performs I/O is asynchronous: fs.readFile, http.get, dns.lookup, child_process.exec. Any callback passed to an array method (map, filter, forEach, reduce) is synchronous. EventEmitter listeners (covered in Chapter 3) are synchronous - when you call emitter.emit('data', chunk), all registered listeners for 'data' run synchronously, in registration order, before emit returns. The setTimeout callback is asynchronous. The Array.sort comparator is synchronous.
Where it gets tricky is user-space libraries. A library function might call your callback synchronously for a cached result and asynchronously for a cache miss. This inconsistency is a source of subtle bugs, and there's a name for it in the Node ecosystem: "releasing Zalgo." The rule, established early in Node's history by Isaac Schlueter, is strict: if a function ever calls its callback asynchronously, it must always call it asynchronously, even when the result is immediately available. process.nextTick() (covered in Chapter 1) exists partly for this reason - it lets you defer a synchronous result to the next tick, maintaining consistent async behavior.
Continuation-Passing Style
There's a formal name for the pattern of passing a callback that receives the result: continuation-passing style, or CPS. Instead of returning a value directly, a function "passes" the result to its continuation - the callback function that represents "what to do next."
Direct style looks like this:
const data = fs.readFileSync("/etc/hostname", "utf8");
console.log(data.trim());You call a function, it returns a value, you use the value. The function returns control and the result to the caller. The program counter moves to the next line. Everything is sequential, everything is on the same stack.
CPS inverts this relationship. The function doesn't return the result. Instead, it calls another function with the result:
fs.readFile("/etc/hostname", "utf8", (err, data) => {
console.log(data.trim());
});The callback is the continuation. It's the rest of your program from that point onward, packaged up as a function argument. Everything you want to do with the file's contents goes inside that callback, because that's the only place where data exists.
There's an implication here that takes a moment to absorb: in CPS, the function you called never "returns" in the meaningful sense. Yes, fs.readFile returns to its caller (it returns undefined, actually). But the result doesn't come back through the return value. It arrives through a different channel entirely - the callback invocation. The return value is irrelevant. The continuation carries all the meaning.
This has a cascading effect. If your continuation itself needs to do async work, its result also gets passed to a callback. And that callback's result goes to another callback. Each step's "rest of the program" nests inside the previous step:
fs.readFile("config.json", "utf8", (err, raw) => {
const config = JSON.parse(raw);
fs.readFile(config.dataPath, "utf8", (err, data) => {
fs.writeFile("output.txt", data, (err) => {
console.log("wrote output");
});
});
});Three async operations, three levels of nesting. Each callback is a continuation of the previous one. The control flow reads top-to-bottom inside-to-inside instead of top-to-bottom line-by-line. This is CPS in practice. It works. It's also where readability starts degrading fast, but we'll get to that.
The terminology matters because CPS is a concept from programming language theory going back to the 1970s. Scheme compilers use CPS as an intermediate representation. Haskell's continuation monad is the same idea in a different syntax. The principle is the same across all of these: instead of returning a value to the caller, pass it forward to the next computation. Node adopted this pattern because JavaScript had first-class functions and no better async primitive at the time. Callbacks were the only tool available. First-class functions made CPS ergonomic enough to use at scale. And the error-first convention made it safe enough to build an ecosystem on.
One distinction worth making: there's synchronous CPS and asynchronous CPS. A function that takes a callback and calls it synchronously (like Array.forEach) is still using CPS - it's just a synchronous continuation. A function that takes a callback and calls it on a later tick (like fs.readFile) is asynchronous CPS. Node uses the async variant almost exclusively for I/O operations, and the sync variant for iterative utilities. The async variant is the one that creates all the complexity, because the callback runs on a different call stack with a different error handling context.
The try/catch Gap
Here's the thing that trips up everyone coming from synchronous programming: try/catch doesn't work across async boundaries.
try {
fs.readFile("/nonexistent", "utf8", (err, data) => {
console.log(data.trim());
});
} catch (e) {
console.log("caught:", e.message);
}If /nonexistent doesn't exist, this code doesn't hit the catch block. The try/catch wraps the call to fs.readFile, which succeeds - it successfully queues the I/O request and returns. No error thrown, no exception. The try block completes normally. Your program continues past the catch.
The error happens later. Libuv tries to open the file, gets an ENOENT from the kernel, and stores the error code in the request struct. On the next event loop iteration, the completion callback fires. The C++ layer constructs a JavaScript Error object from the ENOENT code and calls your callback with (err, undefined). By this point the try/catch block is long gone. The call stack has unwound. The catch clause has already been popped off the execution context. Your callback runs in a completely different call stack, one that has no try/catch wrapped around it.
So err in the callback is a truthy Error object, data is undefined, and data.trim() throws a TypeError. That TypeError propagates up the callback's call stack, hits no handler, and becomes an uncaughtException. The process crashes (or at least it should - swallowing that error silently would be worse).
This is the fundamental gap. Synchronous code can use try/catch because the error and the handler share the same call stack. Async callbacks run on a different call stack. You physically cannot catch their errors from the code that set them up.
Consider what the call stack looks like when your callback executes. It's something like: FSReqCallback::AfterOpen -> MakeCallback -> your callback. There's nothing from your original code in that stack. The try/catch you wrote exists only in the call stack that ran fs.readFile(), and that stack was popped off the moment readFile returned. Two different stacks, two different execution contexts, no connection between them.
The err argument exists because there's no other reliable way to communicate failure across that stack boundary. You can't throw. You can't use try/catch. The error has to travel as data - as a function argument - from the code that detected the failure (the C++ binding layer) to the code that can handle it (your callback).
A lot of Node beginners write code with the pattern above and wonder why their errors "disappear." They don't disappear. They fire as err arguments to callbacks nobody checked. Or they throw inside callbacks and crash the process because no try/catch exists in the callback's own stack. Both patterns come from the same root cause: the async boundary creates a gap that synchronous error handling can't bridge.
Why Error-First
The error-first callback convention is the solution Node adopted for this gap. Every async callback in Node's core API takes an error as its first argument:
fs.readFile(path, encoding, (err, data) => { ... });
fs.writeFile(path, content, (err) => { ... });
fs.stat(path, (err, stats) => { ... });If the operation succeeds, err is null and the remaining arguments contain the result. If it fails, err is an Error object and the result arguments are undefined (or absent entirely -fs.writeFile's callback only gets err, since a successful write has no return value).
Why first? Because it forces you to deal with errors before you touch the result. The calling convention makes error handling impossible to forget - the error is literally the first thing you see in the parameter list. And it enables a clean early-return pattern:
fs.readFile("data.json", "utf8", (err, raw) => {
if (err) return handleError(err);
const parsed = JSON.parse(raw);
processData(parsed);
});Check error, bail out if present, continue with the happy path. The return is doing double duty here: it both hands the error to your handler and prevents execution from falling through to code that assumes raw is valid.
This pattern is so pervasive that you'll see it in virtually every callback-based Node codebase. The if (err) return ... guard at the top of every callback is the equivalent of a try/catch in synchronous code - it's your error boundary. Skip it, and you're running code that assumes success without verifying it. Eventually raw will be undefined, JSON.parse(undefined) will throw, and you'll get a confusing stack trace that points to the JSON.parse line rather than to the actual I/O error that caused the problem.
This wasn't always the convention. Early Node modules used a mix of patterns - some put errors second, some emitted events, some threw synchronously even for async operations. Some modules used separate success and error callbacks, inspired by jQuery's AJAX API. Ryan Dahl and the early Node community settled on error-first as the standard after experimenting with alternatives. By the Node 0.4-0.8 era, error-first had become the universal convention for callback APIs. The Node documentation formalized it, and every core module follows it.
The convention is a contract between the API and the consumer. When you see (err, result) as a callback signature in Node's core, you know exactly what to expect: err is either null or an Error, and result is valid only when err is null. Some community libraries broke this contract - accepting (result) only, or using separate success and error callbacks, or passing error codes instead of Error objects - and it caused pain in practice. The single callback with error-first became the standard because it was the simplest pattern that actually worked consistently.
There's a subtlety around what err actually is when it's truthy. Node core always passes an instance of Error (or a subclass like TypeError, RangeError, or the custom system error classes with code properties like ENOENT, EACCES, EPERM). The error object carries a .message, a .stack trace, and often a .code string you can switch on. Community libraries are less consistent - some pass plain strings, some pass objects with custom properties, some pass error codes as numbers. If you're building callback APIs, always pass proper Error objects. Stack traces matter. .code properties matter. A string with an error message tells you what happened but nowhere in the codebase it originated from.
How Node Dispatches Callbacks at the C++ Layer
When you call fs.readFile(path, callback), the JavaScript function returns almost immediately. But a lot of machinery has already been set in motion underneath. The path from your function call to the callback invocation crosses four boundaries and involves seven distinct handoffs.
The fs.readFile function in JavaScript doesn't do file I/O directly. It constructs a ReadFileContext object to track the operation's state - the file path, the encoding option, the buffer that'll accumulate chunks of data as they're read, and your callback function. It then calls into the C++ binding layer via internalBinding('fs'), which is the bridge from JavaScript-land to Node's native code.
At the C++ level, Node creates a FSReqCallback object. This is a C++ class that inherits from ReqWrap<uv_fs_t>, which wraps a libuv filesystem request. The FSReqCallback has an associated JavaScript wrapper object, and your callback gets stored as the oncomplete property on that wrapper. A V8 Persistent handle keeps the wrapper object alive for the duration of the async operation. The persistent handle acts as a GC root - it makes the wrapper (and by extension your callback) reachable from the root set, so V8's mark-sweep collector won't collect it while the I/O is in flight. Without this persistent reference, V8 could GC the wrapper and your callback before libuv finishes the work, and there'd be nothing to call when the result comes back.
The C++ binding then calls uv_fs_open (or uv_fs_read, depending on the specific operation), passing the libuv event loop handle, the uv_fs_t request struct, and a C++ completion callback. This is where the work leaves the JavaScript thread entirely. Libuv queues the filesystem request on its thread pool. One of the thread pool workers (by default there are four, controlled by UV_THREADPOOL_SIZE) picks up the request and performs the actual open() or read() syscall on a background thread. The worker stores the syscall's return value - either the file contents or an error code - in the uv_fs_t struct's result fields.
When the syscall completes, the thread pool worker posts the result back to the main thread via uv_async_send, signaling through an internal async handle (loop->wq_async). This wakes up the event loop if it's currently blocked in epoll_wait (Linux) or kevent (macOS). On the next iteration, the async handle's callback (uv__work_done) runs on the main thread, iterates the completed work queue, and fires the C++ completion callback that was registered earlier. This all runs on the JavaScript thread, because the event loop itself runs on the main thread.
Inside the completion callback, the C++ code extracts the result from the uv_fs_t struct: the file descriptor, the bytes read, or the error code. If the result is negative (indicating an error), the code constructs a JavaScript Error object with the appropriate code property (ENOENT, EACCES, etc.) and message. If successful, it wraps the data in a Buffer or converts it to a string using the requested encoding. It then retrieves your JavaScript callback function from the oncomplete property on the FSReqCallback's JavaScript wrapper object. And then it calls MakeCallback.
MakeCallback is one of the most important internal functions in Node. Its full name is node::MakeCallback, and it does considerably more than just invoke your function. Here's the sequence:
- It enters the V8 isolate and context, setting up the execution environment for JavaScript code
- It creates an
InternalCallbackScope, which sets up async_hooks tracking (assigning async IDs, emittingbeforeandafterevents) - It calls your JavaScript callback with
Function::Call, passing the error (or null) and the result as arguments - After your callback returns, it runs
InternalCallbackScope::Close, which triggers the microtask checkpoint - During
InternalCallbackScope::Close, Node drains two separate queues. First, theprocess.nextTick()queue - a Node-managed queue independent of V8. Then, V8's microtask queue, which holds resolved promise.then()handlers. If draining either queue schedules more work into either queue, the cycle repeats until both are empty. - It checks if the process needs to exit (no more active handles or requests)
Step 5 is worth dwelling on. Every time a C++ callback fires into JavaScript, MakeCallback ensures both queues are drained afterward. The nextTick queue always drains first, which is why process.nextTick() callbacks run before promise handlers. And because this draining happens after every MakeCallback invocation, nextTick callbacks and promise handlers always run between I/O callbacks. Without this, they'd accumulate and only drain at specific points in the event loop, and the timing guarantees you depend on would break.
The reason callbacks can feel opaque is that this entire chain - JavaScript to C++ binding, C++ to libuv request, libuv to thread pool, thread pool syscall, syscall result back to libuv, libuv to C++ completion handler, C++ to MakeCallback, MakeCallback to your JavaScript function, then microtask drain - is completely hidden. You just see fs.readFile(path, callback) and your callback fires later with a result. Eight handoffs, four boundary crossings (JS/C++, C++/libuv, libuv/threadpool, threadpool/libuv back again), a microtask checkpoint, and async_hooks instrumentation. All invisible.
One subtle consequence: the this binding inside your callback depends on which fs API you called. For most single-shot operations (fs.stat, fs.open, fs.access), this is globalThis. But fs.readFile is different - it uses a ReadFileContext object to track multi-step read state (the fd, accumulated buffers, encoding), and your callback runs with this bound to that context object. In practice nobody relies on this in fs callbacks - you use the arguments. But if you've ever logged this inside a readFile callback using a regular function and seen an unfamiliar object with fd, buffers, and encoding properties, that's the ReadFileContext.
Another consequence: if your callback throws, the exception propagates up through MakeCallback's Function::Call, which catches it and triggers the uncaughtException handler. The exception doesn't crash the C++ layer. MakeCallback is designed to handle JavaScript exceptions gracefully, even though they happen in the middle of a C++ callback chain.
Callback Patterns in Practice
The simplest async callback pattern is sequential: do one thing, then in its callback, do the next thing.
fs.readFile("input.txt", "utf8", (err, data) => {
if (err) return console.error(err);
const upper = data.toUpperCase();
fs.writeFile("output.txt", upper, (err) => {
if (err) return console.error(err);
console.log("done");
});
});Read a file, transform the content, write the result. Each operation starts in the callback of the previous one. The second fs.writeFile only executes after the first fs.readFile completes. This is readable enough with two or three operations. Beyond that, the nesting gets deep and the indentation pushes your actual logic further and further to the right.
Parallel operations are trickier. If you need to read three files and process them together, you can't nest them sequentially - that would serialize the I/O, making each read wait for the previous one to finish. Three reads that could run in 5ms each would take 15ms total. You want all three reads in flight simultaneously.
The standard pattern uses a counter:
const files = ["a.txt", "b.txt", "c.txt"];
const results = new Array(files.length);
let pending = files.length;
files.forEach((file, i) => {
fs.readFile(file, "utf8", (err, data) => {
if (err) return console.error(err);
results[i] = data;
if (--pending === 0) processAll(results);
});
});All three readFile calls fire immediately, without waiting for each other. Each callback decrements the pending counter and stores its result at the correct index. When the counter hits zero, all reads have completed, and processAll receives the results in the original order (because we use i as the index, preserving the position regardless of completion order).
This is a correct parallel-join implementation. It's also tedious to write, easy to get wrong, and only handles the success case cleanly. Think about what happens if b.txt doesn't exist. Its callback gets called with an error. We log the error and return. But the counter never decrements for b.txt's success, so... actually, the counter doesn't decrement at all for the errored entry. That means pending might reach zero only if the two successful reads complete, which it will because pending started at 3 and only gets decremented twice. So processAll never fires. The program silently stalls. Or if we move the decrement before the error check, processAll fires with results[1] being undefined. Neither outcome is correct.
Error handling in parallel callbacks gets messy. A proper version needs a guard:
let errored = false;
files.forEach((file, i) => {
fs.readFile(file, "utf8", (err, data) => {
if (errored) return;
if (err) { errored = true; return handleError(err); }
results[i] = data;
if (--pending === 0) processAll(results);
});
});First error wins. Once any read fails, subsequent completions are ignored. This is the same semantics Promise.all uses - reject on the first failure. You can also implement "collect all errors" semantics, similar to Promise.allSettled, but the boilerplate gets heavy. You'd need a separate errors array, a combined counter for successes and failures, and a final callback that receives both arrays. At this point you're basically reimplementing promise combinators with manual state tracking.
There's also the "waterfall" pattern: a series of async operations where each one's result feeds into the next, but you also need to pass accumulated state through the chain. Without abstractions, this means passing extra arguments through closure scope, which gets tangled fast. If step 3 needs the result of step 1 (not just step 2), you either hoist a variable into the outer scope or pass it as a parameter through intermediate callbacks that don't use it.
And then there's the "retry" pattern. If an operation fails and you want to retry it with exponential backoff, you need to wrap the callback in a recursive function that calls setTimeout on failure and re-issues the original async call. The retry count, the backoff delay, the max retries - all of these have to be tracked in closure variables around a recursive callback. It works, but reading the code requires mentally simulating the recursion across async boundaries.
The library async.js (by Caolan McMahon) appeared early in Node's history precisely because these patterns were so common and so error-prone. It provided async.parallel, async.series, async.waterfall, async.retry, async.queue, and dozens of other flow control utilities. At its peak, async was the most-depended-on package on npm. It existed because raw callbacks made flow control a manual exercise in state tracking, and developers kept reimplementing the same patterns with the same bugs.
Callback Hell and Inversion of Control
The nesting problem has a name: callback hell. Sometimes called the pyramid of doom.
getUser(userId, (err, user) => {
getOrders(user.id, (err, orders) => {
getOrderDetails(orders[0].id, (err, details) => {
getShippingStatus(details.trackingId, (err, status) => {
updateUI(user, orders, details, status);
});
});
});
});Six levels of indentation. Every async step pushes the code one level deeper. Reading this requires tracking which scope you're in, which variables are available from outer closures, and which err shadows which (every level declares its own err, each shadowing the previous one - a linting nightmare). The shape of the code is a sideways triangle - hence "pyramid."
But the indentation is the surface problem. Named functions flatten it. Modular decomposition reduces it. You can rewrite the pyramid as a sequence of named functions and it looks fine. The deeper issue - the one that named functions don't fix - is inversion of control.
When you pass a callback to a function, you're handing control of your program's continuation to someone else's code. You're saying: "here is what I want to happen next; I trust you to call this at the right time, exactly once, with the right arguments." The receiving function now owns your continuation. You've inverted who controls the flow.
That raises trust questions, and these are the ones that actually caused production bugs at scale:
Will the callback be called at all? Some libraries had code paths where the callback was never called, leaving your program hanging indefinitely. A database driver might fail to call back on timeout. A middleware function might forget to call next() on a certain error path. No timeout mechanism, no error, just silence. Your program appears to freeze, memory slowly climbs as pending request state accumulates, and you have no stack trace to diagnose it because nothing threw.
Will it be called exactly once? A bug in the callee could call your callback twice. This is especially dangerous when your callback has side effects - writing to a database, sending an HTTP response. Double-calling a callback often manifests as a "headers already sent" error in Express, or duplicate database records, or double-charged payments. It's one of the most insidious callback bugs because the second invocation happens silently unless you've added your own once-guard.
Will it be called asynchronously? If a library sometimes calls the callback synchronously (on the current tick) and sometimes asynchronously (on a future tick), your code's behavior becomes order-dependent. Event listeners attached after a synchronous callback invocation won't fire. State that you expect to be set before the callback runs might not be. The Node community calls this "releasing Zalgo." The rule: if a function ever calls its callback asynchronously, it must always call it asynchronously, even for cached results or trivial operations. process.nextTick() exists partly to make it easy to defer a synchronous result into the next microtask, maintaining consistent behavior.
Will it receive the right arguments? The error-first convention helps here, but nothing enforces it at a language level. A library could pass a string instead of an Error object, or put the error in the wrong position, or pass extra arguments you don't expect. There's no type signature, no interface, no contract enforcement. It's purely conventional.
Named functions help with the readability problem but don't solve any of these trust issues:
function onUser(err, user) {
if (err) return handleError(err);
getOrders(user.id, onOrders);
}
function onOrders(err, orders) {
if (err) return handleError(err);
getOrderDetails(orders[0].id, onDetails);
}
getUser(userId, onUser);The pyramid is gone. Each step is a named function. The flow reads sequentially at the module level. But you still hand onOrders to getOrders and trust it to be called correctly. You still can't guarantee single invocation. You still can't enforce async timing. The trust problems remain. You've improved readability without improving safety.
Some developers wrapped callbacks in "once" guards to prevent double-calling:
function once(fn) {
let called = false;
return function (...args) {
if (called) return;
called = true;
fn.apply(this, args);
};
}Pass once(callback) instead of callback, and if the callee accidentally calls it twice, the second call is silently ignored. This is a band-aid. It hides the bug instead of surfacing it. But when the alternative is a double-charged credit card, hiding the bug is the lesser evil.
The express middleware ecosystem was full of code paths where next() was called twice, or where error callbacks were silently swallowed. The Node community built linting rules (node/no-callback-literal, handle-callback-err), conventions, and wrapper utilities to mitigate these problems. But the fundamental architecture of callbacks made the trust issues inherent. You hand off control. You hope for the best.
Promises were the structural answer. By returning an object that represents the future result, control stays with the caller. The caller decides when to .then(), can attach multiple handlers, gets guaranteed asynchronous resolution, and gets exactly-once settlement (a promise can only resolve or reject once, and additional calls to resolve() or reject() are silently ignored). But that's subchapter 02's territory.
Callbacks Today
Despite promises and async/await dominating modern Node code, callbacks haven't gone away. They're still the substrate beneath everything.
libuv is entirely callback-based. Every async operation in libuv completes by calling a C function pointer. There's no libuv equivalent of promises or futures - it's function pointers all the way down. Node's C++ binding layer translates libuv callbacks into JavaScript function calls via MakeCallback. Even when you use the fs.promises API, the underlying implementation still uses libuv callbacks - the promise wrapper just creates a FSReqPromise (instead of FSReqCallback) that resolves or rejects a JavaScript promise when the libuv callback fires. await fs.promises.readFile(path) is syntactic sugar that eventually bottoms out at a callback stored in a C++ persistent handle, invoked by a libuv thread pool worker's completion handler.
util.callbackify exists for converting a promise-returning function back into a callback-accepting one. It's the reverse of util.promisify. This sounds backwards - why would you want to go from promises to callbacks? It's used in Node's own internals where callback-based APIs need to call newer promise-based functions, and in libraries that need to maintain backward compatibility with callback consumers while internally using async/await.
Performance is the other reason callbacks persist on hot paths. A raw callback has zero allocation overhead beyond the function object itself (which is often already allocated as a closure in the surrounding scope). A promise, by contrast, creates a Promise object on the V8 heap, allocates internal resolve and reject functions, wraps the executor, and schedules microtask queue processing on settlement. For a single call, the difference is negligible - maybe a microsecond, a handful of heap allocations. On hot paths processing tens of thousands of operations per second, it adds up. Some performance-sensitive libraries (database drivers like pg, Redis clients like ioredis, binary protocol implementations) still offer callback APIs alongside promise APIs for this reason. The callback path allocates less, triggers fewer GC pauses, and avoids the microtask scheduling overhead.
But performance differences between callbacks and promises are shrinking with each V8 release. V8 has optimized promise creation and settlement repeatedly - TurboFan can now inline promise resolution paths, and the await desugaring generates fewer intermediate objects than it used to. The gap is considerably narrower than it was in the Node 8 era. For most application code, the ergonomic and safety benefits of promises and async/await outweigh the performance cost. Profile before optimizing.
EventEmitter listeners are callbacks too. When you write server.on('request', handler), that handler is a callback registered for the 'request' event. The EventEmitter pattern (covered in Chapter 3) is a multi-callback pattern - multiple listeners for the same event, called synchronously in registration order. Streams emit events with callbacks. The 'data' handler on a Readable stream is a callback. The 'error' handler is a callback.
Even setTimeout and setInterval are callback-based APIs. You pass a function, it gets called later. The timer phase of the event loop fires timer callbacks in expiration order. Every scheduler in Node is callback-based at the C level.
And here's the thing that might be surprising: the Promise constructor itself takes a callback. The (resolve, reject) => { ... } function you pass to new Promise() is a callback - it just happens to be called synchronously, not asynchronously. The abstraction that replaced callbacks starts with one.
Callbacks are Node's lowest-level async primitive. Every higher-level pattern - promises, async/await, async iterators, readable stream pipelines - compiles down to a callback at the bottom of the stack. The machinery described earlier in this subchapter -FSReqCallback, persistent handles, MakeCallback, microtask checkpoints - runs underneath all of them. Understanding that machinery is what separates knowing how to use async/await from understanding why it behaves the way it does.