
How to Build a Type-Safe Event Bus Without the Memory Leak Risks of EventEmitter
Native EventTarget has finally arrived in Node.js, offering a memory-safe and type-friendly alternative to the aging EventEmitter for modern cross-component communication.
Managing state and communication between decoupled components usually leads us back to the same old tool: the EventEmitter. But let's be honest, EventEmitter feels a bit like a relic from a different era of Node.js—one where we didn't care about browser compatibility or the nightmare of manually removing listeners to prevent memory leaks.
If you've ever seen the warning (node:1234) MaxListenersExceededWarning: Possible EventEmitter memory leak detected, you know the pain. You forget to call .removeListener(), and suddenly your heap is growing like a sourdough starter left in a warm kitchen.
Thankfully, Node.js finally brought EventTarget to the party. It’s the same API we’ve used in the browser for decades, and it's remarkably better at handling cleanup and type safety than its predecessor.
Why swap EventEmitter for EventTarget?
EventEmitter is a Node-specific beast. It served us well, but it has two major flaws in the modern era:
1. It's not standard. You can't run it in a browser or a Deno/Bun environment without a polyfill.
2. Cleanup is manual labor. You have to keep a reference to the exact function you used as a listener to remove it later.
EventTarget, combined with AbortController, changes the game. It allows you to kill dozens of listeners across different components with a single line of code. No more tracking every individual callback function like a frantic shepherd.
Building a Type-Safe Event Bus
Let's skip the theory and build something. We want a bus that knows exactly what events exist and what data they carry. No more guessing if the payload is user or userId.
First, we define our event contract:
// Define the shape of our events
interface AppEvents {
'user:logged-in': { id: string; name: string };
'system:error': { code: number; message: string };
'config:updated': { theme: 'light' | 'dark' };
}Now, let's build the wrapper. We’ll extend the native EventTarget so we don't have to reinvent the wheel, but we'll add a layer of TypeScript goodness to enforce our contract.
export class TypedEventBus<T extends Record<string, any>> extends EventTarget {
// A helper to dispatch events with typed payloads
emit<K extends keyof T & string>(eventName: K, detail: T[K]) {
const event = new CustomEvent(eventName, { detail });
this.dispatchEvent(event);
}
// A typed wrapper for addEventListener
on<K extends keyof T & string>(
eventName: K,
callback: (data: T[K]) => void,
options?: AddEventListenerOptions
) {
const handler = (event: Event) => {
const customEvent = event as CustomEvent<T[K]>;
callback(customEvent.detail);
};
this.addEventListener(eventName, handler, options);
return handler; // We'll talk about a better way to clean this up in a second
}
}
// Initialize our bus
const bus = new TypedEventBus<AppEvents>();The "Magic Bullet" for Memory Leaks: AbortSignal
This is where EventTarget really shines. In the old days, if you had a component that listened to five different events, you had to call .off() five times when that component unmounted.
With EventTarget, you can pass an AbortSignal. When the signal aborts, every listener attached to it is automatically nuked.
const controller = new AbortController();
bus.on('user:logged-in', (user) => {
console.log(`Welcome, ${user.name}!`);
}, { signal: controller.signal });
bus.on('config:updated', (conf) => {
console.log(`Switching to ${conf.theme} mode`);
}, { signal: controller.signal });
// Later, when you're done or the component "dies":
controller.abort();
// Boom. All listeners are gone. No memory leaks. No manual cleanup.I've found this pattern incredibly useful in long-running Node.js processes or complex CLI tools where you're spinning up transient "tasks" that shouldn't leave listeners dangling once they finish.
Passing Data with CustomEvent
You might have noticed CustomEvent in the code above. In the browser, we’ve had this forever. In Node, it’s a relatively recent addition (v18.7.0+). It’s the standard way to attach a "payload" to an event via the detail property.
The beauty of this is that it forces a consistent structure. You aren't passing (a, b, c, d) as arguments like you might with EventEmitter. You're passing a single, structured object.
A Real-World Example: The "Job Processor"
Imagine you’re building a background job processor. You want to track progress without tightly coupling your UI logic to your worker logic.
type JobEvents = {
'job:progress': { jobId: string; percent: number };
'job:complete': { jobId: string; duration: number };
};
const jobBus = new TypedEventBus<JobEvents>();
function startWorker(id: string) {
const controller = new AbortController();
// Listen for progress
jobBus.on('job:progress', ({ percent }) => {
console.log(`Job ${id} is ${percent}% done`);
if (percent === 100) {
controller.abort(); // Self-cleaning!
}
}, { signal: controller.signal });
// Simulate work
let p = 0;
const i = setInterval(() => {
p += 25;
jobBus.emit('job:progress', { jobId: id, percent: p });
if (p >= 100) clearInterval(i);
}, 500);
}
startWorker('alpha-1');Edge Cases and Gotchas
While I'm clearly a fan of this approach, there are a couple of things to keep in mind:
1. Synchronicity: EventEmitter listeners are called synchronously in the order they were registered. EventTarget also calls listeners synchronously. If you need async behavior, you'll need to wrap your logic in setImmediate or process.nextTick inside the handler.
2. Error Handling: If a listener throws an error, it doesn't necessarily stop other listeners from firing, but it can crash your process if not caught, just like EventEmitter. Always wrap your sensitive logic in a try/catch.
3. Performance: For 99% of applications, the performance difference between EventEmitter and EventTarget is negligible. However, if you are emitting millions of events per second in a high-frequency trading bot, EventEmitter is still slightly faster due to its lower abstraction level.
Wrapping Up
Moving away from EventEmitter isn't just about being "modern"—it's about writing code that's easier to maintain and harder to break. By leveraging EventTarget and AbortController, you get:
- Type safety that actually works.
- Auto-cleanup that prevents memory leaks.
- Standardized code that works in Node, Chrome, and Edge.
Next time you reach for events, try extending EventTarget instead. Your future self, hunting for memory leaks at 2 AM, will thank you.


