Build Your Own Custom Event Emitter
-
Event systems show up everywhere in JavaScript. Click handlers in the browser, HTTP servers in Node, message buses, even many UI frameworks use the same idea. You subscribe to something, later an event fires, all the subscribers run.
-
In this guide we will build that engine ourselves. We will start tiny, then add features one by one. Every snippet compiles on its own, and we will only call helpers after we have defined them, so beginners do not need to guess how things work.
What is an Event Emitter?
An event emitter is just a system where:
- You can listen for events (
on
). - You can emit (trigger) events (
emit
). - You can remove listeners when you don’t need them anymore.
Think of it like a walkie-talkie:
One person shouts (
emit
) →
Everyone tuned in (on
) hears it.
What we are building
An Event Emitter gives you three core abilities:
- on(eventName, listener), register a function that should run when an event happens
- emit(eventName, ...args), trigger an event and pass data to listeners
- off or removeListener(eventName, listener), stop a listener from running
We will also add these quality of life extras:
- once, run a listener only the first time the event fires
- removeAllListeners, clean up
- setMaxListeners and getMaxListeners, simple memory leak warnings
- newListener and removeListener meta events, useful for debugging and tooling
We will store listeners in a Map, where the key is the event name and the value is an array of functions.
Step 1, start the class and storage
We need a place to keep listeners. A Map is perfect because lookups are fast and keys can be any string.
class CustomEventEmitter {
constructor() {
// eventName -> array of listener functions
this.events = new Map();
}
}
- So if someone listens to "data", it might look like:
events = {
"data" → [listener1, listener2]
}
Try it in Node with an empty file and node file.js
. Nothing happens yet, that is fine.
Step 2, add a tiny emit
Before we can register listeners, it helps to know how they will be used. Let us teach the emitter how to fire an event. If there are listeners for that event, call each one with any arguments we received.
class CustomEventEmitter {
constructor() {
this.events = new Map();
}
emit(eventName, ...args) {
const listeners = this.events.get(eventName);
if (!listeners || listeners.length === 0) return false;
// copy so changes during emit do not break the loop
const copy = [...listeners];
for (const fn of copy) {
fn(...args);
}
return true;
}
}
...args
is the rest parameter. It captures any number of values.- We return
true
if something ran,false
otherwise.
Step 3, add a validator helper
We want to fail early if someone tries to register a non function. We will define a small helper now, and then call it from the next step.
class CustomEventEmitter {
constructor() {
this.events = new Map();
}
// helper, define it before we use it
validateListener(listener) {
if (typeof listener !== "function") {
throw new TypeError("Listener must be a function");
}
}
emit(eventName, ...args) {
const listeners = this.events.get(eventName);
if (!listeners || listeners.length === 0) return false;
const copy = [...listeners];
for (const fn of copy) fn(...args);
return true;
}
}
Step 4, implement on method
Now we can register listeners. If this is the first listener for an event, create an empty array first, then push.
class CustomEventEmitter {
constructor() {
this.events = new Map();
}
validateListener(listener) {
if (typeof listener !== "function") {
throw new TypeError("Listener must be a function");
}
}
on(eventName, listener) {
this.validateListener(listener);
if (!this.events.has(eventName)) {
this.events.set(eventName, []);
}
this.events.get(eventName).push(listener);
return this; // lets you chain on().on()
}
emit(eventName, ...args) {
const listeners = this.events.get(eventName);
if (!listeners || listeners.length === 0) return false;
const copy = [...listeners];
for (const fn of copy) fn(...args);
return true;
}
}
Quick test
const e = new CustomEventEmitter();
e.on("greet", (name) => console.log("Hello", name));
e.emit("greet", "Alice"); // prints Hello Alice
Step 5, implement removeListener and removeAllListeners
We should be able to stop listening. We will remove one function from the array. If the array becomes empty, we will delete the key from the Map.
class CustomEventEmitter {
constructor() {
this.events = new Map();
}
validateListener(listener) {
if (typeof listener !== "function") {
throw new TypeError("Listener must be a function");
}
}
on(eventName, listener) {
this.validateListener(listener);
if (!this.events.has(eventName)) this.events.set(eventName, []);
this.events.get(eventName).push(listener);
return this;
}
removeListener(eventName, listener) {
const list = this.events.get(eventName);
if (!list) return this;
const idx = list.indexOf(listener);
if (idx !== -1) {
list.splice(idx, 1);
if (list.length === 0) this.events.delete(eventName);
}
return this;
}
removeAllListeners(eventName) {
if (typeof eventName === "string") {
this.events.delete(eventName);
} else {
this.events.clear();
}
return this;
}
emit(eventName, ...args) {
const listeners = this.events.get(eventName);
if (!listeners || listeners.length === 0) return false;
const copy = [...listeners];
for (const fn of copy) fn(...args);
return true;
}
}
Quick test
const e = new CustomEventEmitter();
function handler(x) {
console.log("value", x);
}
e.on("data", handler);
e.emit("data", 1); // value 1
e.removeListener("data", handler);
e.emit("data", 2); // nothing
Step 6, add once using a wrapper
A once listener should run the first time, then remove itself. The usual trick is to wrap the original function in a small helper that removes itself before calling the original.
class CustomEventEmitter {
constructor() {
this.events = new Map();
this.onceListeners = new Map(); // optional bookkeeping
}
validateListener(listener) {
if (typeof listener !== "function") {
throw new TypeError("Listener must be a function");
}
}
on(eventName, listener) {
this.validateListener(listener);
if (!this.events.has(eventName)) this.events.set(eventName, []);
this.events.get(eventName).push(listener);
return this;
}
once(eventName, listener) {
this.validateListener(listener);
const onceWrapper = (...args) => {
this.removeListener(eventName, onceWrapper);
listener.apply(this, args);
};
// keep a back reference so removal by original function still works
onceWrapper.originalListener = listener;
// optional bookkeeping for introspection or cleanup
if (!this.onceListeners.has(eventName))
this.onceListeners.set(eventName, []);
this.onceListeners.get(eventName).push(onceWrapper);
return this.on(eventName, onceWrapper);
}
removeListener(eventName, listener) {
const list = this.events.get(eventName);
if (list) {
const idx = list.indexOf(listener);
if (idx !== -1) {
list.splice(idx, 1);
if (list.length === 0) this.events.delete(eventName);
}
}
// also check the once wrappers
const onceList = this.onceListeners.get(eventName);
if (onceList) {
const idx = onceList.findIndex(
(l) => l === listener || l.originalListener === listener
);
if (idx !== -1) {
onceList.splice(idx, 1);
if (onceList.length === 0) this.onceListeners.delete(eventName);
}
}
return this;
}
removeAllListeners(eventName) {
if (typeof eventName === "string") {
this.events.delete(eventName);
this.onceListeners.delete(eventName);
} else {
this.events.clear();
this.onceListeners.clear();
}
return this;
}
emit(eventName, ...args) {
const listeners = this.events.get(eventName);
if (!listeners || listeners.length === 0) return false;
const copy = [...listeners];
for (const fn of copy) fn(...args);
return true;
}
}
Quick test
const e = new CustomEventEmitter();
e.once("ready", () => console.log("runs once"));
e.emit("ready"); // prints
e.emit("ready"); // nothing
Step 7, add basic safety warnings
Attaching a very large number of listeners is often a mistake. We can warn when the count goes above a limit.
class CustomEventEmitter {
constructor() {
this.events = new Map();
this.onceListeners = new Map();
this.maxListeners = 10;
this.warningsEnabled = true;
}
validateListener(listener) {
if (typeof listener !== "function") {
throw new TypeError("Listener must be a function");
}
}
checkMaxListeners(eventName, count) {
if (
this.warningsEnabled &&
this.maxListeners > 0 &&
count > this.maxListeners
) {
console.warn(
`Possible memory leak, ${count} listeners on "${eventName}". ` +
`Use setMaxListeners() to increase the limit`
);
}
}
setMaxListeners(n) {
if (typeof n !== "number" || n < 0) {
throw new TypeError("Max listeners must be a non negative number");
}
this.maxListeners = n;
return this;
}
getMaxListeners() {
return this.maxListeners;
}
on(eventName, listener) {
this.validateListener(listener);
if (!this.events.has(eventName)) this.events.set(eventName, []);
const list = this.events.get(eventName);
list.push(listener);
this.checkMaxListeners(eventName, list.length);
return this;
}
// once, removeListener, removeAllListeners, emit, same as before
}
Step 8, make emit safer with error events
In Node, emitting an error without a handler should throw. We will follow that idea. If a listener throws during emit, we will re-emit an error on the next tick so your error handler can catch it.
emit(eventName, ...args) {
const listeners = this.events.get(eventName);
if (!listeners || listeners.length === 0) {
if (eventName === "error") {
const err = args[0] instanceof Error
? args[0]
: new Error(args[0] || "Unhandled error event");
throw err;
}
return false;
}
const copy = [...listeners];
for (const fn of copy) {
try {
fn.apply(this, args);
} catch (err) {
if (eventName !== "error") {
// schedule an error event so user code can handle it
if (typeof process !== "undefined" && typeof process.nextTick === "function") {
process.nextTick(() => this.emit("error", err));
} else {
setTimeout(() => this.emit("error", err), 0);
}
} else {
throw err;
}
}
}
return true;
}
Step 9, meta events, newListener and removeListener
Tools sometimes want to know when listeners are added or removed. We can emit meta events for that. Call emit("newListener", eventName, listener)
right after adding, and call emit("removeListener", eventName, listener)
right after removing.
Hook them into the methods we already have:
on(eventName, listener) {
this.validateListener(listener);
if (!this.events.has(eventName)) this.events.set(eventName, []);
const list = this.events.get(eventName);
list.push(listener);
this.checkMaxListeners(eventName, list.length);
// meta event
this.emit("newListener", eventName, listener);
return this;
}
removeListener(eventName, listener) {
const list = this.events.get(eventName);
if (list) {
const idx = list.indexOf(listener);
if (idx !== -1) {
list.splice(idx, 1);
// meta event
this.emit("removeListener", eventName, listener);
if (list.length === 0) this.events.delete(eventName);
}
}
const onceList = this.onceListeners.get(eventName);
if (onceList) {
const idx = onceList.findIndex(
l => l === listener || l.originalListener === listener
);
if (idx !== -1) {
onceList.splice(idx, 1);
if (onceList.length === 0) this.onceListeners.delete(eventName);
}
}
return this;
}
Step 10, add friendly aliases
People often expect addListener
and off
.
addListener(eventName, listener) {
return this.on(eventName, listener);
}
off(eventName, listener) {
return this.removeListener(eventName, listener);
}
Final code, ready to paste
Below is a single file version that includes every feature we built. It matches the structure you shared, with helpers defined before use inside each step of the blog and with the small safety improvement for emit
when process.nextTick
is not available.
class CustomEventEmitter {
constructor() {
this.events = new Map();
this.onceListeners = new Map();
this.maxListeners = 10;
this.warningsEnabled = true;
}
// helpers
validateListener(listener) {
if (typeof listener !== "function") {
throw new TypeError("Listener must be a function");
}
}
checkMaxListeners(eventName, count) {
if (
this.warningsEnabled &&
this.maxListeners > 0 &&
count > this.maxListeners
) {
console.warn(
`Possible EventEmitter memory leak detected. ${count} ${eventName} listeners added. ` +
`Use emitter.setMaxListeners() to increase limit`
);
}
}
setMaxListeners(n) {
if (typeof n !== "number" || n < 0) {
throw new TypeError("Max listeners must be a non-negative number");
}
this.maxListeners = n;
return this;
}
getMaxListeners() {
return this.maxListeners;
}
// core
on(eventName, listener) {
this.validateListener(listener);
if (!this.events.has(eventName)) {
this.events.set(eventName, []);
}
const listeners = this.events.get(eventName);
listeners.push(listener);
this.checkMaxListeners(eventName, listeners.length);
this.emit("newListener", eventName, listener);
return this;
}
addListener(eventName, listener) {
return this.on(eventName, listener);
}
once(eventName, listener) {
this.validateListener(listener);
const onceWrapper = (...args) => {
this.removeListener(eventName, onceWrapper);
listener.apply(this, args);
};
onceWrapper.originalListener = listener;
if (!this.onceListeners.has(eventName)) {
this.onceListeners.set(eventName, []);
}
this.onceListeners.get(eventName).push(onceWrapper);
return this.on(eventName, onceWrapper);
}
emit(eventName, ...args) {
const listeners = this.events.get(eventName);
if (!listeners || listeners.length === 0) {
if (eventName === "error") {
const error =
args[0] instanceof Error
? args[0]
: new Error(args[0] || "Unhandled error event");
throw error;
}
return false;
}
const listenersCopy = [...listeners];
for (const listener of listenersCopy) {
try {
listener.apply(this, args);
} catch (error) {
if (eventName !== "error") {
if (
typeof process !== "undefined" &&
typeof process.nextTick === "function"
) {
process.nextTick(() => this.emit("error", error));
} else {
setTimeout(() => this.emit("error", error), 0);
}
} else {
throw error;
}
}
}
return true;
}
removeListener(eventName, listener) {
const listeners = this.events.get(eventName);
if (listeners) {
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
if (listeners.length === 0) {
this.events.delete(eventName);
}
this.emit("removeListener", eventName, listener);
}
}
const onceListeners = this.onceListeners.get(eventName);
if (onceListeners) {
const onceIndex = onceListeners.findIndex(
(l) => l === listener || l.originalListener === listener
);
if (onceIndex !== -1) {
onceListeners.splice(onceIndex, 1);
if (onceListeners.length === 0) {
this.onceListeners.delete(eventName);
}
}
}
return this;
}
off(eventName, listener) {
return this.removeListener(eventName, listener);
}
removeAllListeners(eventName) {
if (eventName) {
const listeners = this.events.get(eventName);
if (listeners) {
for (const listener of listeners) {
this.emit("removeListener", eventName, listener);
}
}
this.events.delete(eventName);
this.onceListeners.delete(eventName);
} else {
for (const [event, listeners] of this.events) {
for (const listener of listeners) {
this.emit("removeListener", event, listener);
}
}
this.events.clear();
this.onceListeners.clear();
}
return this;
}
}
export default CustomEventEmitter;
Usage demo
const emitter = new CustomEventEmitter();
emitter.on("newListener", (evt) => console.log("added listener for", evt));
emitter.on("removeListener", (evt) => console.log("removed listener for", evt));
emitter.on("greet", (name) => console.log("Hello", name));
emitter.once("ready", () => console.log("Only once"));
emitter.emit("greet", "Alice");
emitter.emit("ready");
emitter.emit("ready");
function handler(x) {
console.log("value", x);
}
emitter.on("data", handler);
emitter.emit("data", 1);
emitter.removeListener("data", handler);
emitter.emit("data", 2);
emitter.on("error", (err) => console.log("caught:", err.message));
emitter.on("boom", () => {
throw new Error("oops");
});
emitter.emit("boom");
Notes for running
- If you save this as
emitter.mjs
, you can import it withimport CustomEventEmitter from "./emitter.mjs"
. - If you prefer CommonJS, replace the export with
module.exports = CustomEventEmitter
and useconst CustomEventEmitter = require("./emitter")
.
That is it. You now have a solid Event Emitter you understand line by line. If you want, to add extras like prependListener
, listenerCount
, or a waitFor(eventName)
that returns a Promise for async workflow. you can check this gist
Have feedback?
- Was anything confusing, hard to follow, or out of date? Let me know what you think of the article and I'll make sure to update it with your advice.
Read more

Tanstack Query
Understand what TanStack Query is, why it’s powerful, and how to start using it in your projects.

Inside Node.js: V8 And The Event Loop
Ever wondered what happens after you type node app.js? In this post, we'll explore how V8 parses your JavaScript into an AST, runs it through Ignition and TurboFan, and how Node's event loop (powered by libuv) handles async operations like promises, timers, and I/O.