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 (open → data → end → close):
class Task extends EventEmitter {
async run() {
this.emit('start')
this.emit('progress', 50)
this.emit('progress', 100)
this.emit('complete', { ok: true })
}
}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
import { EventEmitter } from 'node:events'
export const bus = new EventEmitter()// 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))Pattern: namespaced event names
As the number of events grows, prefix them by domain. It prevents collisions and makes the catalog self-organizing:
bus.emit('user:login', { id })
bus.emit('user:logout', { id })
bus.emit('order:created', { id })
bus.emit('order:shipped', { id })Anti-pattern: forgetting cleanup
The leak, and the fix
// ✗ 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:
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
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 boomWhen 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.