Skip to content
Codeloom
Node.js

Event Emitters in Node.js

Understand the EventEmitter pattern in Node.js. Covers custom emitters, error handling, async events, memory leaks, and real-world patterns.

·6 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How EventEmitter works under the hood
  • Creating custom event emitters
  • Error handling and the error event
  • Avoiding memory leaks with listener management
  • Real-world patterns: typed events, async events, once

Prerequisites

  • Node.js basics
  • Understanding of callbacks and promises
  • Basic OOP (classes)

The EventEmitter is the foundation of Node.js. Streams, HTTP servers, child processes, and most core modules are built on it. It implements the observer pattern: objects emit named events, and registered listener functions are called when those events fire.

Basic usage

const EventEmitter = require('events');

const emitter = new EventEmitter();

// Register a listener
emitter.on('greet', (name) => {
  console.log(`Hello, ${name}!`);
});

// Emit the event
emitter.emit('greet', 'Alice');
// Output: Hello, Alice!

emit calls all registered listeners for the given event synchronously, in the order they were registered.

Core methods

const emitter = new EventEmitter();

// on: adds a listener (alias: addListener)
emitter.on('data', handler);

// once: adds a listener that fires only once
emitter.once('connect', () => {
  console.log('Connected (this runs only once)');
});

// off: removes a listener (alias: removeListener)
emitter.off('data', handler);

// removeAllListeners: removes all listeners for an event
emitter.removeAllListeners('data');

// listenerCount: how many listeners for an event
console.log(emitter.listenerCount('data'));

// eventNames: list all events with listeners
console.log(emitter.eventNames());

// prependListener: add listener to the beginning of the array
emitter.prependListener('data', handler);

Creating a custom event emitter

Extend EventEmitter to create domain-specific event sources:

const EventEmitter = require('events');

class FileWatcher extends EventEmitter {
  constructor(filePath) {
    super();
    this.filePath = filePath;
    this.lastModified = null;
  }

  start(intervalMs = 1000) {
    const fs = require('fs');

    this.interval = setInterval(() => {
      fs.stat(this.filePath, (err, stats) => {
        if (err) {
          this.emit('error', err);
          return;
        }

        const mtime = stats.mtime.getTime();

        if (this.lastModified && mtime !== this.lastModified) {
          this.emit('change', {
            file: this.filePath,
            previous: new Date(this.lastModified),
            current: stats.mtime,
          });
        }

        this.lastModified = mtime;
      });
    }, intervalMs);

    this.emit('start', this.filePath);
  }

  stop() {
    clearInterval(this.interval);
    this.emit('stop');
  }
}

// Usage
const watcher = new FileWatcher('./config.json');

watcher.on('start', (file) => console.log(`Watching ${file}`));
watcher.on('change', (info) => console.log('File changed:', info));
watcher.on('error', (err) => console.error('Watch error:', err));

watcher.start(2000);

The error event

The error event is special. If an error event is emitted and no listener is registered for it, Node.js throws the error and crashes the process:

const emitter = new EventEmitter();

// This will crash the process with an unhandled error
emitter.emit('error', new Error('Something went wrong'));

Always register an error listener:

emitter.on('error', (err) => {
  console.error('Handled error:', err.message);
});

Listener management and memory leaks

By default, an EventEmitter warns if more than 10 listeners are registered for a single event. This is usually a sign of a memory leak (forgetting to remove listeners):

// This will print a warning after 11 registrations
for (let i = 0; i < 20; i++) {
  emitter.on('data', () => {});
}
// MaxListenersExceededWarning: Possible EventEmitter memory leak detected.

If you genuinely need more listeners, set the limit explicitly:

emitter.setMaxListeners(100);
// Or for all emitters:
EventEmitter.defaultMaxListeners = 100;

Cleaning up listeners

function onData(data) {
  console.log(data);
}

emitter.on('data', onData);

// Later, when you no longer need it
emitter.off('data', onData);

With once, the listener is automatically removed after one invocation:

emitter.once('ready', () => {
  console.log('Ready! This listener is now removed.');
});

Async event handling

Listeners are called synchronously by default. If a listener performs async work, errors may not propagate as expected:

// BAD: unhandled promise rejection
emitter.on('data', async (data) => {
  await processData(data); // If this throws, it's not caught
});

To handle async listeners properly:

// Option 1: catch inside the listener
emitter.on('data', async (data) => {
  try {
    await processData(data);
  } catch (err) {
    emitter.emit('error', err);
  }
});

// Option 2: use events.on() for async iteration
const { on } = require('events');

async function processEvents(emitter) {
  for await (const [data] of on(emitter, 'data')) {
    await processData(data);
  }
}

events.once() for promises

Convert a one-time event to a promise:

const { once } = require('events');

async function waitForReady(server) {
  await once(server, 'listening');
  console.log('Server is ready');
}

// Practical example: wait for server to start
const http = require('http');
const server = http.createServer();
server.listen(3000);

await once(server, 'listening');
console.log('Server running on port 3000');

events.on() for async iteration

const { on } = require('events');
const { createReadStream } = require('fs');
const readline = require('readline');

async function processLines(filePath) {
  const rl = readline.createInterface({
    input: createReadStream(filePath),
  });

  for await (const [line] of on(rl, 'line')) {
    console.log('Line:', line);
  }
}

Typed event emitter pattern

For better developer experience, create a typed wrapper:

class TypedEmitter extends EventEmitter {
  /**
   * @param {'connect'} event
   * @param {(port: number) => void} listener
   */
  on(event, listener) {
    return super.on(event, listener);
  }
}

In TypeScript, this is much more powerful:

interface ServerEvents {
  connect: [port: number];
  disconnect: [reason: string];
  data: [payload: Buffer, metadata: { timestamp: number }];
  error: [err: Error];
}

class Server extends EventEmitter<ServerEvents> {
  start(port: number) {
    // TypeScript knows the shape of each event
    this.emit('connect', port);      // OK
    this.emit('connect', 'wrong');   // Type error
  }
}

const server = new Server();
server.on('data', (payload, metadata) => {
  // payload is Buffer, metadata is { timestamp: number }
});

Pattern: event bus

A centralized event bus for decoupled communication between modules:

// eventBus.js
const EventEmitter = require('events');
const bus = new EventEmitter();
bus.setMaxListeners(50);
module.exports = bus;

// userService.js
const bus = require('./eventBus');
function createUser(data) {
  const user = saveToDb(data);
  bus.emit('user:created', user);
  return user;
}

// emailService.js
const bus = require('./eventBus');
bus.on('user:created', (user) => {
  sendWelcomeEmail(user.email);
});

// analyticsService.js
const bus = require('./eventBus');
bus.on('user:created', (user) => {
  trackSignup(user.id);
});

The user service does not know about emails or analytics. New subscribers can be added without modifying existing code.

Pattern: event forwarding

Forward events from one emitter to another:

class DatabasePool extends EventEmitter {
  constructor() {
    super();
    this.connections = [];
  }

  addConnection(conn) {
    this.connections.push(conn);

    // Forward connection events to the pool
    conn.on('error', (err) => this.emit('connectionError', conn, err));
    conn.on('close', () => this.emit('connectionClosed', conn));
    conn.on('query', (sql) => this.emit('query', conn, sql));
  }
}

AbortSignal integration

Use AbortController to cancel event listeners:

const { once } = require('events');

const ac = new AbortController();

// Cancel after 5 seconds
setTimeout(() => ac.abort(), 5000);

try {
  await once(emitter, 'data', { signal: ac.signal });
  console.log('Got data');
} catch (err) {
  if (err.code === 'ABORT_ERR') {
    console.log('Timed out waiting for data');
  }
}

Summary

EventEmitter is the observer pattern in Node.js. Extend it to create domain-specific event sources. Always handle the error event to prevent crashes. Watch for memory leaks by cleaning up listeners and monitoring listenerCount. Use events.once() and events.on() to bridge events with promises and async iteration. The event bus pattern decouples modules without adding dependencies between them.