0x55aa
Back to Blog

📡 Node.js Event Emitters: The Built-In Pub/Sub You've Been Ignoring

|
5 min read

📡 Node.js Event Emitters: The Built-In Pub/Sub You've Been Ignoring

Here's a confession every Node.js developer eventually makes: "I installed a whole pub/sub library to do something Node.js already does natively."

EventEmitter has lived in Node.js since version 0.1 — it predates npm, Express, and half the internet. Yet developers reach for external packages the moment they need to wire up loosely-coupled components. Today we're fixing that relationship.

What Even Is an Event Emitter?

Think of EventEmitter like a radio station. The station emits a signal (an event). Anyone with a radio tuned to that frequency listens and reacts. The station doesn't care who's listening — it just broadcasts. Listeners don't care where the signal comes from — they just react.

This is the publish/subscribe pattern, and it's the secret sauce behind everything in Node.js: HTTP servers, streams, process, even fs. When you write fs.createReadStream('file.txt').on('data', chunk => ...), you're already using EventEmitter. You just didn't know it.

Your First Event Emitter

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

class OrderService extends EventEmitter {
  async placeOrder(order) {
    // Pretend we save to DB here
    const savedOrder = { ...order, id: Date.now(), status: 'placed' };

    // Emit events — let other parts of the system react
    this.emit('order:placed', savedOrder);

    return savedOrder;
  }

  async shipOrder(orderId) {
    const order = { id: orderId, status: 'shipped' };
    this.emit('order:shipped', order);
    return order;
  }
}

const orderService = new OrderService();

// The email service doesn't need to be called directly
orderService.on('order:placed', (order) => {
  console.log(`📧 Sending confirmation email for order #${order.id}`);
});

// Analytics can tap in without touching OrderService
orderService.on('order:placed', (order) => {
  console.log(`📊 Logging order #${order.id} to analytics`);
});

// Inventory reacts to shipments
orderService.on('order:shipped', (order) => {
  console.log(`📦 Updating inventory for shipped order #${order.id}`);
});

// Now place an order — all listeners fire automatically
orderService.placeOrder({ item: 'Mechanical Keyboard', qty: 1 });

The beauty here? OrderService knows nothing about email, analytics, or inventory. It just shouts into the void and whoever cares shows up. Adding a new reaction to order:placed is one line — no modification to OrderService required. That's the Open/Closed Principle in action, and you got it for free.

The Patterns That Actually Matter

Once is Enough

Sometimes you only want to react to an event once — like sending a welcome email when a user verifies their account:

class UserService extends EventEmitter {
  verifyEmail(userId) {
    this.emit('email:verified', { userId });
  }
}

const userService = new UserService();

// .once() auto-removes itself after firing
userService.once('email:verified', ({ userId }) => {
  console.log(`🎉 Welcome gift sent to user ${userId} — won't send again!`);
});

// Always-on listeners still work alongside .once()
userService.on('email:verified', ({ userId }) => {
  console.log(`✅ Marking user ${userId} as verified in DB`);
});

userService.verifyEmail(42); // Both fire
userService.verifyEmail(42); // Only the .on() fires now

Error Events Are Special — Treat Them That Way

EventEmitter has one opinionated rule: if you emit an 'error' event and nobody is listening, Node.js throws and crashes your process. This isn't a bug — it's a feature. Unhandled errors should be loud.

Always add an error listener to any EventEmitter you create:

class DataPipeline extends EventEmitter {
  process(data) {
    if (!data) {
      // This will crash if nobody's listening for 'error'
      this.emit('error', new Error('No data provided'));
      return;
    }
    this.emit('data:processed', data);
  }
}

const pipeline = new DataPipeline();

pipeline.on('error', (err) => {
  console.error('Pipeline error caught:', err.message);
  // Handle gracefully — no crash
});

pipeline.on('data:processed', (data) => {
  console.log('Processed:', data);
});

pipeline.process(null); // Safely handled
pipeline.process({ value: 42 }); // Works fine

When EventEmitter Shines (And When It Doesn't)

Great use cases:

  • Decoupling service logic from side effects (email, logging, webhooks)
  • Building plugin/hook systems where extensions tap into lifecycle events
  • Reacting to state transitions (order placed → order shipped → delivered)
  • In-process notification without the complexity of a message queue

Where it falls short:

  • Cross-process communication — EventEmitter is in-memory only. If you have multiple Node.js processes or servers, events don't cross that boundary. You'll want Redis Pub/Sub or a proper message broker.
  • Durability — events are fire-and-forget. If a listener crashes or isn't registered yet, the event is gone. For critical workflows, use a job queue with persistence.
  • Async listeners with error handling — if a listener throws asynchronously, EventEmitter won't catch it. You need to handle async errors explicitly inside each listener.

The rule of thumb: if the data needs to survive a restart or reach another machine, reach for a queue. If it's in-process coordination? EventEmitter all day.

Avoiding the MaxListenersExceededWarning

Node.js warns you if more than 10 listeners attach to the same event. This is usually a sign of a listener leak — you're adding listeners in a loop without removing old ones.

Fix it by either increasing the limit (use sparingly) or using removeListener / off to clean up:

const emitter = new EventEmitter();
emitter.setMaxListeners(20); // If you genuinely need more

// Or clean up manually
const handler = (data) => console.log(data);
emitter.on('data', handler);

// Later, when you're done:
emitter.off('data', handler);

The Takeaway

EventEmitter is Node.js's secret architecture tool. It lets you build systems where components react to things without being tightly wired together — no imports, no direct calls, no spaghetti dependencies.

Before you install your next pub/sub package, ask: is this staying in one process? Is fire-and-forget acceptable? If yes, you already have everything you need.


What are you using EventEmitter for in your projects? Drop a comment below — I'd love to hear about creative use cases. And if you've been burned by async listener errors or cross-process limitations, share the war story. We're all learning here. 🚀

Thanks for reading!

Back to all posts