NodeJSEventEmitter Patterns

EventEmitter Patterns

Once you're comfortable with on/emit, the next step is using emitters well. This page collects the patterns experienced Node developers reach for — and the anti-patterns they avoid — so your event-driven code stays decoupled, leak-free, and easy to reason about.

Pattern: progress and lifecycle events

Emit named milestones so callers can hook into a long operation without you knowing who they are — the same shape as Node's own streams (opendataendclose):

JS
class Task extends EventEmitter {
  async run() {
    this.emit('start')
    this.emit('progress', 50)
    this.emit('progress', 100)
    this.emit('complete', { ok: true })
  }
}
A consistent lifecycle vocabulary helps
Reusing familiar event names — `start`/`progress`/`complete`/`error`, or stream-style `data`/`end`/`close` — means consumers can guess your API. Document which events fire, in what order, and what payload each carries; the event names *are* your public contract.
Pattern: a decoupled event bus

A shared emitter lets unrelated modules communicate without importing each other — the publisher doesn't know the subscribers exist. Great for cross-cutting concerns like logging or analytics:

bus.js

JS
import { EventEmitter } from 'node:events'
export const bus = new EventEmitter()

JS
// orders.js — publisher, knows nothing about who listens
import { bus } from './bus.js'
bus.emit('order:created', { id: 42 })

// email.js — subscriber, knows nothing about who publishes
import { bus } from './bus.js'
bus.on('order:created', (o) => sendReceipt(o.id))
A global bus can become spaghetti
Event buses decouple *too well*: with everything talking through one emitter, it gets hard to trace "who fires this, and who reacts?". Keep buses scoped and small, namespace events (`order:created`, `user:deleted`), and document the catalog. For complex flows, an explicit function call or a queue is often clearer than an invisible event.
Pattern: namespaced event names

As the number of events grows, prefix them by domain. It prevents collisions and makes the catalog self-organizing:

JS
bus.emit('user:login',   { id })
bus.emit('user:logout',  { id })
bus.emit('order:created', { id })
bus.emit('order:shipped', { id })
Anti-pattern: forgetting cleanup
Subscribe in a loop, leak forever
The single most common emitter bug: adding a listener to a *shared, long-lived* emitter on every request/iteration and never removing it. Each listener stays alive — and pins everything in its closure — so memory climbs until the process dies. Use `once` for one-shots, keep references and `off` them on teardown, or use `AbortSignal` (below) to detach a whole group at once.

The leak, and the fix

JS
// ✗ LEAK — a new permanent listener every request
function handler(req, res) {
  sharedBus.on('shutdown', () => res.end())   // never removed!
}

// ✓ FIX — once + cleanup on response finish
function handler(req, res) {
  const onShutdown = () => res.end()
  sharedBus.once('shutdown', onShutdown)
  res.on('finish', () => sharedBus.off('shutdown', onShutdown))
}
Pattern: AbortSignal for group cleanup

Modern Node lets you attach listeners tied to an AbortSignal. Abort the controller and all of them detach at once — perfect for tearing down everything associated with a request or component:

JS
const controller = new AbortController()
const { signal } = controller

emitter.on('data', onData, { signal })
emitter.on('error', onError, { signal })

// One call removes BOTH listeners:
controller.abort()
Anti-pattern: async listeners and unhandled rejections
`emit` does not await async listeners
If a listener is `async` and throws, `emit` won't see the rejection — `emit` already moved on, so the error becomes an *unhandled promise rejection*, not a catchable throw. Either handle errors *inside* the async listener (`try/catch` and emit `'error'`), or use `events.captureRejections` / the `captureRejections: true` emitter option to route listener rejections to the `'error'` event.

JS
import { EventEmitter } from 'node:events'

// Opt in: a rejected async listener routes to the 'error' event
const e = new EventEmitter({ captureRejections: true })
e.on('task', async () => { throw new Error('async boom') })
e.on('error', (err) => console.error('captured:', err.message))
e.emit('task')   // → captured: async boom
When NOT to use an emitter
  • A one-shot future value → use a promise (async/await), not an event. Clearer and composable.

  • A strict request→response → just call a function and use its return value.

  • A stream of bytes/objects → use a Stream, which is an emitter with backpressure built in.

  • Reach for emitters when there are multiple, independent, repeating reactions to the same occurrence.

Next
The one event you can never ignore — get it wrong and the process dies: [Handling error Events](/nodejs/error-events).