Question: If a
once()listener is wrapped, how canoff(event, originalFunction)still remove it?Answer: The wrapper must keep the original function's identity somewhere. If your emitter hides that relationship, removal becomes guesswork.
That tiny problem exposes most of the hard parts in an event system: duplicate listeners, mutation during dispatch, synchronous ordering, one-shot cleanup, and what an error event means.
We will build a small emitter that makes those contracts explicit. Use Node's battle-tested EventEmitter in production unless you need different semantics. The point of this implementation is to understand the promises your code already depends on.
Before code, answer five questions
An event emitter is not just a map from names to callbacks. Pick the behavior first.
- Are listeners called synchronously or queued?
- Does registration order matter?
- Can the same function be registered twice?
- What happens if a listener is removed while an emit is in progress?
- Is
erroran ordinary event or a failure channel?
Node's answers are precise:
-
emit()calls listeners synchronously in registration order. - Duplicate registrations are allowed and produce duplicate calls.
-
removeListener()removes at most one matching registration. - Removing a listener during an active emit does not cancel that listener from the already-started dispatch.
- Emitting
errorwithout anerrorlistener throws.
Those rules are documented in the Node 24.15 Events API. Our emitter will copy this useful core, without pretending to reproduce every Node feature.
A listener is a registration, not only a function
Start with a representation that can preserve identity:
class TinyEmitter {
#events = new Map();
on(eventName, listener) {
this.#assertListener(listener);
const registrations = this.#events.get(eventName) ?? [];
registrations.push({ listener, original: listener, once: false });
this.#events.set(eventName, registrations);
return this;
}
#assertListener(listener) {
if (typeof listener !== 'function') {
throw new TypeError('listener must be a function');
}
}
}
Why store an object instead of the function directly? Because one function may appear in several independent registrations. Each registration can have its own once behavior while still pointing to the same original callback.
const emitter = new TinyEmitter();
function logSave(path) {
console.log('saved', path);
}
emitter.on('save', logSave);
emitter.on('save', logSave);
This is not accidental deduplication waiting to happen. Node explicitly allows it. Calling emit('save') should call logSave twice. If duplicate registration is a bug in your application, prevent it at the application boundary instead of silently changing emitter semantics.
Synchronous emit creates a stack, not a queue
Add dispatch:
emit(eventName, ...args) {
const registrations = this.#events.get(eventName);
if (!registrations || registrations.length === 0) {
if (eventName === 'error') {
const error = args[0];
throw error instanceof Error
? error
: new Error(`Unhandled error event: ${String(error)}`);
}
return false;
}
const snapshot = registrations.slice();
for (const registration of snapshot) {
if (registration.once) {
this.#removeRegistration(eventName, registration);
}
registration.listener.apply(this, args);
}
return true;
}
The call to apply is intentional. With a normal function listener, Node binds this to the emitter. Arrow functions keep lexical this, as usual.
The snapshot is more important. Consider this:
function first() {
console.log('first');
emitter.off('tick', second);
}
function second() {
console.log('second');
}
emitter.on('tick', first);
emitter.on('tick', second);
emitter.emit('tick');
Node calls both listeners during that emit. The removal affects later emits. Iterating a snapshot gives us the same observable rule.
If we iterated the live array and spliced it inside first, second might slide into the current index and get skipped. That would make listener behavior depend on array mutation details instead of the contract.
Synchronous does not mean "fast" or "blocking forever." It means each listener runs on the caller's current stack before emit() returns. A listener can choose to defer its own work:
emitter.on('report', (payload) => {
setImmediate(() => writeReport(payload));
});
The emitter still dispatched synchronously. The listener scheduled separate work.
Remove one registration from the end
Node removes at most one matching registration. When the same function appears more than once, current Node behavior removes the most recently added matching instance.
off(eventName, listener) {
this.#assertListener(listener);
const registrations = this.#events.get(eventName);
if (!registrations) return this;
for (let index = registrations.length - 1; index >= 0; index -= 1) {
const registration = registrations[index];
if (registration.original === listener) {
registrations.splice(index, 1);
break;
}
}
if (registrations.length === 0) {
this.#events.delete(eventName);
}
return this;
}
#removeRegistration(eventName, target) {
const registrations = this.#events.get(eventName);
if (!registrations) return;
const index = registrations.indexOf(target);
if (index !== -1) registrations.splice(index, 1);
if (registrations.length === 0) {
this.#events.delete(eventName);
}
}
Identity comparison uses ===. Two arrow functions with identical source text are still different objects:
emitter.on('open', () => console.log('open'));
emitter.off('open', () => console.log('open')); // removes nothing
Keep the reference when you will need to unsubscribe:
const handleOpen = () => console.log('open');
emitter.on('open', handleOpen);
emitter.off('open', handleOpen);
This is the same reason a UI cleanup can fail when registration and removal each create a fresh closure.
once() must remove before calling
Our registration object makes once() simple:
once(eventName, listener) {
this.#assertListener(listener);
const registrations = this.#events.get(eventName) ?? [];
registrations.push({ listener, original: listener, once: true });
this.#events.set(eventName, registrations);
return this;
}
Notice the dispatch order:
if (registration.once) {
this.#removeRegistration(eventName, registration);
}
registration.listener.apply(this, args);
Removal happens before invocation. That protects against re-entrant emits:
emitter.once('ready', () => {
console.log('ready');
emitter.emit('ready');
});
emitter.emit('ready'); // logs once, not recursively forever
A common hand-built version removes the wrapper after calling the listener. The nested emit then sees the wrapper still installed and calls it again.
Node exposes this wrapper distinction through two inspection methods. listeners(event) returns original listeners, including originals registered with once(). rawListeners(event) includes wrappers. Our object representation makes the same split easy:
listeners(eventName) {
return (this.#events.get(eventName) ?? [])
.map(({ original }) => original);
}
rawListeners(eventName) {
return (this.#events.get(eventName) ?? [])
.map(({ listener }) => listener);
}
Our once() does not need a callable wrapper, so both arrays contain the same function. If you implement once() with wrappers, keep wrapper.listener = original or an equivalent registration field so off(event, original) can still work.
error is a policy decision
Node treats the string event name error specially. No listener means the emitted error is thrown and the process normally exits. That loud failure prevents an operational error from disappearing into an empty listener list.
const emitter = new TinyEmitter();
emitter.emit('error', new Error('connection lost'));
Our emit copies that core behavior. Whether your own event abstraction should do the same depends on its role. A browser UI event bus may be better with explicit result types. A Node service component may benefit from Node-compatible failure semantics.
Async listeners add another edge:
emitter.on('job', async () => {
throw new Error('job failed');
});
emitter.emit('job');
Synchronous try/catch around listener.apply cannot catch a later Promise rejection. Node offers captureRejections to route rejected async listeners to its rejection hook or error channel. Our tiny emitter does not. Callers must catch inside the listener, or the emitter must detect thenables and define what happens to rejection.
Do not bolt that feature on without deciding whether rejection routing is synchronous, asynchronous, recursive, and observable through error.
Test contracts, not private arrays
These tests use Node's built-in test runner:
import assert from 'node:assert/strict';
import test from 'node:test';
test('keeps registration order and duplicates', () => {
const emitter = new TinyEmitter();
const calls = [];
const repeated = () => calls.push('same');
emitter.on('tick', () => calls.push('first'));
emitter.on('tick', repeated);
emitter.on('tick', repeated);
emitter.emit('tick');
assert.deepEqual(calls, ['first', 'same', 'same']);
});
test('removing during emit affects the next emit', () => {
const emitter = new TinyEmitter();
const calls = [];
const second = () => calls.push('second');
const first = () => {
calls.push('first');
emitter.off('tick', second);
};
emitter.on('tick', first);
emitter.on('tick', second);
emitter.emit('tick');
emitter.emit('tick');
assert.deepEqual(calls, ['first', 'second', 'first']);
});
test('once is safe under re-entrant emit', () => {
const emitter = new TinyEmitter();
let calls = 0;
emitter.once('ready', () => {
calls += 1;
emitter.emit('ready');
});
emitter.emit('ready');
assert.equal(calls, 1);
});
Run them with:
node --test emitter.test.js
The implementation is about 70 lines. The semantics are the actual product.
When reviewing an event system, I now ask one question first: what is a listener in this design? If the answer is only "a function in an array," once(), duplicate removal, and in-flight mutation are probably waiting to surprise you.


Top comments (0)