Event Emitter Pattern
Implement on/off/emit with Map of Sets — once, error isolation, memory leaks, and how this differs from DOM events.
- javascript
- event-emitter
- pub-sub
- patterns
An event emitter is an in-memory pub/sub object: subscribe with on, publish with emit, unsubscribe with off. Node’s EventEmitter popularized the API; frontends reimplement tiny versions for stores, WebSocket clients, and cross-widget signals without dragging in the DOM.
Minimal implementation
class Emitter {
#events = new Map(); // type → Set<fn>
on(type, fn) {
if (!this.#events.has(type)) this.#events.set(type, new Set());
this.#events.get(type).add(fn);
return () => this.off(type, fn); // handy unsubscribe
}
off(type, fn) {
this.#events.get(type)?.delete(fn);
}
once(type, fn) {
const wrap = (...args) => {
this.off(type, wrap);
fn(...args);
};
return this.on(type, wrap);
}
emit(type, ...args) {
const fns = [...(this.#events.get(type) ?? [])];
for (const fn of fns) {
try {
fn(...args);
} catch (err) {
console.error(`handler error for ${type}`, err);
}
}
}
listenerCount(type) {
return this.#events.get(type)?.size ?? 0;
}
}
const bus = new Emitter();
const stop = bus.on('login', (user) => console.log(user.id));
bus.emit('login', { id: 1 });
stop();
Copy the Set before iterating so off inside a handler doesn’t skip siblings.
once, prepend, async
// async handlers — emit usually doesn't await
bus.on('save', async (doc) => {
await api.save(doc); // errors become unhandled rejections unless you wrap
});
// better: document that handlers are sync, or
async function emitAsync(type, ...args) {
const fns = [...(this.#events.get(type) ?? [])];
await Promise.all(fns.map((fn) => fn(...args)));
}
Memory leaks
Long-lived emitters (singleton stores, window-level buses) retain handlers that closed over components:
// React
useEffect(() => {
return bus.on('tick', handleTick); // if on returns off
}, []);
Always unsubscribe on teardown. Prefer returning an off function from on.
DOM events vs emitter
| DOM | Emitter |
|---|---|
| Propagation, capture | Flat topic list |
| Tied to nodes | Free-floating |
| Browser-defined types | Your strings |
| GC with node | Manual off |
Use DOM custom events when the hierarchy matters. Use an emitter for app services.
Interview whiteboard extras
- Wildcard events (
on('*')) removeAllListeners- Max listeners warning (Node)
- Don’t mutate the listener set while iterating without a snapshot
// wildcard sketch
emit(type, ...args) {
this.#fire(type, args);
this.#fire('*', [type, ...args]);
}
Interview answer
“An event emitter maps event names to listener sets with on/off/emit. I snapshot listeners before emit, isolate handler errors, and return unsubscribe functions to avoid leaks. once wraps and removes itself. I pick emitters for app-level signals and DOM events for UI tree communication.”
Related
Testing and typing events
// allow awaiting first event in tests
function oncePromise(emitter, type) {
return new Promise((resolve) => emitter.once(type, resolve));
}
await oncePromise(bus, 'ready');
Document the event map:
/**
* @typedef {{ login: [user: {id:string}], logout: [] }} AppEvents
*/
In TypeScript, generic Emitter<AppEvents> with on<K extends keyof AppEvents> prevents typo event names. For production, cap listener counts on singleton buses and log when exceeded — leaked React effects show up as unbounded growth.
Error isolation vs fail-fast
emit(type, ...args) {
const fns = [...(this.#events.get(type) ?? [])];
for (const fn of fns) fn(...args); // one throw skips the rest
}
Product buses usually catch per-handler so one bad subscriber doesn’t kill others. Infrastructure code might prefer fail-fast. Pick deliberately and document it. Also avoid emitting during another emit on the same type without a queue — re-entrancy creates hard-to-debug order bugs; snapshotting listeners already helps.
Further reading
- Node.js — EventEmitter
- MDN — EventTarget (browser analog)
Related guides
- Functional Array PatternsPractical functional array techniques — flatMap, partitioning, indexing, zip, and when chaining hurts performance.
- Immutability Patterns in JSUpdate state without mutating — spread paths, arrays, structural sharing ideas, freeze, and libraries when nested updates hurt.
- Pub Sub vs ObserverSeparate publishers from subscribers vs subject–observer coupling — when each fits UI apps, and a tiny EventEmitter sketch.
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.
- Array find, some, every, includesShort-circuiting array predicates: find, findIndex, some, every, and includes — when to use each and common interview traps.