Events Best Practices
In-process events stay maintainable with explicit contracts, disciplined cleanup, and clear boundaries between notification and durable messaging.
How to Use This List
- Review when adding
EventEmitter,@OnEvent, or socketonhandlers. - Any global listener without cleanup plan is a production leak risk.
- Revisit when promoting in-process events to cross-service integration.
- Pair with Memory Leaks from Listeners.
A - Naming and Contracts
- Name domain events in past tense (
order.paid, notpayOrder). Reflects facts that already happened. - Publish event name and payload types from a shared module or typed emitter. No scattered magic strings.
- Version breaking payload changes (
v2envelope or new event name). Document migration for subscribers. - Keep payloads small and identifiers-focused. Pass IDs, not full ORM graphs.
- Document which events are domain vs integration. Integration events cross service boundaries with schemas.
B - Lifecycle and Memory
- Remove listeners when HTTP request, socket, or job scope ends.
off,once, orAbortSignal. - Never raise
setMaxListenerswithout fixing duplicate registration. Warnings indicate leaks. - Avoid attaching per-request handlers to process-global singleton emitters without cleanup. #1 leak pattern.
- In tests, tear down listeners in
afterEach. Prevent flaky listener accumulation. - On shutdown,
removeAllListenersonly for app-owned buses. Not on shared Node internals recklessly.
C - Error Handling and Performance
- Wrap async listeners with try/catch or
Promise.allSettledfan-out. One failure must not break others silently. - Do not await long I/O inside emit unless architecture requires it. Prefer enqueue job from thin handler.
- Do not use EventEmitter for backpressure-heavy byte flows. Use streams instead.
- Emit after DB transaction commits for consistency. Or use transactional outbox to queue.
- Log handler failures with event name and correlation ID, not full payload PII.
D - When Not to Use EventEmitter
- Use a queue when work must survive process crash. BullMQ, SQS, etc.
- Use direct function calls when one module owns the workflow. Events are for unknown fan-out.
- Use HTTP/webhooks for cross-team service contracts. Not shared in-memory bus.
- Use streams for byte/chunk processing. Not
dataevents on custom emitters. - Use OpenTelemetry spans for cross-cutting observability. Not ad-hoc metric events unless domain meaningful.
E - TypeScript and Testing
- Subclass or wrap EventEmitter with typed
emit/on. See Typed EventEmitter. - Unit-test handlers in isolation with mocked dependencies. Integration-test bus wiring separately.
- Assert
listenerCountreturns to baseline after scoped work. Automated leak regression test. - Lint against raw string literals for event names in application code. Constants or typed map only.
- Share event types package without server-only imports. Safe for workers and tests.
FAQs
What is the most common production bug?
Listeners on global emitters never removed - slow memory growth over days.
Should handlers be async?
Often yes - but isolate errors and prefer queue for heavy or retriable side effects.
How many listeners is too many?
More than one per event on global bus per request scope is suspicious - investigate architecture.
Are Node stream events the same?
Different API surface - same cleanup discipline applies to on('data').
NestJS @OnEvent guidelines?
Keep handlers thin; push durable work to BullMQ; module scope providers reduce global singleton misuse.
Can I emit during module import?
Avoid - ordering surprises during boot; explicit bootstrap() phase instead.
Error event convention?
Many classes emit 'error' - handle or process crashes for unhandled error events on some streams.
Event vs command?
Commands are imperative (CreateOrder) - events are facts (order.created). Do not mix semantics.
How to document events?
ADR or docs/events.md table: name, payload schema, producers, consumers, idempotency notes.
Socket.IO events?
Same cleanup on disconnect - see socket.io for transport-specific rules.
Metrics from events?
OK for domain counters - avoid high-cardinality emit per request without aggregation.
Further reading?
Domain Events vs EventEmitter for queue vs bus decisions.
Related
- Memory Leaks from Listeners - cleanup patterns
- Domain Events vs EventEmitter - queue vs bus
- Typed EventEmitter - TypeScript contracts
- BullMQ ioredis - durable events
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.