Learning Guides
Menu

Callbacks and Events

8 min read•Node.js Design Patterns

Callbacks and Events

Callbacks and events are the foundation of Node.js asynchronous programming. While promises and async/await have become more popular, understanding callbacks and the EventEmitter is essential—they're still used throughout the Node.js ecosystem.

The Callback Pattern

A callback is a function passed to another function, to be called when an operation completes.

The Continuation-Passing Style (CPS)

Node.js uses CPS for async operations:

JAVASCRIPT
// Synchronous (direct style)
function addSync(a, b) {
  return a + b;
}
const result = addSync(2, 3);
 
// Asynchronous (CPS)
function addAsync(a, b, callback) {
  setTimeout(() => {
    callback(a + b);
  }, 100);
}
addAsync(2, 3, (result) => {
  console.log(result); // 5
});

The Node.js Callback Convention

Node.js callbacks follow a specific convention:

JAVASCRIPT
// Error-first callbacks: callback(error, result)
fs.readFile("file.txt", (err, data) => {
  if (err) {
    console.error("Error:", err);
    return;
  }
  console.log("Data:", data);
});

Rules:

  1. Error is always the first argument
  2. If no error, first argument is null or undefined
  3. Result(s) come after error
  4. Callback is always the last argument

Warning

Always check for errors first. Ignoring errors leads to silent failures and hard-to-debug issues.

JAVASCRIPT
// WRONG: ignoring errors
fs.readFile("file.txt", (err, data) => {
  console.log(data.toString()); // Crashes if err exists!
});
 
// CORRECT: handle errors
fs.readFile("file.txt", (err, data) => {
  if (err) {
    console.error("Failed to read file:", err.message);
    return;
  }
  console.log(data.toString());
});

Synchronous vs Asynchronous Callbacks

Never mix sync and async behavior:

JAVASCRIPT
// BAD: unpredictable behavior
function badCache(key, callback) {
  if (cache.has(key)) {
    callback(cache.get(key)); // Synchronous!
  } else {
    fetchFromDb(key, (err, value) => {
      cache.set(key, value);
      callback(value); // Asynchronous!
    });
  }
}
 
// GOOD: always async
function goodCache(key, callback) {
  if (cache.has(key)) {
    process.nextTick(() => callback(cache.get(key))); // Force async
  } else {
    fetchFromDb(key, (err, value) => {
      cache.set(key, value);
      callback(value);
    });
  }
}

Note

Use setImmediate() or process.nextTick() to defer synchronous callbacks. This ensures consistent behavior for callers.


The Callback Hell Problem

Nested callbacks become unreadable:

JAVASCRIPT
// The pyramid of doom
getUser(userId, (err, user) => {
  if (err) return handleError(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handleError(err);
    getOrderDetails(orders[0].id, (err, details) => {
      if (err) return handleError(err);
      getProduct(details.productId, (err, product) => {
        if (err) return handleError(err);
        console.log("Product:", product);
      });
    });
  });
});

Solutions

1. Named Functions

JAVASCRIPT
function handleProduct(err, product) {
  if (err) return handleError(err);
  console.log("Product:", product);
}
 
function handleDetails(err, details) {
  if (err) return handleError(err);
  getProduct(details.productId, handleProduct);
}
 
function handleOrders(err, orders) {
  if (err) return handleError(err);
  getOrderDetails(orders[0].id, handleDetails);
}
 
function handleUser(err, user) {
  if (err) return handleError(err);
  getOrders(user.id, handleOrders);
}
 
getUser(userId, handleUser);

2. Early Returns

JAVASCRIPT
getUser(userId, (err, user) => {
  if (err) return handleError(err);
 
  getOrders(user.id, (err, orders) => {
    if (err) return handleError(err);
    if (!orders.length) return handleNoOrders();
 
    // Continue only with successful path
  });
});

3. Async Libraries (before Promises)

JAVASCRIPT
const async = require("async");
 
async.waterfall(
  [
    (callback) => getUser(userId, callback),
    (user, callback) => getOrders(user.id, callback),
    (orders, callback) => getOrderDetails(orders[0].id, callback),
    (details, callback) => getProduct(details.productId, callback),
  ],
  (err, product) => {
    if (err) return handleError(err);
    console.log("Product:", product);
  },
);

Note

While these techniques help, Promises and async/await are the modern solution. We cover them in the next chapter.


The EventEmitter

The EventEmitter is Node.js's implementation of the observer pattern. It's used throughout Node.js core (streams, HTTP, child processes).

Basic Usage

JAVASCRIPT
const EventEmitter = require("events");
 
const emitter = new EventEmitter();
 
// Subscribe to an event
emitter.on("message", (text) => {
  console.log("Received:", text);
});
 
// Emit an event
emitter.emit("message", "Hello, World!");
// Output: Received: Hello, World!

Common Methods

JAVASCRIPT
const emitter = new EventEmitter();
 
// on(event, listener) - add listener
emitter.on("data", handler);
 
// once(event, listener) - listener fires only once
emitter.once("ready", () => console.log("Ready!"));
 
// off(event, listener) or removeListener(event, listener) - remove listener
emitter.off("data", handler);
 
// removeAllListeners([event]) - remove all listeners
emitter.removeAllListeners("data");
 
// emit(event, ...args) - trigger event
emitter.emit("data", arg1, arg2);
 
// listenerCount(event) - number of listeners
console.log(emitter.listenerCount("data"));
 
// eventNames() - array of event names with listeners
console.log(emitter.eventNames());

Error Handling

EventEmitter treats error events specially:

JAVASCRIPT
const emitter = new EventEmitter();
 
// If no 'error' listener and error is emitted, Node.js crashes!
emitter.emit("error", new Error("Something broke"));
// UnhandledError: Something broke
 
// Always add error listener
emitter.on("error", (err) => {
  console.error("Error occurred:", err.message);
});
emitter.emit("error", new Error("Something broke"));
// Error occurred: Something broke

Warning

Always attach an error event listener to any EventEmitter. Unhandled error events crash your application.

Memory Leak Detection

By default, Node.js warns when you add more than 10 listeners to an event:

JAVASCRIPT
const emitter = new EventEmitter();
 
for (let i = 0; i < 15; i++) {
  emitter.on("data", () => {});
}
// Warning: Possible EventEmitter memory leak detected.
 
// To increase the limit (or 0 for unlimited):
emitter.setMaxListeners(20);
// or
EventEmitter.defaultMaxListeners = 20;

Creating Observable Classes

Extend EventEmitter to create your own observable classes:

A File Watcher Class

JAVASCRIPT
const EventEmitter = require("events");
const fs = require("fs");
 
class FileWatcher extends EventEmitter {
  constructor(filename) {
    super();
    this.filename = filename;
    this.watcher = null;
  }
 
  start() {
    this.watcher = fs.watch(this.filename, (eventType, filename) => {
      if (eventType === "change") {
        fs.readFile(this.filename, "utf8", (err, content) => {
          if (err) {
            this.emit("error", err);
            return;
          }
          this.emit("change", content);
        });
      }
    });
 
    this.emit("started");
    return this;
  }
 
  stop() {
    if (this.watcher) {
      this.watcher.close();
      this.emit("stopped");
    }
    return this;
  }
}
 
// Usage
const watcher = new FileWatcher("config.json");
 
watcher
  .on("started", () => console.log("Watching..."))
  .on("change", (content) => console.log("File changed:", content))
  .on("error", (err) => console.error("Error:", err))
  .on("stopped", () => console.log("Stopped watching"))
  .start();
 
// Later: watcher.stop();

Async Event Handlers

Event handlers are called synchronously. For async work, be careful:

JAVASCRIPT
const emitter = new EventEmitter();
 
// Errors in async handlers won't be caught by event error handling
emitter.on("data", async (data) => {
  const result = await processData(data); // If this throws...
  // ...it becomes an unhandled promise rejection
});
 
// Solution 1: Wrap in try-catch
emitter.on("data", async (data) => {
  try {
    const result = await processData(data);
  } catch (err) {
    emitter.emit("error", err);
  }
});
 
// Solution 2: Use a wrapper
function asyncHandler(emitter, handler) {
  return (...args) => {
    Promise.resolve(handler(...args)).catch((err) => {
      emitter.emit("error", err);
    });
  };
}
 
emitter.on(
  "data",
  asyncHandler(emitter, async (data) => {
    const result = await processData(data);
  }),
);

Combining Callbacks and Events

Sometimes you need both patterns:

Database Connection with Events and Callbacks

JAVASCRIPT
const EventEmitter = require("events");
 
class Database extends EventEmitter {
  constructor(config) {
    super();
    this.config = config;
    this.connected = false;
    this.connection = null;
  }
 
  connect(callback) {
    // Simulate async connection
    setTimeout(() => {
      this.connected = true;
      this.connection = { id: Date.now() };
 
      this.emit("connected", this.connection);
 
      if (callback) callback(null, this.connection);
    }, 100);
 
    return this;
  }
 
  query(sql, callback) {
    if (!this.connected) {
      const err = new Error("Not connected");
      this.emit("error", err);
      if (callback) callback(err);
      return this;
    }
 
    // Simulate query
    setTimeout(() => {
      const results = [{ id: 1 }, { id: 2 }];
      this.emit("query", sql, results);
      if (callback) callback(null, results);
    }, 50);
 
    return this;
  }
 
  disconnect(callback) {
    this.connected = false;
    this.connection = null;
    this.emit("disconnected");
    if (callback) callback();
    return this;
  }
}
 
// Usage with callbacks
const db = new Database({ host: "localhost" });
db.connect((err, conn) => {
  if (err) return console.error(err);
  db.query("SELECT * FROM users", (err, results) => {
    console.log("Results:", results);
  });
});
 
// Usage with events
const db2 = new Database({ host: "localhost" });
db2
  .on("connected", () => db2.query("SELECT * FROM users"))
  .on("query", (sql, results) => console.log("Results:", results))
  .on("error", (err) => console.error("Error:", err))
  .connect();

Event-Driven Architecture Patterns

The Observer Pattern

EventEmitter IS the observer pattern:

JAVASCRIPT
class Subject extends EventEmitter {
  constructor() {
    super();
    this.state = {};
  }
 
  setState(key, value) {
    this.state[key] = value;
    this.emit("stateChange", key, value, this.state);
  }
}
 
// Observers
const logger = (key, value) => console.log(`State: ${key}=${value}`);
const validator = (key, value, state) => {
  if (key === "count" && value < 0) {
    throw new Error("Count cannot be negative");
  }
};
 
const subject = new Subject();
subject.on("stateChange", logger);
subject.on("stateChange", validator);
 
subject.setState("count", 5); // Logs and validates

The Pub/Sub Pattern

Similar to observer but with channels:

JAVASCRIPT
class PubSub {
  constructor() {
    this.channels = new Map();
  }
 
  subscribe(channel, handler) {
    if (!this.channels.has(channel)) {
      this.channels.set(channel, new Set());
    }
    this.channels.get(channel).add(handler);
 
    // Return unsubscribe function
    return () => this.channels.get(channel).delete(handler);
  }
 
  publish(channel, data) {
    if (!this.channels.has(channel)) return;
    for (const handler of this.channels.get(channel)) {
      handler(data);
    }
  }
}
 
const pubsub = new PubSub();
 
const unsub = pubsub.subscribe("user:created", (user) => {
  console.log("New user:", user.name);
});
 
pubsub.publish("user:created", { name: "Alice" });
unsub(); // Unsubscribe

Summary

Callbacks and events are foundational to Node.js:

PatternUse Case
CallbacksOne-time async operations
EventEmitterMultiple events, multiple listeners, streams

Callback best practices:

  1. Error-first: Always callback(err, result)
  2. Handle errors: Check err before using result
  3. Consistent async: Never mix sync and async
  4. Avoid nesting: Use named functions or modern patterns

EventEmitter best practices:

  1. Always handle errors: Attach error listener
  2. Clean up listeners: Remove when no longer needed
  3. Watch for leaks: Monitor listener counts
  4. Extend for custom classes: Create observable objects

Note

While Promises and async/await are more ergonomic for most async code, EventEmitter remains the right choice for: - Streams - Long-running connections - Multiple event types - Multiple consumers of the same events