An EventEmitter is a small object that lets one piece of code emit named events and any number of other places listen for them. It is the core of the publish-subscribe (pub/sub) pattern and one of the most common ways to decouple producers from consumers in JavaScript.
The EventEmitter API shows up across the JavaScript ecosystem:
stream.on('data', chunk => ...), process.on('exit', ...), and httpServer.on('request', ...) all use Node's built-in EventEmitter.EventTarget. Every DOM node is an EventTarget, and addEventListener, removeEventListener, and dispatchEvent follow the same shape. Modern Node and browsers expose EventTarget directly, so most apps no longer need a custom class.| Option | When to reach for it |
|---|---|
EventTarget (DOM/Node) | Default for most apps. Built into the platform, supports AbortSignal for cleanup, no dependencies. |
Node EventEmitter | Server-side code that needs Node-specific extras like setMaxListeners, error events, or once. |
| Custom emitter (this question) | When you need something the built-ins do not expose, such as listing all listeners, wildcard events, priority ordering, namespaces, or interview practice. |
| Observer pattern (RxJS, MobX) | One-to-many sync of state rather than discrete named events. |
| Store-based state (Redux, Zustand) | Global state with subscriptions and time-travel debugging. Do not use an emitter as your state store; you will lose causality. |
Implement an EventEmitter class similar to the one in Node.js. The exercise tests how you design a small public API, pick the right data structure, and handle the subtle bugs that most basic implementations get wrong.
Example usage of the EventEmitter class:
const emitter = new EventEmitter();function addTwoNumbers(a, b) {console.log(`The sum is ${a + b}`);}emitter.on('foo', addTwoNumbers);emitter.emit('foo', 2, 5);// > "The sum is 7"emitter.on('foo', (a, b) => console.log(`The product is ${a * b}`));emitter.emit('foo', 4, 5);// > "The sum is 9"// > "The product is 20"emitter.off('foo', addTwoNumbers);emitter.emit('foo', -3, 9);// > "The product is -27"
EventEmitter APIImplement the following class and APIs:
new EventEmitter()Creates an instance of the EventEmitter class. Events and listeners are isolated within the EventEmitter instances they're added to; that is, listeners should not react to events emitted by other EventEmitter instances.
emitter.on(eventName, listener)Adds a callback function (listener) that will be invoked when an event with the name eventName is emitted.
| Parameter | Type | Description |
|---|---|---|
eventName | string | The name of the event. |
listener | Function | The callback function to be invoked when the event occurs. |
Returns the EventEmitter instance so that calls can be chained.
emitter.off(eventName, listener)Removes the specified listener from the list of listeners for the event with the name eventName.
| Parameter | Type | Description |
|---|---|---|
eventName | string | The name of the event. |
listener | Function | The callback function to remove from the list of listeners for the event. |
Returns the EventEmitter instance so that calls can be chained.
emitter.emit(eventName[, ...args])Invokes each listener registered for eventName with the supplied arguments in order.
| Parameter | Type | Description |
|---|---|---|
eventName | string | The name of the event. |
...args | any | Arguments to pass to the listener functions. |
Returns true if the event had listeners, false otherwise.
An event-based interaction model lets callers register functions for named events, then invoke those functions later with emitted arguments. The DOM uses this same model with document.addEventListener() and document.removeEventListener() for events like click, hover, and input.
For this implementation, the core responsibility is maintaining a listener registry that preserves registration order, supports duplicate listeners, and stays predictable when listeners are added or removed.
Clarify the emitter semantics up front because listener ordering, duplicate callbacks, and failure handling affect the code.
emitter.emit() be called without any arguments besides the eventName?
eventName?
eventName is emitted, in the order the registrations were added.emitter.off() is called once for that listener?
this value of the listeners be?
null.emitter.emit()?
The solution handles all of the cases above except the last two.
Read the flow as "one ordered listener bucket per event name". on() appends to a bucket, off() removes one matching entry from that bucket, and emit() reads a snapshot of the bucket in order.
The clean default is a map from eventName to an array of listeners.
Conceptually:
events = {foo: [Function1, Function3],bar: [Function2],};
That structure makes all three operations direct:
on() appends to one event bucketoff() searches inside one event bucketemit() reads one event bucket and iterates it in orderA flat list of { eventName, listener } pairs also works, but it forces every off() and emit() call to scan unrelated entries.
Because eventName comes from user input, the approach stores these buckets in Object.create(null) instead of a normal object literal. That avoids collisions with inherited keys like toString.
EventEmitter.on()Create the listener array if this is the first time the event name appears, then push the new listener and return this for chaining.
Appending is important because listeners must run in the order they were registered. The same function can be pushed more than once; those entries are separate subscriptions for this part of the question.
EventEmitter.off()If the event does not exist, do nothing. Otherwise, find the first matching listener with findIndex() and remove only that single occurrence with splice().
Removing one occurrence matters because the same function can be registered multiple times and should be removed in registration order.
EventEmitter.emit()If the event does not exist or has no listeners, return false.
Otherwise, clone the listener array with slice() and call each listener with the emitted arguments. Cloning protects the current emission from listeners that add or remove handlers while emit() is running.
The snapshot makes listener mutation easier to follow:
emit() is still present in the current snapshot if it had not run yet.emit() is not part of the current snapshot and will only run on a later emit().Listeners are invoked with listener.apply(null, args), so the emitter intentionally uses null as the listener this value while forwarding every emitted argument.
For an event with listeners [a, b], if a calls off(eventName, b) while the emission is running, b still runs for that current emission because it is present in the snapshot. The removal affects the next emit().
The same snapshot rule handles additions:
During current emit() | Current snapshot | Next emit() |
|---|---|---|
listener a adds listener c | still [a, b] | [a, b, c] |
listener a removes listener b | still [a, b] | [a] |
For duplicate listeners, registration entries are independent:
| Step | Bucket for "double" | Result |
|---|---|---|
on(double) | [double] | One call per emit |
on(double) again | [double, double] | Two calls per emit |
off(double) once | [double] | Only the first matching registration is removed |
off(double) again | [] | Later emits return false |
eventNamesStoring listeners in a plain JavaScript object lets user-provided names such as valueOf or toString collide with inherited properties.
const emitter = new EventEmitter();emitter.emit('toString');
Two common fixes are:
Map instead of an object. This is the modern approach.Object.create(null) so that the object does not have a prototype or inherited properties.export default class EventEmitter {constructor() {// Avoid creating objects via `{}` to exclude unwanted properties// on the prototype (such as `.toString`).this._events = Object.create(null);}/*** @param {string} eventName* @param {Function} listener* @returns {EventEmitter}*/on(eventName, listener) {// Group listeners by event name so emit/off only touch one bucket.if (!Object.hasOwn(this._events, eventName)) {this._events[eventName] = [];}this._events[eventName].push(listener);return this;}/*** @param {string} eventName* @param {Function} listener* @returns {EventEmitter}*/off(eventName, listener) {// Ignore non-existing eventNames.if (!Object.hasOwn(this._events, eventName)) {return this;}const listeners = this._events[eventName];// Find only first instance of the listener.const index = listeners.findIndex((listenerItem) => listenerItem === listener,);if (index < 0) {return this;}this._events[eventName].splice(index, 1);return this;}/*** @param {string} eventName* @param {...unknown} args* @returns {boolean}*/emit(eventName, ...args) {// Return false for non-existing eventNames or events without listeners.if (!Object.hasOwn(this._events, eventName) ||this._events[eventName].length === 0) {return false;}// Make a clone of the listeners in case one of the listeners// mutates this listener array.const listeners = this._events[eventName].slice();listeners.forEach((listener) => {listener.apply(null, args);});return true;}}
interface EventEmitter {_events: Record<string, Array<Function>>;on(eventName: string, listener: Function): EventEmitter;off(eventName: string, listener: Function): EventEmitter;emit(eventName: string, ...args: Array<any>): boolean;}export default function EventEmitter(this: EventEmitter) {// Avoid creating objects via `{}` to exclude unwanted properties// on the prototype (such as `.toString`).this._events = Object.create(null);}EventEmitter.prototype.on = function (this: EventEmitter,eventName: string,listener: Function,): EventEmitter {// Group listeners by event name so emit/off only touch one bucket.if (!Object.hasOwn(this._events, eventName)) {this._events[eventName] = [];}this._events[eventName].push(listener);return this;};EventEmitter.prototype.off = function (this: EventEmitter,eventName: string,listener: Function,): EventEmitter {// Ignore non-existing eventNames.if (!Object.hasOwn(this._events, eventName)) {return this;}const listeners = this._events[eventName];// Find only first instance of the listener.const index = listeners.findIndex((listenerItem) => listenerItem === listener,);if (index < 0) {return this;}this._events[eventName].splice(index, 1);return this;};EventEmitter.prototype.emit = function (this: EventEmitter,eventName: string,...args: Array<any>): boolean {// Return false for non-existing eventNames or events without listeners.if (!Object.hasOwn(this._events, eventName) ||this._events[eventName].length === 0) {return false;}// Make a clone of the listeners in case one of the listeners// mutates this listener array.const listeners = this._events[eventName].slice();listeners.forEach((listener) => {listener.apply(null, args);});return true;};
emitter.emit() may be called without any arguments beyond eventName.eventName values.eventName values may be built-in object properties like valueOf or toString.off() or on() to mutate it. Use a snapshot for the current emission.off() call. This API removes only the first matching registration.emit() would need to catch errors without stopping the remaining listeners.this, because arrow functions do not get their own method receiver.EventEmitter supports more features, such as symbol event names, but they are intentionally out of scope here.on() and off() for chaining. The follow-up question changes the subscription API so on() returns a dedicated object with off().console.log() statements will appear here.