Chapter 3: Callbacks and Events
Summary
Callbacks and EventEmitter are the two fundamental mechanisms Node.js uses to propagate results of asynchronous operations. This chapter introduces Continuation-Passing Style (CPS) -- the pattern where a function passes its result to a callback instead of returning it -- and the critical distinction between synchronous and asynchronous CPS.
The chapter warns against "Unleashing Zalgo": creating functions that behave
synchronously sometimes (when data is cached) and asynchronously other times
(when I/O is needed). The book demonstrates this with the inconsistentRead()
function and the createFileReader() wrapper where reader2's listener never
fires because the callback runs synchronously before the listener is registered.
The remedy is to always guarantee consistent behavior, either fully synchronous
(using readFileSync) or fully asynchronous (using process.nextTick() to
defer the cached path).
The chapter also covers Node.js callback conventions (callback last,
error-first, propagate errors, never throw in async callbacks) and the
Observer pattern via EventEmitter (.on(), .once(), .emit(),
the special 'error' event), explaining when to use each pattern.
Key Concepts
The Callback Pattern
Callbacks are the materialization of the handlers in the reactor pattern (Chapter 1). They are functions invoked to propagate the result of an operation. In JavaScript, functions are first-class citizens -- they can be assigned to variables, passed as arguments, and returned from other functions -- making callbacks a natural fit for the reactor pattern's handler mechanism.
Direct Style vs CPS
Direct style -- the function returns its result:
function add(a, b) {
return a + b;
}
Continuation-Passing Style (CPS) -- the result is passed to a callback (the "continuation") instead of being returned:
function addCps(a, b, callback) {
callback(a + b);
}
Key Distinction
In direct style, control flow is managed by the return statement.
In CPS, control flow is managed by invoking the callback. The callback IS
the continuation -- it represents "what to do next with this result."
Synchronous CPS vs Asynchronous CPS
Synchronous CPS -- the callback is called before the function returns:
function addSync(a, b, callback) {
callback(a + b);
}
console.log('before');
addSync(1, 2, (result) => console.log('Result:', result));
console.log('after');
// Output: before, Result: 3, after
Asynchronous CPS -- the callback is deferred (invoked after the function returns):
function addAsync(a, b, callback) {
setTimeout(() => callback(a + b), 0);
}
console.log('before');
addAsync(1, 2, (result) => console.log('Result:', result));
console.log('after');
// Output: before, after, Result: 3
The Key Difference In synchronous CPS, execution is predictable and sequential -- the callback runs inline. In asynchronous CPS, the callback runs after the current call stack unwinds. The order of output reveals the difference: "before, Result, after" vs "before, after, Result."
Non-CPS Callbacks
Not every function that takes a callback is CPS. Array.map() takes a callback,
but it is used to iterate over elements -- the callback is not a continuation
that receives the propagated result of an async operation:
const doubled = [1, 2, 3].map(x => x * 2); // Not CPS -- direct-style
The Zalgo Problem: inconsistentRead()
Unleashing Zalgo A function that is sometimes synchronous (e.g., when data is cached) and sometimes asynchronous (e.g., when reading from disk) is unpredictable and dangerous. Listeners registered after a synchronous callback invocation are never called.
The book's inconsistentRead() function:
import { readFile } from 'fs';
const cache = {};
function inconsistentRead(filename, callback) {
if (cache[filename]) {
// Invoked synchronously -- BUG: Zalgo!
callback(cache[filename]);
} else {
readFile(filename, 'utf8', (err, data) => {
cache[filename] = data;
callback(data);
});
}
}
The createFileReader() bug that demonstrates the danger:
function createFileReader(filename) {
const listeners = [];
inconsistentRead(filename, (value) => {
listeners.forEach((listener) => listener(value));
});
return {
onDataReady: (listener) => listeners.push(listener)
};
}
const reader1 = createFileReader('data.txt');
reader1.onDataReady((data) => {
console.log('First call data:', data);
// Now the file is cached. Create reader2:
const reader2 = createFileReader('data.txt');
reader2.onDataReady((data) => {
console.log('reader2 data:', data); // NEVER FIRES!
});
});
reader2's listener never fires. On the second call, inconsistentRead() finds
the data in cache and calls the callback synchronously -- during the
createFileReader() call, before reader2.onDataReady(...) registers the
listener. The listeners array is empty when forEach runs.
Fixing Zalgo
Fix 1: Use synchronous APIs -- make the function always synchronous:
import { readFileSync } from 'fs';
const cache = {};
function consistentReadSync(filename) {
if (cache[filename]) {
return cache[filename];
}
cache[filename] = readFileSync(filename, 'utf8');
return cache[filename];
}
Blocking Tradeoff Using synchronous APIs blocks the event loop. This is acceptable during initialization but not recommended inside request handlers or hot paths.
Fix 2: Use process.nextTick() -- guarantee async behavior:
import { readFile } from 'fs';
const cache = {};
function consistentReadAsync(filename, callback) {
if (cache[filename]) {
// Deferred -- always async
process.nextTick(() => callback(cache[filename]));
} else {
readFile(filename, 'utf8', (err, data) => {
cache[filename] = data;
callback(data);
});
}
}
process.nextTick() vs setTimeout(fn, 0)
process.nextTick() executes at the end of the current operation, before the
event loop continues to the next phase (microtask queue). setTimeout(fn, 0)
defers to the timer phase of the next event loop iteration. process.nextTick()
is faster but can starve I/O if overused (the microtask queue drains completely
before I/O callbacks run).
Node.js Callback Conventions
The Four Conventions
- Callback comes last --
fs.readFile(path, options, callback) - Error-first --
callback(err, data)-- err is null on success - Propagate errors -- in async code, pass errors to callbacks; never throw
- Uncaught exceptions -- errors thrown in async callbacks crash the process
The correct way to propagate errors -- the readJSON example:
import { readFile } from 'fs';
function readJSON(filename, callback) {
readFile(filename, 'utf8', (err, data) => {
if (err) {
return callback(err); // return stops execution!
}
try {
const parsed = JSON.parse(data);
callback(null, parsed);
} catch (parseErr) {
callback(parseErr); // Propagate parse errors too
}
});
}
The return callback(err) Pattern
Without return, execution falls through to the success path after calling
the error callback. The callback gets invoked TWICE -- once with the error,
once with the result. Always use return callback(err) to prevent this.
Never throw inside async callbacks Throwing inside an async callback creates an uncaught exception because the original call stack (with any try-catch) is long gone. The process crashes. Always pass errors to the callback instead.
The Observer Pattern: EventEmitter
Definition
The observer pattern defines an object (the subject) that notifies a set of
observers (listeners) when a change in state occurs. In Node.js, this is
implemented by the EventEmitter class from the events module.
The key difference from callbacks: EventEmitter can notify multiple listeners and events can fire multiple times.
import { EventEmitter } from 'events';
const emitter = new EventEmitter();
// Register listeners
emitter.on('data', (chunk) => {
console.log('Listener 1:', chunk);
});
emitter.on('data', (chunk) => {
console.log('Listener 2:', chunk);
});
// One-time listener
emitter.once('ready', () => {
console.log('Ready! (fires only once)');
});
// Emit events
emitter.emit('ready');
emitter.emit('ready'); // No output -- .once() listener was removed
emitter.emit('data', 'hello');
emit() is Synchronous
Events emitted with .emit() are dispatched synchronously -- all listeners
are invoked in registration order, and .emit() returns only after all
listeners finish. This means listeners must be registered BEFORE the event
is emitted.
Making Any Object Observable (FindRegex)
Extend EventEmitter to make your own classes observable:
import { EventEmitter } from 'events';
import { readFile } from 'fs';
class FindRegex extends EventEmitter {
constructor(regex) {
super();
this.regex = regex;
this.files = [];
}
addFile(file) {
this.files.push(file);
return this; // Enable chaining
}
find() {
for (const file of this.files) {
readFile(file, 'utf8', (err, content) => {
if (err) {
return this.emit('error', err);
}
this.emit('fileread', file);
const match = content.match(this.regex);
if (match) {
match.forEach(m => this.emit('found', file, m));
}
});
}
return this;
}
}
// Usage
const finder = new FindRegex(/hello/g);
finder
.addFile('file1.txt')
.addFile('file2.txt')
.find()
.on('found', (file, match) => console.log(`Found "${match}" in ${file}`))
.on('error', (err) => console.error('Error:', err.message));
Why chaining works here
find() returns this, and the readFile callbacks are async. Listeners
registered via .on() after .find() are set up BEFORE the async callbacks
fire. If find() used synchronous I/O, the events would fire before listeners
were registered (same as the constructor emit problem).
Error Propagation in EventEmitter
The 'error' Event is Special If an 'error' event is emitted and no listener is registered, Node.js throws the error as an uncaught exception and terminates the process. Always add an 'error' listener to your EventEmitters.
const emitter = new EventEmitter();
// Without this, emitting 'error' would crash the process
emitter.on('error', (err) => {
console.error('Handled:', err.message);
});
emitter.emit('error', new Error('something broke'));
Memory Leaks and Max Listeners
MaxListenersExceededWarning By default, Node.js warns if more than 10 listeners are registered for a single event. This usually indicates a bug where listeners are added repeatedly without being removed (e.g., inside a loop or request handler).
const emitter = new EventEmitter();
// Increase limit when you intentionally have many listeners
emitter.setMaxListeners(50);
// Or remove listeners when done
const handler = () => console.log('event');
emitter.on('data', handler);
emitter.removeListener('data', handler);
Emitting Events in Constructors
Since emit() is synchronous, emitting in a constructor fires the event immediately -- before the constructor returns and before the caller registers listeners:
// BUG: listener never fires
class BrokenEmitter extends EventEmitter {
constructor() {
super();
this.emit('ready'); // fires NOW, no listeners yet
}
}
const broken = new BrokenEmitter();
broken.on('ready', () => console.log('never called'));
// FIX: defer with process.nextTick
class FixedEmitter extends EventEmitter {
constructor() {
super();
process.nextTick(() => this.emit('ready')); // fires LATER
}
}
const fixed = new FixedEmitter();
fixed.on('ready', () => console.log('this works!'));
EventEmitter vs Callbacks
| Criterion | Callback | EventEmitter |
|---|---|---|
| Result delivery | Once | Multiple times |
| Number of listeners | One callback | Multiple listeners |
| Use case | Async operations with a single result | Streams, servers, recurring events |
| Error handling | Error-first argument | Emit 'error' event |
You can also combine both patterns: a function accepts a callback for the final result but returns an EventEmitter for progress updates:
import { EventEmitter } from 'events';
function complexOperation(callback) {
const emitter = new EventEmitter();
process.nextTick(() => {
emitter.emit('progress', 50);
emitter.emit('progress', 100);
callback(null, 'done');
});
return emitter; // Caller can listen for progress
}
const op = complexOperation((err, result) => {
console.log('Final:', result);
});
op.on('progress', (pct) => console.log(`${pct}%`));
Mind Map
Connections
- Previous: Chapter 2 -- The Module System
- Next: Chapter 4 -- Asynchronous Control Flow Patterns with Callbacks
- The callback pattern is the materialization of handlers from the reactor pattern in Chapter 1
- CPS evolves into Promises and async/await in Chapter 5
- EventEmitter is the foundation of Streams (all streams are EventEmitters)