← All Chapters

Chapter 3

Callbacks and Events

Pages 63-88nodejscallbackseventscpseventemitter
Ch 1Ch 2Ch 3Ch 4Ch 5Ch 6Ch 7Ch 8Ch 9Ch 10Ch 11Ch 12Ch 13

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);
}
ℹ️Info

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
⚠️Warning

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()

🔴Danger

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];
}
⚠️Warning

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);
    });
  }
}
💡Tip

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

ℹ️Info

The Four Conventions

  1. Callback comes last -- fs.readFile(path, options, callback)
  2. Error-first -- callback(err, data) -- err is null on success
  3. Propagate errors -- in async code, pass errors to callbacks; never throw
  4. 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
    }
  });
}
🔴Danger

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.

🔴Danger

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

ℹ️Info

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');
⚠️Warning

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));
💡Tip

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

🔴Danger

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

⚠️Warning

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

CriterionCallbackEventEmitter
Result deliveryOnceMultiple times
Number of listenersOne callbackMultiple listeners
Use caseAsync operations with a single resultStreams, servers, recurring events
Error handlingError-first argumentEmit '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)

15 quiz · 22 cards · 2 exercises · Ch 3 of 13