Event Emitter
The Production-Grade Observer
LinkedIn Hook
"Write an EventEmitter with on, off, emit, and once."
Every senior frontend and Node.js interview eventually lands on that prompt, and the reason is simple — if you can build one from scratch, you understand how Node streams, the DOM event system, Redux middleware, and most React state libraries work under the hood.
The tricky part is not the first two methods. It is the edge cases:
oncelisteners that must remove themselves mid-iteration, the"error"event that throws if no one is listening,setMaxListenersthat catches leak bugs before production, and wildcard listeners that watch every channel at once.In this lesson you will build a complete, production-grade EventEmitter — including a wildcard variant and a reactive
Store— and compare it field-by-field with Node's official API.Read the full lesson -> [link]
#JavaScript #EventEmitter #NodeJS #DesignPatterns #InterviewPrep #SystemDesign #Frontend
What You'll Learn
- How to implement a full EventEmitter with
on,off,emit,once, and introspection methods - How
"error"events andsetMaxListenersprotect you from silent failures and memory leaks - How to extend an emitter with wildcard listeners and build a reactive store on top of it
The Radio Tower Analogy
An Event Emitter is a full-featured implementation of the Observer pattern, designed to be reusable across your application. If the Observer pattern is the concept, the Event Emitter is the production-grade implementation. Think of it as a radio station: it broadcasts on different frequencies (events), and anyone with a radio tuned to that frequency (listener) receives the broadcast.
Complete EventEmitter Implementation
class EventEmitter {
constructor() {
this._events = {};
this._maxListeners = 10; // prevent memory leaks
}
// Register a listener for an event
on(event, listener) {
if (typeof listener !== "function") {
throw new TypeError("Listener must be a function");
}
if (!this._events[event]) {
this._events[event] = [];
}
// Memory leak warning
if (this._events[event].length >= this._maxListeners) {
console.warn(
`Warning: ${event} has ${this._events[event].length} listeners. ` +
`Possible memory leak. Use setMaxListeners() to increase limit.`
);
}
this._events[event].push({ listener, once: false });
return this;
}
// Register a one-time listener
once(event, listener) {
if (typeof listener !== "function") {
throw new TypeError("Listener must be a function");
}
if (!this._events[event]) {
this._events[event] = [];
}
this._events[event].push({ listener, once: true });
return this;
}
// Remove a specific listener
off(event, listener) {
if (!this._events[event]) return this;
this._events[event] = this._events[event].filter(
(entry) => entry.listener !== listener
);
if (this._events[event].length === 0) {
delete this._events[event];
}
return this;
}
// Emit an event with data
emit(event, ...args) {
// Handle error events specially
if (event === "error" && !this._events["error"]) {
const err = args[0];
throw err instanceof Error ? err : new Error("Unhandled error event");
}
if (!this._events[event]) return false;
// Copy array to handle removal during iteration (once listeners)
const listeners = [...this._events[event]];
listeners.forEach((entry) => {
try {
entry.listener.apply(this, args);
} catch (err) {
// Emit on "error" channel if listener throws
if (event !== "error") {
this.emit("error", err);
}
}
});
// Remove "once" listeners after they fire
this._events[event] = (this._events[event] || []).filter(
(entry) => !entry.once
);
if (this._events[event]?.length === 0) {
delete this._events[event];
}
return true;
}
// Remove all listeners for an event (or all events)
removeAllListeners(event) {
if (event) {
delete this._events[event];
} else {
this._events = {};
}
return this;
}
// Get listener count for an event
listenerCount(event) {
return this._events[event]?.length || 0;
}
// Get all event names that have listeners
eventNames() {
return Object.keys(this._events);
}
// Set max listeners (0 = unlimited)
setMaxListeners(n) {
this._maxListeners = n;
return this;
}
// Get all listeners for an event
listeners(event) {
return (this._events[event] || []).map((entry) => entry.listener);
}
}
Using the EventEmitter
const emitter = new EventEmitter();
// Regular listeners
function onMessage(sender, text) {
console.log(`[${sender}]: ${text}`);
}
function logMessage(sender, text) {
console.log(`LOG: Message from ${sender} at ${new Date().toISOString()}`);
}
emitter.on("message", onMessage);
emitter.on("message", logMessage);
emitter.emit("message", "Alice", "Hello!");
// [Alice]: Hello!
// LOG: Message from Alice at 2026-04-10T...
// Once listener — fires only once
emitter.once("connection", (id) => {
console.log(`First connection: ${id}`);
});
emitter.emit("connection", "user-123"); // "First connection: user-123"
emitter.emit("connection", "user-456"); // (nothing — listener was removed)
// Remove specific listener
emitter.off("message", logMessage);
emitter.emit("message", "Bob", "Hi!");
// [Bob]: Hi! (only onMessage fires now)
// Error handling
emitter.on("error", (err) => {
console.error("Caught error:", err.message);
});
emitter.emit("error", new Error("Something broke"));
// "Caught error: Something broke"
Wildcard Listeners
class WildcardEmitter extends EventEmitter {
emit(event, ...args) {
// Emit to exact match listeners
super.emit(event, ...args);
// Emit to wildcard listeners
if (this._events["*"]) {
this._events["*"].forEach((entry) => {
try {
entry.listener.call(this, event, ...args);
} catch (err) {
console.error("Wildcard listener error:", err);
}
});
// Clean up once wildcards
this._events["*"] = this._events["*"].filter((entry) => !entry.once);
}
return true;
}
}
const ee = new WildcardEmitter();
// Listen to ALL events
ee.on("*", (eventName, ...data) => {
console.log(`[Wildcard] Event: ${eventName}`, data);
});
ee.on("login", (user) => console.log(`Welcome, ${user}!`));
ee.emit("login", "Alice");
// "Welcome, Alice!"
// "[Wildcard] Event: login" ["Alice"]
ee.emit("logout", "Alice");
// "[Wildcard] Event: logout" ["Alice"]
Practical Example: State Manager
class Store extends EventEmitter {
constructor(initialState = {}) {
super();
this._state = initialState;
}
getState() {
return { ...this._state };
}
setState(updater) {
const prevState = { ...this._state };
if (typeof updater === "function") {
this._state = { ...this._state, ...updater(this._state) };
} else {
this._state = { ...this._state, ...updater };
}
// Notify listeners of changes
this.emit("change", this._state, prevState);
// Emit specific field changes
for (const key of Object.keys(this._state)) {
if (this._state[key] !== prevState[key]) {
this.emit(`change:${key}`, this._state[key], prevState[key]);
}
}
}
}
// Usage
const store = new Store({ count: 0, user: null });
store.on("change", (newState, prevState) => {
console.log("State changed:", newState);
});
store.on("change:count", (newVal, oldVal) => {
console.log(`Count: ${oldVal} -> ${newVal}`);
});
store.setState({ count: 1 });
// "State changed: { count: 1, user: null }"
// "Count: 0 -> 1"
store.setState((prev) => ({ count: prev.count + 1 }));
// "State changed: { count: 2, user: null }"
// "Count: 1 -> 2"
Comparison with Node.js EventEmitter API
| Feature | Our Implementation | Node.js EventEmitter |
|---|---|---|
on(event, fn) | Yes | Yes |
once(event, fn) | Yes | Yes |
off(event, fn) | Yes | Yes (alias: removeListener) |
emit(event, ...args) | Yes | Yes |
removeAllListeners() | Yes | Yes |
listenerCount(event) | Yes | Yes |
eventNames() | Yes | Yes |
setMaxListeners(n) | Yes | Yes (default: 10) |
prependListener | No | Yes |
"error" event | Yes — throws if unhandled | Same behavior |
"newListener" event | No | Yes |
Common Mistakes
- Iterating the live
_events[event]array while listeners remove themselves (viaonce) — always copy the array before iteration, otherwise the forEach skips entries. - Emitting
"error"without ever registering an error listener — unhandled error events must throw, and the safety is only useful if you actually attach a handler somewhere. - Adding listeners in a loop (e.g., one per rendered row) without ever removing them — the
setMaxListenerswarning exists exactly for this case; heed it, don't mute it.
Interview Questions
Q: Implement an EventEmitter class with on, off, emit, and once methods.
(See the full implementation above.) Key points: store listeners in an object keyed by event name,
onpushes to the array,offfilters out the listener,emititerates and calls each listener,oncewraps the listener so it removes itself after first invocation.
Q: How does Node.js handle "error" events on EventEmitter?
If an "error" event is emitted and no listener is registered for it, Node.js throws the error as an unhandled exception. This is a safety mechanism — it forces developers to handle errors explicitly rather than silently swallowing them.
Q: What is the purpose of setMaxListeners?
It sets a warning threshold for the number of listeners on a single event. The default is 10. If more listeners are added, a warning is printed to help detect memory leaks (e.g., attaching listeners in a loop without removing them). Setting it to 0 disables the warning.
Quick Reference — Cheat Sheet
EVENT EMITTER — QUICK MAP
Core API:
on(event, fn) -> register listener
off(event, fn) -> remove listener
emit(event, ...args) -> fire all listeners
once(event, fn) -> auto-remove after first fire
Introspection:
listenerCount(event), eventNames(), listeners(event)
Safety:
"error" event throws if no listener registered
setMaxListeners(n) -> warn on likely leaks (default 10)
copy array before iterating so once-removals don't skip
Extensions:
wildcard "*" listener -> fires on every event
subclass + setState -> reactive store ("change" + "change:field")
Previous: Factory Pattern -> Build by Description Next: Dependency Injection -> Push, Don't Pull
This is Lesson 12.5 of the JavaScript Interview Prep Course — 14 chapters, 87 lessons.