Programming Tutorials

Build a Basic Event Emitter / Pub-Sub System From Scratch

A working event emitter built in under fifty lines, plus the specific bug that let one misbehaving listener silently break every other listener subscribed to the same event.

By Aissam Ait Ahmed Programming Tutorials 0 comments

Node's built-in EventEmitter and every pub-sub library on npm hide a genuinely small amount of actual logic behind their API — a map of event names to arrays of listener functions, plus a way to add to and iterate over that map. Building one from scratch takes under fifty lines and makes the pattern's real trade-offs visible in a way that using a library's polished implementation doesn't, including a failure mode that's easy to miss until you hit it directly.

The core: a map of event names to listener arrays

class EventEmitter {
    #listeners = {};

    on(eventName, callback) {
        if (!this.#listeners[eventName]) {
            this.#listeners[eventName] = [];
        }

        this.#listeners[eventName].push(callback);

        return this; // allow chaining
    }

    emit(eventName, ...args) {
        const callbacks = this.#listeners[eventName] || [];

        for (const callback of callbacks) {
            callback(...args);
        }
    }
}

const bus = new EventEmitter();

bus.on('order.created', (order) => sendConfirmationEmail(order));
bus.on('order.created', (order) => updateInventory(order));
bus.on('order.created', (order) => notifySlack(order));

bus.emit('order.created', { id: 42, total: 89.99 });

Three completely independent listeners react to the same event, none of them aware the others exist — that decoupling is the entire point of the pattern. The code that creates an order doesn't need to know or care that it results in an email, an inventory update, and a Slack message; it just emits what happened and lets interested parties react.

Adding off() — and the bug that showed up removing the wrong thing

off(eventName, callback) {
    if (!this.#listeners[eventName]) return;

    this.#listeners[eventName] = this.#listeners[eventName]
        .filter((listener) => listener !== callback);
}

This looks straightforward and broke immediately for a specific, common usage pattern: an inline arrow function passed directly to on() can never be removed with off(), because each time you write (order) => doSomething(order), JavaScript creates a brand-new function reference — even if the code looks identical, fn1 !== fn2 for two separately-written arrow functions with the same body. Calling off('order.created', (order) => sendConfirmationEmail(order)) does nothing, because that new inline function is not reference-equal to the one originally passed to on(), even though a human reading both lines would assume they're "the same" listener.

// This does NOT remove the listener — it's a different function reference
bus.on('order.created', (order) => sendConfirmationEmail(order));
bus.off('order.created', (order) => sendConfirmationEmail(order)); // no-op, silently

// This works, because it's the same reference both times
const emailListener = (order) => sendConfirmationEmail(order);
bus.on('order.created', emailListener);
bus.off('order.created', emailListener); // correctly removes it

Nothing throws an error in the broken version — off() just quietly fails to remove anything, which is a genuinely nasty failure mode because the code looks correct and runs without complaint. The listener stays subscribed indefinitely, which in a long-running process is a real, gradual memory leak as more and more listeners accumulate on events that were supposed to have been cleaned up.

The bigger bug: one failing listener breaking every listener after it

The more serious issue surfaced once a real listener threw an exception — a third-party email API call failing inside the sendConfirmationEmail listener. Because emit() simply loops and calls each callback directly with no error handling around the call, an exception thrown by one listener propagated up through the loop and stopped execution entirely, meaning updateInventory and notifySlack — completely unrelated listeners with no dependency on the email call succeeding — never ran at all for that event.

// Before: one listener throwing kills every listener after it in the array
emit(eventName, ...args) {
    const callbacks = this.#listeners[eventName] || [];

    for (const callback of callbacks) {
        callback(...args); // an exception here stops the whole loop
    }
}

// After: isolate each listener so one failure can't affect the others
emit(eventName, ...args) {
    const callbacks = this.#listeners[eventName] || [];

    for (const callback of callbacks) {
        try {
            callback(...args);
        } catch (error) {
            console.error(`Listener for "${eventName}" threw:`, error);
            // continue to the next listener regardless
        }
    }
}

Wrapping each individual listener call in its own try/catch, inside the loop rather than around it, means one listener's failure is contained to that listener — every other subscriber to the same event still runs, which matches what the decoupling promise of pub-sub actually implies. Without this, the whole point of using events to decouple unrelated side effects quietly breaks the moment any single one of them can fail, which — for anything calling an external API — is a real, ordinary possibility, not an edge case.

Adding once() for a listener that should only fire a single time

once(eventName, callback) {
    const wrapper = (...args) => {
        callback(...args);
        this.off(eventName, wrapper); // remove itself after firing
    };

    this.on(eventName, wrapper);

    return this;
}

The wrapper function is what actually gets registered as the listener, and it removes itself from the listener list — by reference to itself, correctly, since wrapper is a single stable reference rather than a new inline function each time — right after calling the real callback once. This is a small, genuinely clever use of closures: wrapper closes over both callback and a reference to itself via the surrounding once() call, which is exactly what makes the self-removal possible.

A realistic use case: decoupling order processing from side effects

The order-created example throughout this post isn't hypothetical — it's the actual shape that made a real refactor easier. A checkout flow originally called sendConfirmationEmail(), updateInventory(), and notifySlack() directly, in sequence, inside the same function that processed the order. Every new side effect added to that list meant editing the core checkout function again, and a bug in any one of the three could, before the try/catch fix above, block the others from running at all. Moving all three behind an emitted order.created event meant the checkout function's job shrank to "process the order and emit that it happened," with every side effect subscribing independently — adding a fourth side effect later meant adding one new bus.on() call, with zero changes to the checkout function itself.

What this pattern is and isn't good for

  • Good fit: decoupling side effects that don't need to return a value to the code that triggered them, and don't need to happen in a guaranteed order relative to each other.
  • Bad fit: anything where the caller needs a return value or needs to know for certain that a specific listener succeeded before continuing — a plain function call, or an awaited promise, is more honest about that dependency than an emitted event that the caller has no visibility into.
  • Worth watching for: listener accumulation from code that calls on() repeatedly without ever calling off() — exactly the leak the reference-equality bug above causes when it goes unnoticed.

Comparing this to the library version you'd actually use

Node's built-in EventEmitter handles all of the above and a few things this tiny version doesn't — a configurable max-listener warning that flags a likely leak automatically rather than requiring you to notice growing memory usage yourself, and a distinct 'error' event convention where an emitted error with no listener attached throws rather than silently vanishing. Building the small version first is what makes those library decisions legible: the max-listener warning exists specifically because the reference-equality leak covered above is common enough in real codebases that the library authors added a built-in guard against it, and that guard makes a lot more sense once you've hit the underlying problem yourself in fifty lines of code you wrote rather than reading about it as an abstract feature in documentation.

The error-event convention is worth calling out specifically, because it's an easy thing to get wrong building your own version: if an 'error'-named event is emitted with zero listeners attached, silently doing nothing — the same behavior as any other unlistened event in the implementation above — hides a failure that arguably should be loud. A small addition makes this version match that convention:

emit(eventName, ...args) {
    const callbacks = this.#listeners[eventName] || [];

    if (eventName === 'error' && callbacks.length === 0) {
        throw args[0] instanceof Error ? args[0] : new Error(String(args[0]));
    }

    for (const callback of callbacks) {
        try {
            callback(...args);
        } catch (error) {
            console.error(`Listener for "${eventName}" threw:`, error);
        }
    }
}

This is a deliberate, narrow exception to the "one listener's failure shouldn't affect the system" principle established earlier — an unhandled error event is specifically the case where staying silent is worse than being loud, because an emitted error with nobody listening for it usually means a real problem is being generated and nobody in the system is positioned to see it.

If you're applying this same decoupling idea to backend job processing rather than in-browser event handling, building a job queue from scratch in PHP covers a related pattern — a queue is, in a sense, a persistent, durable version of the same "emit now, handle later, independently" idea. And if the workflow triggering these events involves an external service like Slack, the delivery-reliability concerns are the same ones covered in building a Slack bot that actually gets used.

Adding wildcard listeners for cross-cutting concerns

A real requirement that showed up once we had a handful of events flowing through the bus: logging every single event for debugging, without adding a specific logging call to every individual emit() site scattered across the codebase. A wildcard subscription, listening to every event regardless of name, solved this cleanly:

class EventEmitter {
    #listeners = {};
    #wildcardListeners = [];

    onAny(callback) {
        this.#wildcardListeners.push(callback);
        return this;
    }

    emit(eventName, ...args) {
        const callbacks = this.#listeners[eventName] || [];

        for (const callback of callbacks) {
            try {
                callback(...args);
            } catch (error) {
                console.error(`Listener for "${eventName}" threw:`, error);
            }
        }

        for (const callback of this.#wildcardListeners) {
            try {
                callback(eventName, ...args);
            } catch (error) {
                console.error(`Wildcard listener threw for "${eventName}":`, error);
            }
        }
    }
}

bus.onAny((eventName, ...args) => {
    console.debug(`[event] ${eventName}`, args);
});

Every emitted event now flows through both its specific listeners and any wildcard listeners, with the same per-listener error isolation applied to both. This turned out to be genuinely useful beyond debugging — a single wildcard listener also became the natural place to forward every event into an analytics pipeline later, again without touching any of the individual emit() call sites scattered through the checkout flow.

A namespacing convention that avoided a real collision

Early on, two unrelated parts of the codebase both emitted an event named simply updated — one for order updates, one for user profile updates — and a listener subscribed to updated expecting order data received user profile data instead on at least one occasion, silently, with no error thrown anywhere, because the emitter has no concept of what a listener actually expects to receive. The fix was a naming convention, not a code change: every event name follows a noun.verb pattern (order.created, user.updated, order.cancelled), which is purely a discipline enforced by convention rather than anything the emitter itself validates, but it eliminated this entire category of collision going forward simply by making event names specific enough that two unrelated concepts couldn't accidentally share one.

  • Namespace event names by the entity they concern, not just the action — order.created, not created.
  • A wildcard listener is worth adding early, even before you have a specific use for it beyond debugging — the cost is small and it tends to find a real use (logging, analytics, audit trails) once it exists.
  • The emitter has no way to validate what shape of data a listener expects, which is the trade-off for its flexibility — a naming convention is cheap insurance against the kind of silent collision described above, since the emitter itself will never catch it for you.
Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Programming Tutorials Free Resources Explore Tools