DEV Community

Brendon O'Neill
Brendon O'Neill

Posted on

How to Build Your Own EventEmitter in JavaScript

When I use a feature a lot in projects, I usually get curious about how it works under the hood. I’ve found that if you don’t really understand how something works, you’re mostly just hoping it behaves the way you expect.

So I like to look at the structure first. I try to understand how the code flows, then I build my own version of it so I can see what is actually happening step by step.

This time, I decided to look into Node.js event emitters.

They are similar to browser events like mouse clicks or button presses, but instead of waiting for built-in UI interactions, you create your own event emitter instance and trigger your own events whenever you need them.

import { EventEmitter } from 'node:events';

const eventEmitter = new EventEmitter();
Enter fullscreen mode Exit fullscreen mode

From there, you can register a callback with .on(). This tells the emitter what should happen when a specific event is triggered.

eventEmitter.on('hello', () => {
  console.log('Hello!');
});

//or

eventEmitter.on('greet', greet);

function greet(){
  console.log('Hello')
}
Enter fullscreen mode Exit fullscreen mode

Then, when you want to run that event, you use .emit().

eventEmitter.emit('hello');
Enter fullscreen mode Exit fullscreen mode

You can also pass arguments through the event if you want the callback to work with some data.

eventEmitter.on('double', (item) => {
  console.log(`${item * 2}`);
});

eventEmitter.emit('double', 5);

eventEmitter.on('multiply', (one,two) => {
  console.log(`${one * two}`);
});

eventEmitter.emit('multiply', 5,12);
Enter fullscreen mode Exit fullscreen mode

I found this especially useful in my video transformer project, where different files needed to go through different steps asynchronously. Being able to trigger events and let each part of the process flow into the next one made the whole thing much easier to manage.

Now that we’ve seen the basic idea behind event emitters, let’s build our own from scratch so we can understand how each part works.

Let's create our own

I wanted to build a simple version of this on the client side so we could understand each part properly.

You could use custom events here, but I wanted to create it from scratch so the structure makes sense before we rely on anything built-in.

First, we create our function with the bare minimum values.

function Emitter()
{
    this.actions = new Map();
    this.id = 0;
}

const emitter = new Emitter()

Enter fullscreen mode Exit fullscreen mode

The actions map stores each event label and its callbacks. The id helps us keep track of each callback individually.

From there, we can add our first method for registering callbacks. We’ll call it add, and it will take three parameters: label, listener, and once.

  • label: the name of the event
  • listener: the callback function to run when the event is triggered
  • once: a boolean to decide whether the callback should run only one time
Emitter.prototype.add = function(label, listener, once=false) {

}
Enter fullscreen mode Exit fullscreen mode

First, we check that the listener passed in is actually a function.

if (typeof listener !== "function") {
   throw new TypeError("listener must be a function");
}
Enter fullscreen mode Exit fullscreen mode

Then we check whether the label already exists in our actions map. If it doesn’t, we create a new array for it. This can be only one function, but we will be able to call multiple functions with one trigger using an array.

if (!this.actions.has(label)) {
     this.actions.set(label, []);
}
Enter fullscreen mode Exit fullscreen mode

After that, we push our callback object into the array attached to that label.

this.actions.get(label).push({
            action: listener,
            once: once,
            id:this.id++
});
Enter fullscreen mode Exit fullscreen mode

All together, it looks like this:

 Emitter.prototype.add = function(label, listener, once=false) {
        if (typeof listener !== "function") {
            throw new TypeError("listener must be a function");
        }

        if (!this.actions.has(label)) {
            this.actions.set(label, []);
        }

        this.actions.get(label).push({
            action: listener,
            once: once,
            id:this.id++
        });
    }
Enter fullscreen mode Exit fullscreen mode

Remove a Listener

Now that we can add callbacks to an event, the next thing we need is a way to remove them.

This can be done in a few different ways, but in this version I’m choosing to remove callbacks by comparing the function itself. That keeps things simple, and in most cases it’s enough for what we need.

Emitter.prototype.removeListener = function (label, listener) {
const listeners = this.actions.get(label);

if (!listeners) {
    return
}
}
Enter fullscreen mode Exit fullscreen mode

We start by getting the list of listeners for the label we want to remove from.

If there are no listeners for that label, we just return.

From there, we filter out the callback we want to remove.

const remaining = listeners.filter(entry => entry.action !== listener);
Enter fullscreen mode Exit fullscreen mode

If there are no listeners left after filtering, we delete the label from the map entirely.

If there are still listeners left, we update the map with the filtered list.

if (remaining.length === 0) {
  this.actions.delete(label);
} else {
  this.actions.set(label, remaining);
}
Enter fullscreen mode Exit fullscreen mode

All together, it looks like this:

 Emitter.prototype.removeListener = function (label, listener) {
        const listeners = this.actions.get(label);

        if (!listeners) {
            return
        }

        const remaining = listeners.filter(
            entry => entry.action !== listener
        );

        if (remaining.length === 0) {
            this.actions.delete(label);
        } else {
            this.actions.set(label, remaining);
        }

        return
    }
Enter fullscreen mode Exit fullscreen mode

This gives us a clean way to remove a specific callback without affecting the others. Next, let’s look at removing all listeners from a label at once.

Remove All Listeners

If we want to clear out every callback attached to a label, that becomes much simpler.

Instead of filtering individual functions, we just check whether the label exists in our map and remove it if it does.

Emitter.prototype.removeAllListeners = function (label) {
        const hasLabels = this.actions.has(label);

        if (hasLabels) {
            this.actions.delete(label);
        }
        return 
    }
Enter fullscreen mode Exit fullscreen mode

That’s really all this method needs to do.

If the label exists, we delete it. If it doesn’t, nothing happens.

This is useful when you want to reset a trigger completely without worrying about which callbacks were attached to it.

Next, we need to look at the part that matters most: how the event is actually emitted and how the callbacks are handled.

Emit

This is the most important part of the emitter.

When we trigger an event, we need to decide how to handle the callbacks attached to it. In this version, I wanted to support both synchronous and asynchronous listeners, while also handling the once flag.

That means we need to be careful about how we process each callback and how we clean things up afterwards.

This section you can shape differently depending on whether your labels only have one callback or only synchronous listeners.

Emitter.prototype.emit = async function (label, ...params) {
        const listeners = this.actions.get(label);

        if (!listeners || listeners.length === 0) {
            return false;
        }
}
Enter fullscreen mode Exit fullscreen mode

We start by getting the listeners for the label that was triggered.

If there are no listeners, we return false.

From there, we create a snapshot of the current listeners.

const snapshot = [...listeners];
Enter fullscreen mode Exit fullscreen mode

This is important because it gives us a stable copy to work with while the original array can still be updated separately.

From there, we collect all of the listeners marked with once.

const onceIds = new Set(
     snapshot.filter(entry => entry.once).map(entry => entry.id)
);
Enter fullscreen mode Exit fullscreen mode

Then we filter those listeners out of the active array.

 const remaining = listeners.filter(
            entry => !onceIds.has(entry.id)
 );

 if (remaining.length === 0) {
         this.actions.delete(label);
 } else {
         this.actions.set(label, remaining);
 }
Enter fullscreen mode Exit fullscreen mode

This is where the once behaviour happens. If a listener should only run one time, it gets removed from the map after the event is triggered.

After that, we run all of the callbacks and wait for them to finish.

const results = await Promise.allSettled(
  snapshot.map(async entry => ({
      data: await entry.action(...params),
      id: entry.id,
  }))
);
Enter fullscreen mode Exit fullscreen mode

I used Promise.allSettled here because I wanted every listener to run, even if one of them fails.

That gives us more control. Instead of stopping at the first error, we can inspect all of the results and decide what to do next.

From there, we can separate the successful calls from the failed ones.

 const successful = [];
 const errors = [];

 results.forEach((outcome, index) => {
   const entry = snapshot[index];

   if (outcome.status === "fulfilled") {
           successful.push({ data: outcome.value, id: entry.id });
   } else {
           errors.push({ error: outcome.reason, id: entry.id });
   }
   });
Enter fullscreen mode Exit fullscreen mode

If there are any failures, we can use that error information to decide how cleanup should happen.

In this case, cleanup would not need to be a separate helper if you don’t want it to be. It can simply be part of the same flow inside emit.

That cleanup step could reset the label in actions using the remaining listeners, or handle whatever recovery logic you want to apply.

If there are no failures, we can return the successful results.

   if (errors.length > 0) {
       // await cleanup(errors);
       throw new AggregateError(
           errors.map(item => item.error),
           "One or more listeners failed");
   }

return results;
Enter fullscreen mode Exit fullscreen mode

All together, the method looks like this:

Emitter.prototype.emit = async function (label, ...params) {
        const listeners = this.actions.get(label);

        if (!listeners || listeners.length === 0) {
            return false;
        }

        const snapshot = [...listeners];

        const onceIds = new Set(
            snapshot.filter(entry => entry.once).map(entry => entry.id)
        );

        const remaining = listeners.filter(
            entry => !onceIds.has(entry.id)
        );

        if (remaining.length === 0) {
            this.actions.delete(label);
        } else {
            this.actions.set(label, remaining);
        }

        const results = await Promise.allSettled(
            snapshot.map(async entry => ({
                data: await entry.action(...params),
                id: entry.id,
            }))
        );

        const successful = [];
        const errors = [];

        results.forEach((outcome, index) => {
        const entry = snapshot[index];

        if (outcome.status === "fulfilled") {
            successful.push({ data: outcome.value.data, id: entry.id });
        } else {
            errors.push({ error: outcome.reason, id: entry.id });
        }
        });

        if (errors.length > 0) {
            await cleanup(errors);
            throw new AggregateError(
            errors.map(item => item.error),
            "One or more listeners failed");
        }

        return results;
    }
Enter fullscreen mode Exit fullscreen mode

All together, this gives us a way to trigger multiple callbacks, wait for them to finish, remove once listeners automatically, and handle failures without blocking the rest of the listeners.

This is the part where everything comes together. Now that the emitter can add, remove, and trigger callbacks, we can test it with a few examples.

Let's test it out

Now that we’ve built the emitter, let’s try a few examples and see how it behaves in practice.

Example 1

const emitter = new Emitter()

emitter.add('fetch',async (number) => {
    let res = await fetch('https://pokeapi.co/api/v2/pokemon/'+number);
    if(res.ok)
    {
        let data = await res.json();
        return data
    }else
    {
        console.error('failed to fetch pokemon')
    }
},true)

async function fetchPokemon(){
   let data = await emitter.emit('fetch','100')
   console.log(data[0].value.data)
}

fetchPokemon()
Enter fullscreen mode Exit fullscreen mode

This returns the Pokémon object from the api.

Example 2

const emitter = new Emitter()

emitter.add('add', (numberOne, numberTwo) => {
    return numberOne + numberTwo   
})

async function main(){
   let data = await emitter.emit('add',20,5)
   console.log(data[0].value.data)
}

main()
Enter fullscreen mode Exit fullscreen mode

This returns the result of the two added numbers, which is 25.

Example 3

const emitter = new Emitter()

emitter.add('double',async (number) => {
    let res = await fetch('https://pokeapi.co/api/v2/pokemon/'+ number);
    if(res.ok)
    {
        let data = await res.json();
        return data
    }else
    {
        console.error('failed to fetch pokemon')
    }
},true)

emitter.add('double', (numberOne, numberTwo) => {
    return numberOne + numberTwo   
})

async function main(){
    let data = await emitter.emit('double',20,5)
    console.log(data)
}
Enter fullscreen mode Exit fullscreen mode

This shows how the emitter can handle more than one callback on the same label.

Example 4

const emitter = new Emitter();

emitter.add('fail', '');
Enter fullscreen mode Exit fullscreen mode

This returns a TypeError: listener must be a function.

These examples show the main idea behind the emitter. You can attach callbacks, trigger them with data, and decide how you want to handle success, failure, and one-time listeners.

Conclusion

Event emitters are one of those features that seem simple at first, but once you understand how they work, you start seeing useful ways to apply them in real projects.

For me, that’s the main reason I like building a small version of something myself. It forces me to look at the structure, understand the flow, and think about how the pieces fit together instead of just using the feature blindly.

That process usually gives me a much better understanding than reading about it once and moving on.

Node.js event emitters are especially useful when you want different parts of an application to communicate without being tightly connected. They make it easier to trigger actions, handle async work, and keep things organised when a project starts to grow.

I also think this is a good reminder that learning works best when you stay curious. If you use a feature often, don’t just accept it as a black box. Take some time to see how it works, build a small version, and test your own understanding.

That’s usually where the real learning happens.

Thanks for reading, and happy coding.

Top comments (0)