EventEmitter

intermediate nodejs events pubsub

EventEmitter is the publish-subscribe primitive that sits underneath an enormous fraction of Node’s core: every stream is an emitter, every HTTP server and request is an emitter, the process global is one too. If we want to understand what req.on('data', ...) really does, we have to understand EventEmitter.

In simple language: it’s an object with two main methods — emit('name', data) to fire an event, and on('name', handler) to subscribe to it. That’s the whole concept. Everything else is variation.

The basic pattern

import { EventEmitter } from 'node:events';

const bus = new EventEmitter();

bus.on('user.signup', (user) => {
  console.log(`Welcome email queued for ${user.email}`);
});

bus.on('user.signup', (user) => {
  console.log(`Analytics tracked for ${user.id}`);
});

bus.emit('user.signup', { id: 42, email: 'a@b.com' });

Multiple listeners on the same event? They all run, in registration order, synchronously when emit is called. The emitter doesn’t await anything — if a listener is async, it runs but emit doesn’t wait for it.

Publisher
emit('x', data)
→ → →
EventEmitter
listener map
→ → →
Listener A
Listener B
Listener C

once — fire-and-forget subscriber

If we only care about the first occurrence, once auto-removes the listener after it fires.

server.once('listening', () => {
  console.log('Server started');
});

Great for one-time initialization signals.

off / removeListener — cleanup

If we add listeners dynamically, we have to remove them or we leak memory. off (alias for removeListener) needs the same function reference we passed to on.

function onData(chunk) { /* ... */ }

stream.on('data', onData);
// later
stream.off('data', onData);

Anonymous arrow functions can’t be removed cleanly because we don’t have a reference. That’s why long-lived emitters always store handler references.

The MaxListeners warning

Every emitter has a soft limit — by default 10 listeners per event. Cross that and Node prints:

(node:1234) MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
11 data listeners added to [ReadStream].

In simple language: Node is saying “you keep adding listeners and never removing them — looks like a leak.” Sometimes it’s a real bug (forgot to off), sometimes it’s just a high-traffic legitimate use. We can raise the cap:

emitter.setMaxListeners(50);          // per-instance
EventEmitter.defaultMaxListeners = 20; // global default

But always check whether the listeners should actually be removed before papering over the warning.

The special ‘error’ event

EventEmitter has one cursed event name: 'error'. If we emit('error', err) and nothing is listening, Node treats it as uncaught and crashes the process.

const e = new EventEmitter();
e.emit('error', new Error('boom')); // CRASHES

The fix is always to have an error listener:

e.on('error', (err) => {
  console.error('Emitter error:', err);
});

This is why streams everywhere need .on('error', ...) — it’s the same EventEmitter behavior.

Extending it for our own classes

The natural way to build a class with built-in pub/sub:

import { EventEmitter } from 'node:events';

class JobRunner extends EventEmitter {
  async run(job) {
    this.emit('start', job);
    try {
      const result = await job.execute();
      this.emit('done', { job, result });
    } catch (err) {
      this.emit('error', err);
    }
  }
}

const runner = new JobRunner();
runner.on('done', ({ job, result }) => log(`${job.id} → ${result}`));
runner.on('error', (err) => alert(err));

This pattern shows up everywhere — Express’s app, Mongoose connection, ws WebSocket server, the Node process itself.

events.once — promise wrapper

When we want to await for a single event (e.g., wait for 'listening'), there’s a helper:

import { once } from 'node:events';

await once(server, 'listening');
console.log('Server is up');

Resolves with an array of args. Rejects if 'error' fires first. Beautiful for sequencing.

The mental model

EventEmitter is sync pub/sub: emit is just “loop through listeners and call them in order.” Always handle 'error'. Always remove listeners on long-lived emitters. When you npm install something and it has an .on(...) API, you’re almost certainly looking at an EventEmitter underneath.