Callbacks
The callback pattern
Section titled “The callback pattern”A callback is a function you pass to another function, to be called later when an operation completes. This was JavaScript’s original approach to async work — pass a function, and the runtime will call it when the result is ready.
setTimeout(function() { console.log('This runs after 1 second');}, 1000);The second argument to setTimeout is a callback. You hand control to the runtime and your function is called back when the timer fires.
The error-first convention
Section titled “The error-first convention”Node’s built-in APIs use a consistent signature for callbacks:
const fs = require('node:fs');
fs.writeFileSync('data.txt', 'hello from data.txt');
fs.readFile('data.txt', 'utf8', function(err, data) { if (err) { console.error('Read failed:', err.message); return; } console.log('File contents:', data);});The node: prefix marks a built-in module explicitly — it works everywhere require('fs') does, and is required (not just preferred) for a few newer built-ins like node:test and node:sqlite.
The pattern is (err, data) — error always first, result second. If the operation succeeded, err is null. If it failed, err is an Error object and data is undefined.
Always check err before using data. If you ignore a non-null err and try to use data, you will get confusing errors.
Callback nesting
Section titled “Callback nesting”When one async operation depends on the result of another, you nest callbacks:
function step1(callback) { setTimeout(function() { callback(null, 'A'); }, 0);}function step2(input, callback) { setTimeout(function() { callback(null, 'B'); }, 0);}function step3(input, callback) { setTimeout(function() { callback(null, 'C'); }, 0);}function handleError(err) { console.error('Error:', err.message);}
step1(function(err, resultA) { if (err) return handleError(err); step2(resultA, function(err, resultB) { if (err) return handleError(err); step3(resultB, function(err, resultC) { if (err) return handleError(err); console.log('Final:', resultC); }); });});This deep nesting — sometimes called callback hell or the pyramid of doom — is hard to read and error-prone. Promises and async/await (covered in the next lessons) solve this.
Runnable demo
Section titled “Runnable demo”The snippet below simulates two async steps using setTimeout. Each step uses the error-first callback pattern. Run it to see the output order.
The output is always Step 1 done: result-A then Step 2 done: result-B — each step fires only after the previous one completes.
Waiting for an event without nesting: events.once and events.on
Section titled “Waiting for an event without nesting: events.once and events.on”EventEmitter predates Promises, but node:events now ships two helpers that turn event callbacks into modern async code without changing how EventEmitter itself works. once(emitter, name) returns a Promise that resolves with the event’s arguments the first time it fires — useful for a one-shot “wait for this” instead of registering and later removing a listener by hand. on(emitter, name) returns an async iterator, so a stream of repeated events can be consumed with for await instead of a callback that fires forever:
Output (verified on Node 24)
once() resolved with: payload-A
on() saw tick 1
on() saw tick 2
doneBoth helpers still use the same EventEmitter underneath — once/on just wrap the callback registration in a Promise or async iterator so the rest of your code can await it instead of nesting a handler.
Mental Model
Section titled “Mental Model”An error-first callback is a contract, not a suggestion: the function you receive is called exactly once, with err in the first slot. Everything downstream — retries, logging, the next step in a pipeline — depends on every caller honoring that contract every single time, including the times it is inconvenient (an early return after handling err, not falling through to use data anyway). events.once/events.on do not replace this contract; they sit on top of it, converting “register a callback, remember to remove it” into “await a Promise” or “iterate a stream” for the common case of waiting on one or more EventEmitter events.
Trade-offs
Section titled “Trade-offs”Callbacks have no built-in composition — chaining three callback-based steps means three levels of nesting, three separate error-handling branches, and one easy-to-miss return away from a callback firing twice. That is precisely the pyramid of doom Promises and async/await were designed to flatten (next two lessons). Callbacks remain the right tool for anything that can fire more than once over time — a data event on a stream, a repeated timer tick — where a Promise’s “settle once” model does not fit; events.on’s async iterator is the modern way to consume that repeated-firing shape without writing the listener/cleanup dance by hand.
Common Misconceptions
Section titled “Common Misconceptions”- “Forgetting to
returnafter handlingerris a style nitpick.” It is a correctness bug: without thereturn, execution falls through and usesdata— which isundefinedon the error path — often throwing a confusing, unrelatedTypeErrorfar from the real cause. - “
events.onceand the.once()listener method are the same thing.” They are related but different:emitter.once(name, fn)registers a listener that auto-removes after firing once (still callback-style);once(emitter, name)(imported fromnode:events) returns a Promise for that same one-shot wait. - “A callback-based API is inherently slower or worse than a Promise-based one.” Performance is not the difference — ergonomics are. Both ultimately hand control back to the event loop; callbacks just require you to manage nesting, error propagation, and cleanup by hand.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated code that “modernizes” a callback API often wraps an already-Promise-returning function in new Promise(...) anyway — a leftover reflex from converting callback APIs, applied to something that was never callback-based to begin with.
🐛 Spot the bug
function loadConfig() {return new Promise(function (resolve, reject) { fetchConfigFromApi().then(resolve, reject); // fetchConfigFromApi() already returns a Promise});}Show the answer
fetchConfigFromApi() already returns a Promise, so wrapping it in new Promise(...) adds a pointless extra layer — this is the “Promise constructor anti-pattern” (sometimes called the “deferred anti-pattern”). It is not just redundant: if the executor forgets to attach reject (a single .then(resolve) with no error argument, easy to type by accident), a rejection from fetchConfigFromApi() is silently swallowed instead of propagating. The fix is to skip the wrapper entirely — return fetchConfigFromApi(); — since a function that already returns a Promise needs no new Promise around it.