Event Emitter

Medium

Prompt

Your task is to build a basic EventEmitter class in JavaScript. Event emitters are a common pattern used in Node.js and the browser to handle asynchronous actions (like responding to a user click or a network response).

Your class must implement the following three methods:

  • on(eventName, listener): Subscribes a listener function to a specific eventName. Multiple listeners can be subscribed to the same event.
  • off(eventName, listener): Unsubscribes a specific listener function from the eventName. If the listener isn't subscribed, it should do nothing.
  • emit(eventName, ...args): Triggers all listeners subscribed to the eventName in the order they were added, passing any provided args to each listener. If no listeners are subscribed to the event, it should do nothing.

Requirements:

  • You should handle the case where multiple functions are registered to the same event.
  • You should handle emitting events that have no listeners gracefully (without throwing an error).
  • Listeners might need to receive an arbitrary number of arguments when an event is emitted.

Playground

Hint 1

You'll need a way to keep track of event names and their corresponding listeners. Think about what data structure is best for mapping a string (the event name) to a list of functions.

Hint 2

When someone calls on for an event that hasn't been used yet, you might need to initialize an empty collection for that event before adding the listener to it.

Hint 3

To remove a listener in off, you can use array methods that create a new array excluding the specified item, or find its index and remove it directly.

Hint 4

When emit is called, you'll need to check if the event exists in your tracking structure. If it does, loop through its collection of listeners and call each one, passing along all the ...args.

Solution

Explanation

I will walk you through this challenge as it was asked at Airbnb. We will go through the solution step by step. I will show you what an ideal solution looks like, where candidates go wrong, and how interviewers decide between "Strong No", "No", "Yes", and "Strong Yes".

Before writing a single line of code, read the prompt carefully. This question asks for three methods — on, off, and emit. Make sure you understand each one.

Now, most candidates read the prompt and jump straight into coding the happy path — on adds a listener, emit calls it, off removes it. That's fine. But senior candidates think one step further before they start. There are two edge cases in this problem that the prompt mentions but most candidates either miss or only add later when their code throws:

  • What if off is called for an event that was never registered?
  • What if emit is called when there are no listeners?

You do not need to ask the interviewer about these — the prompt already tells you to handle them gracefully. The point is to proactively identify them yourself and mention them out loud before you code. Something like: "I'm also going to make sure emit and off handle missing events without throwing, since that's a realistic edge case in production." That small moment of forward thinking is what separates a "Yes" from a "Strong Yes" before you have written a single line of code.

Setting Up the Constructor

We will start by creating the EventEmitter class with just the constructor. Before we can register or emit anything, we need somewhere to store our subscriptions.

class EventEmitter {
constructor() {
this.events = {};
}
}

this.events is a plain JavaScript object that acts as our database of subscriptions. The keys will be event names (strings like 'newMessage'), and the values will be arrays of listener functions. Here is what it looks like when populated:

this.events = {
newMessage: [bobListener, aliceListener],
userJoined: [someOtherListener],
};

We chose an object because it gives us O(1) lookup by event name — just this.events[eventName] and we instantly know who is subscribed. The array preserves insertion order, so listeners are called in the order they were added, exactly as the prompt requires.

Object vs Map

Some candidates reach for a Map here instead of a plain object. Both work. A Map is slightly more correct for non-string keys, but since event names are always strings, a plain object is simpler and is what most interviewers expect to see. If you use a Map, be ready to explain why.

Implementing on

Now let's add the on method. When someone calls emitter.on('newMessage', bobListener), we need to add bobListener to the list of subscribers for 'newMessage'.

on(eventName, listener) {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(listener);
}

The guard on the first line is critical. Before pushing, we check if an array already exists for this event name. If it does not, we create an empty one. Only then do we push the listener.

Common Pitfalls

  1. The most common mistake candidates make here is skipping the guard and going straight to this.events[eventName].push(listener). If this event has never been registered before, this.events[eventName] is undefined, and you will get Cannot read properties of undefined (reading 'push'). Always initialize first.
  2. Some candidates store the listener directly instead of in an array: this.events[eventName] = listener. This works for one subscriber but completely breaks when a second listener tries to subscribe to the same event, because you overwrite the first one. The prompt explicitly says multiple listeners can subscribe to the same event.

Implementing off

Next, the off method. When someone calls emitter.off('newMessage', aliceListener), we need to remove aliceListener from the subscription list — and only that listener.

off(eventName, listener) {
if (!this.events[eventName]) return;
this.events[eventName] = this.events[eventName].filter(
(fn) => fn !== listener
);
}

The guard at the top handles the case where off is called for an event that was never registered. Without it, we would throw on the next line. After the guard, we use .filter() to create a new array that excludes the specific listener being removed.

Common Pitfalls

  1. Do not use .splice() to remove the listener. Splice mutates the original array in place, which can cause subtle bugs — for example, if emit is currently iterating over that same array when off is called. Using .filter() creates a new array and avoids the mutation entirely.
  2. A common mistake is using delete this.events[eventName]. This removes the entire event key from our object, which means all listeners for that event disappear — not just the one passed into off. The prompt says to remove a specific listener, not all of them.

Implementing emit

Finally, emit. When someone calls emitter.emit('newMessage', 'Hello!'), we need to call every listener subscribed to 'newMessage' and pass them the arguments.

emit(eventName, ...args) {
if (!this.events[eventName]) return;
this.events[eventName].forEach((listener) => {
listener(...args);
});
}

We guard first — if no one is subscribed to this event, we return early without doing anything. Then we loop through the array of listeners and call each one, spreading the args so any number of arguments can be passed through.

Common Pitfalls

  1. Forgetting the guard is one of the most common mistakes on emit. The prompt says to handle events with no listeners gracefully. Without the guard, this.events[eventName] is undefined, and calling .forEach() on it throws a TypeError. Interviewers specifically check whether candidates handle this.
  2. Forgetting the spread operator when calling each listener: listener(args) instead of listener(...args). With listener(args), the listener receives a single array argument instead of the individual values. Always spread.

The Complete Solution

Here is the full class put together:

class EventEmitter {
constructor() {
this.events = {};
}
on(eventName, listener) {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(listener);
}
off(eventName, listener) {
if (!this.events[eventName]) return;
this.events[eventName] = this.events[eventName].filter(
(fn) => fn !== listener
);
}
emit(eventName, ...args) {
if (!this.events[eventName]) return;
this.events[eventName].forEach((listener) => {
listener(...args);
});
}
}

Communicate While You Code

Throughout the interview, talk through your decisions as you make them. When you initialize this.events, say out loud: "I'm using a plain object here because it gives me O(1) lookup by event name." When you write the guard in on, say: "I need to initialize the array first, otherwise pushing to undefined will throw."

This kind of commentary is exactly what separates a "Yes" from a "Strong Yes". The interviewer already knows you can write the code — what they want to see is that you understand why each line exists. Candidates who silently code the solution and do not explain their thinking often get passed over even when their code is correct.

Real-World Use Cases

Event Emitters aren't just interview questions; they're the invisible plumbing of the web!

  • The DOM: Every time you write document.addEventListener('click', fn), you are using the browser's native Event Emitter! (addEventListener is essentially just .on()).
  • Node.js: The core of Node.js servers (listening for HTTP requests) is built entirely on a built-in EventEmitter class.

Watch Out: Memory Leaks!

One of the most important things to understand about Event Emitters is why .off() exists. If Alice subscribes but never unsubscribes, the Event Emitter holds onto her listener function forever — even after Alice's component is gone from the screen. That listener will keep firing on every emit, referencing variables and closures that should have been garbage collected long ago.

In React, this happens when you subscribe inside a useEffect without returning a cleanup function that calls .off(). Every time the component re-mounts, a new listener gets added on top of the old ones. Over time, memory grows and you get stale, unexpected behavior.

The interviewer may bring this up as a follow-up: "What happens if we forget to call .off()?" Being able to answer this clearly, with a React example, is what moves you from the basic implementation into a stronger hire signal.