Building an EventEmitter
An EventEmitter is the pub/sub pattern in a small, named API: on to subscribe, off to unsubscribe, emit to broadcast. Node's standard library has one (events.EventEmitter) used by streams, HTTP servers and child processes. The browser has EventTarget for the DOM. Both implement the same idea — and both are easy to recreate in fifty lines, which is the best way to internalise how they work.
A minimal implementation
class EventEmitter {
#listeners = new Map(); // event -> Set of fns
on(event, fn) {
if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
this.#listeners.get(event).add(fn);
return () => this.off(event, fn); // returns an unsubscribe helper
}
off(event, fn) {
this.#listeners.get(event)?.delete(fn);
}
emit(event, ...args) {
for (const fn of this.#listeners.get(event) ?? []) {
fn(...args);
}
}
}A Map keyed by event name holds a Set of listener functions. on adds; off removes; emit iterates and calls. That is the whole pattern.
Using it
const bus = new EventEmitter();
const stop = bus.on("login", user => console.log("welcome", user.name));
bus.on("login", user => console.log("audit:", user.id));
bus.emit("login", { id: 1, name: "Ada" });
stop(); // remove the first listener
bus.emit("login", { id: 2, name: "Lin" }); // only the audit listener fireswelcome Ada audit: 1 audit: 2
A small extension — once
Listeners that fire exactly once are a common need. Implement once in terms of on and off.
EventEmitter.prototype.once = function (event, fn) {
const wrapper = (...args) => {
this.off(event, wrapper);
fn(...args);
};
return this.on(event, wrapper);
};Error handling
A throwing listener stops the loop and leaves later subscribers un-notified. Decide what your emitter should do — Node throws synchronously by default; many app-level emitters catch and continue.
emit(event, ...args) {
for (const fn of this.#listeners.get(event) ?? []) {
try { fn(...args); }
catch (err) { console.error("listener error for", event, err); }
}
}Memory leaks — the classic trap
Forgetting to unsubscribe is the most common bug. Each on holds the listener forever; if the listener captures a big object via closure, that object never gets garbage-collected.
EventEmitter vs DOM EventTarget
API names — DOM uses
addEventListener/removeEventListener/dispatchEvent; EventEmitter useson/off/emit.Event payload — DOM listeners receive an
Eventobject. EventEmitter listeners receive whatever argumentsemitwas given.Bubbling — DOM events bubble through the tree. EventEmitter is flat — one emitter, one set of listeners per name.
Default behaviour — DOM events support
preventDefaultandstopPropagation. EventEmitter is fire-and-forget.
Same pattern with EventTarget
const target = new EventTarget();
target.addEventListener("login", e => console.log(e.detail));
target.dispatchEvent(new CustomEvent("login", { detail: { id: 1 } }));Node's EventEmitter — the differences worth knowing
Has a default cap of 10 listeners per event (
emitter.setMaxListeners(n)to change). Helpful for catching forgotten removals.Emits a special
'error'event — emitting it with no listener crashes the process. Always attach an error handler to long-lived emitters.Listeners run synchronously, in the order they were added, on the same tick as
emit.Supports
prependListener,eventNames,listenerCountand friends for introspection.
When to use an emitter (and when not to)
Decoupling components inside a single process — a module raises an event, anyone interested can listen.
Stream-shaped APIs —
data,end,errorare perfect for EventEmitter.NOT a substitute for a message queue across processes — for that, use Redis pub/sub, Kafka, or your platform of choice.
NOT a substitute for state management — emitters carry events, not the current value. Pair them with a store if subscribers need the latest state.