This is the same pub/sub primitive as Event Emitter, with one change to the API. Instead of on/off, there is a single subscribe(eventName, callback) that returns a handle — a small object with a release() method. Calling release() removes that one subscription. There is no off(eventName, callback). The handle is the only way to unsubscribe.
The handle pattern shows up in RxJS, in DOM AbortController, and in most modern bus libraries because it sidesteps a real footgun in the on/off design: if the caller passes an inline arrow function to on, they no longer hold a reference equal to it and can never call off for it. A handle is unique by construction.
class EventEmitter {
subscribe(eventName, callback)
// returns { release: () => void }
emit(eventName, ...args)
// calls each live subscriber for eventName, in subscription order, with args
}
eventName is a string. callback is a function. release() returns nothing.
Basic subscribe + emit:
const bus = new EventEmitter();
const sub = bus.subscribe('login', (user) => console.log('hi', user));
bus.emit('login', 'ada'); // logs: hi ada
sub.release();
bus.emit('login', 'ada'); // no listeners — silent
Release one of several subscribers:
const bus = new EventEmitter();
const a = bus.subscribe('tick', () => console.log('a'));
const b = bus.subscribe('tick', () => console.log('b'));
const c = bus.subscribe('tick', () => console.log('c'));
b.release();
bus.emit('tick'); // logs: a then c
Double release is a safe no-op:
const sub = bus.subscribe('x', () => {});
sub.release();
sub.release(); // does NOT throw, does NOT remove anything else
subscribe with the same function twice creates two independent handles; releasing one leaves the other firing.emit on an event with no subscribers is a silent no-op, not an error.release() more than once is safe and must not affect other subscribers.emit forwards args verbatim — whatever you pass after the event name reaches each callback as positional arguments.emit returns. Don't worry about async fan-out, once, or wildcards.You will build a small pub/sub class whose subscribe method returns a handle — an object with a release() method that removes that one subscription, and only that one, no matter how many other subscribers share the same callback.
Imagine a user logs in, and three modules want to react: a header that swaps "Sign in" for the user's name, an analytics module that pings the server, and a sync module that pulls down data. Each module subscribes by handing over a function. Later, when the user logs out, each module needs to clean up its own subscription — without disturbing the others, and without having to remember the exact function it passed in (which is impossible if it passed an inline arrow). That clean-up token is the handle this question asks you to build.
The standard on/off pattern (see Event Emitter) tries to identify subscriptions by the callback's object identity. That fails the moment two subscribers share a callback, or a subscriber passes an arrow function inline. The handle pattern fixes both by giving each subscription its own unique identity from birth.
Inside the emitter, every event name maps to an array of wrapper objects. Each wrapper holds a callback and an alive flag. subscribe creates a fresh wrapper, pushes it into the array, and hands back a handle whose release() flips that one wrapper's alive to false. emit snapshots the array, walks it in order, and calls each wrapper whose alive is still true.
The handle is just a closure that captured one wrapper.
The obvious first try: skip the wrapper and let release just delete the callback from a per-event table.
class EventEmitter {
constructor() {
this.listeners = {};
}
subscribe(event, cb) {
if (!this.listeners[event]) this.listeners[event] = new Set();
this.listeners[event].add(cb);
return {
release: () => {
this.listeners[event].delete(cb);
},
};
}
emit(event, ...args) {
const set = this.listeners[event];
if (!set) return;
for (const cb of set) cb(...args);
}
}
It looks fine, and it passes most of the spec. The bug hides in one specific case: two subscriptions with the same callback.
const log = (x) => console.log(x);
const a = bus.subscribe('e', log);
const b = bus.subscribe('e', log);
a.release(); // should leave b alive
bus.emit('e', 'hi'); // expected: logs 'hi' once
// actual: logs nothing
The Set only stores log once. a.release() deletes log — and b is now a handle to nothing. Even switching to an array doesn't save you: array.filter((c) => c !== log) strips both copies, and findIndex plus splice removes whichever comes first, neither of which is what the caller meant. The callback is not a unique identifier. We need one.
Each subscription gets its own wrapper. The handle closes over the wrapper. release flips a flag. emit skips dead wrappers.
class EventEmitter {
constructor() {
// Map (not plain object) so user-supplied event names like 'toString'
// can't collide with prototype keys.
this.events = new Map();
}
subscribe(eventName, cb) {
let arr = this.events.get(eventName);
if (!arr) {
// Lazy init — events with zero subscribers cost zero memory.
arr = [];
this.events.set(eventName, arr);
}
// Each subscription gets its own wrapper object. Object identity is
// what makes release() unambiguous — even when two subscribers share
// the same callback, their wrappers are distinct.
const wrapper = { cb, alive: true };
arr.push(wrapper);
// The handle closes over this one wrapper. release() flips a flag
// rather than splicing the array because (a) it's O(1) and (b) it's
// safe to call during an in-flight emit — the emit snapshot will
// see alive=false and skip.
return {
release: () => {
wrapper.alive = false;
},
};
}
emit(eventName, ...args) {
const arr = this.events.get(eventName);
if (!arr) return; // no subscribers — silent
// Snapshot before iterating. A subscriber may call subscribe() (which
// pushes onto arr) or release() (which flips a flag) during this loop.
// The spec says new subscribers added during emit do NOT fire for the
// in-flight emit, and released subscribers do NOT fire if they haven't
// already. Both are achieved by iterating a copy and checking alive
// on each step.
const snapshot = arr.slice();
for (const w of snapshot) {
if (w.alive) w.cb(...args);
}
}
}
module.exports = { EventEmitter };
Three shifts from the naive version. Wrapper objects, not callbacks, give every subscription a unique identity even when callbacks are shared. alive flag, not array removal, means release is O(1) and safe to call mid-emit. Snapshot before iterating locks in the "who fires for this emit" decision before any subscriber runs, so reentrant subscribe and release calls behave predictably.
Two design choices worth calling out explicitly. First, emit rethrows when a callback throws — this matches Node's EventEmitter and surfaces bugs in subscribers rather than swallowing them. If you prefer swallow-and-continue, wrap each call in a try/catch; the tests pin the rethrow behavior. Second, a released-but-not-yet-fired subscriber does NOT fire because the loop re-checks alive on each step. This is the safer default — if you released it, you wanted it gone.
Trace three subscribes, one release, and one emit:
const bus = new EventEmitter();
const a = bus.subscribe('tick', () => console.log('a'));
const b = bus.subscribe('tick', () => console.log('b'));
const c = bus.subscribe('tick', () => console.log('c'));
b.release();
bus.emit('tick');
Step by step:
new EventEmitter() — events is an empty Map.bus.subscribe('tick', fnA) — events.get('tick') is undefined, so we create arr = [], store it in the Map, push {cb: fnA, alive: true}. The Map now has 'tick' -> [w0]. We return a handle whose release will flip w0.alive.bus.subscribe('tick', fnB) — Map has 'tick'; push {cb: fnB, alive: true}. Array is [w0, w1]. Return a handle pointing at w1.bus.subscribe('tick', fnC) — Push {cb: fnC, alive: true}. Array is [w0, w1, w2]. Return a handle pointing at w2.b.release() — The handle's closure flips w1.alive to false. The array is still [w0, w1, w2], but w1 is now dead.bus.emit('tick') — Look up 'tick', get [w0, w1, w2]. Snapshot to [w0, w1, w2]. Iterate:
w0.alive is true → call fnA() → logs a.w1.alive is false → skip.w2.alive is true → call fnC() → logs c.Output: a, then c. b never fired, and the order of a before c was preserved because we walked the array in subscription order.
bus.on('e', (x) => x + 1) followed by bus.off('e', (x) => x + 1) removes nothing — those are two different function objects. Handles sidestep this entirely because the handle, not the callback, is the unsubscribe token. Whenever a subscriber doesn't control its callback's identity (inline arrows, partially applied functions, methods bound to this), prefer handles.subscribe during emit the array grows; if it calls release, a flag flips. There is no engine lottery here: ECMA-262 says the iterator walks a live index, so a subscriber appended mid-emit will fire in the round that added it, and every engine agrees. That is exactly what you don't want — whether a listener runs would depend on the order the ones before it happened to subscribe in. arr.slice() makes a one-shot snapshot, so emit delivers to the subscribers that existed when it started — O(n), which is the cost of iteration anyway.wrapper.alive = false is just an idempotent re-assignment. Whatever you do, don't add a "already released" exception — callers will hate you.subscribe call returns a fresh handle, even when the callback is the same. Tests rely on this; the implementation enforces it by allocating a new { release } object each time.once(eventName, cb) — fires the callback exactly once, then auto-releases. Implement by wrapping the user's callback in a wrapper-internal (args) => { handle.release(); cb(...args); } so the release runs before the callback in case the callback throws.bus.subscribe('user.*', cb) fires for user.login, user.logout, etc. Either keep wildcards in a separate list scanned on every emit, or build a small prefix tree if you expect many of them.bus.subscribe(eventName, cb, { priority: 10 }) runs higher-priority subscribers first. Trivial extension: change the array push to an insertion that respects priority, or sort the snapshot inside emit before iterating.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
This is the same pub/sub primitive as Event Emitter, with one change to the API. Instead of on/off, there is a single subscribe(eventName, callback) that returns a handle — a small object with a release() method. Calling release() removes that one subscription. There is no off(eventName, callback). The handle is the only way to unsubscribe.
The handle pattern shows up in RxJS, in DOM AbortController, and in most modern bus libraries because it sidesteps a real footgun in the on/off design: if the caller passes an inline arrow function to on, they no longer hold a reference equal to it and can never call off for it. A handle is unique by construction.
class EventEmitter {
subscribe(eventName, callback)
// returns { release: () => void }
emit(eventName, ...args)
// calls each live subscriber for eventName, in subscription order, with args
}
eventName is a string. callback is a function. release() returns nothing.
Basic subscribe + emit:
const bus = new EventEmitter();
const sub = bus.subscribe('login', (user) => console.log('hi', user));
bus.emit('login', 'ada'); // logs: hi ada
sub.release();
bus.emit('login', 'ada'); // no listeners — silent
Release one of several subscribers:
const bus = new EventEmitter();
const a = bus.subscribe('tick', () => console.log('a'));
const b = bus.subscribe('tick', () => console.log('b'));
const c = bus.subscribe('tick', () => console.log('c'));
b.release();
bus.emit('tick'); // logs: a then c
Double release is a safe no-op:
const sub = bus.subscribe('x', () => {});
sub.release();
sub.release(); // does NOT throw, does NOT remove anything else
subscribe with the same function twice creates two independent handles; releasing one leaves the other firing.emit on an event with no subscribers is a silent no-op, not an error.release() more than once is safe and must not affect other subscribers.emit forwards args verbatim — whatever you pass after the event name reaches each callback as positional arguments.emit returns. Don't worry about async fan-out, once, or wildcards.You will build a small pub/sub class whose subscribe method returns a handle — an object with a release() method that removes that one subscription, and only that one, no matter how many other subscribers share the same callback.
Imagine a user logs in, and three modules want to react: a header that swaps "Sign in" for the user's name, an analytics module that pings the server, and a sync module that pulls down data. Each module subscribes by handing over a function. Later, when the user logs out, each module needs to clean up its own subscription — without disturbing the others, and without having to remember the exact function it passed in (which is impossible if it passed an inline arrow). That clean-up token is the handle this question asks you to build.
The standard on/off pattern (see Event Emitter) tries to identify subscriptions by the callback's object identity. That fails the moment two subscribers share a callback, or a subscriber passes an arrow function inline. The handle pattern fixes both by giving each subscription its own unique identity from birth.
Inside the emitter, every event name maps to an array of wrapper objects. Each wrapper holds a callback and an alive flag. subscribe creates a fresh wrapper, pushes it into the array, and hands back a handle whose release() flips that one wrapper's alive to false. emit snapshots the array, walks it in order, and calls each wrapper whose alive is still true.
The handle is just a closure that captured one wrapper.
The obvious first try: skip the wrapper and let release just delete the callback from a per-event table.
class EventEmitter {
constructor() {
this.listeners = {};
}
subscribe(event, cb) {
if (!this.listeners[event]) this.listeners[event] = new Set();
this.listeners[event].add(cb);
return {
release: () => {
this.listeners[event].delete(cb);
},
};
}
emit(event, ...args) {
const set = this.listeners[event];
if (!set) return;
for (const cb of set) cb(...args);
}
}
It looks fine, and it passes most of the spec. The bug hides in one specific case: two subscriptions with the same callback.
const log = (x) => console.log(x);
const a = bus.subscribe('e', log);
const b = bus.subscribe('e', log);
a.release(); // should leave b alive
bus.emit('e', 'hi'); // expected: logs 'hi' once
// actual: logs nothing
The Set only stores log once. a.release() deletes log — and b is now a handle to nothing. Even switching to an array doesn't save you: array.filter((c) => c !== log) strips both copies, and findIndex plus splice removes whichever comes first, neither of which is what the caller meant. The callback is not a unique identifier. We need one.
Each subscription gets its own wrapper. The handle closes over the wrapper. release flips a flag. emit skips dead wrappers.
class EventEmitter {
constructor() {
// Map (not plain object) so user-supplied event names like 'toString'
// can't collide with prototype keys.
this.events = new Map();
}
subscribe(eventName, cb) {
let arr = this.events.get(eventName);
if (!arr) {
// Lazy init — events with zero subscribers cost zero memory.
arr = [];
this.events.set(eventName, arr);
}
// Each subscription gets its own wrapper object. Object identity is
// what makes release() unambiguous — even when two subscribers share
// the same callback, their wrappers are distinct.
const wrapper = { cb, alive: true };
arr.push(wrapper);
// The handle closes over this one wrapper. release() flips a flag
// rather than splicing the array because (a) it's O(1) and (b) it's
// safe to call during an in-flight emit — the emit snapshot will
// see alive=false and skip.
return {
release: () => {
wrapper.alive = false;
},
};
}
emit(eventName, ...args) {
const arr = this.events.get(eventName);
if (!arr) return; // no subscribers — silent
// Snapshot before iterating. A subscriber may call subscribe() (which
// pushes onto arr) or release() (which flips a flag) during this loop.
// The spec says new subscribers added during emit do NOT fire for the
// in-flight emit, and released subscribers do NOT fire if they haven't
// already. Both are achieved by iterating a copy and checking alive
// on each step.
const snapshot = arr.slice();
for (const w of snapshot) {
if (w.alive) w.cb(...args);
}
}
}
module.exports = { EventEmitter };
Three shifts from the naive version. Wrapper objects, not callbacks, give every subscription a unique identity even when callbacks are shared. alive flag, not array removal, means release is O(1) and safe to call mid-emit. Snapshot before iterating locks in the "who fires for this emit" decision before any subscriber runs, so reentrant subscribe and release calls behave predictably.
Two design choices worth calling out explicitly. First, emit rethrows when a callback throws — this matches Node's EventEmitter and surfaces bugs in subscribers rather than swallowing them. If you prefer swallow-and-continue, wrap each call in a try/catch; the tests pin the rethrow behavior. Second, a released-but-not-yet-fired subscriber does NOT fire because the loop re-checks alive on each step. This is the safer default — if you released it, you wanted it gone.
Trace three subscribes, one release, and one emit:
const bus = new EventEmitter();
const a = bus.subscribe('tick', () => console.log('a'));
const b = bus.subscribe('tick', () => console.log('b'));
const c = bus.subscribe('tick', () => console.log('c'));
b.release();
bus.emit('tick');
Step by step:
new EventEmitter() — events is an empty Map.bus.subscribe('tick', fnA) — events.get('tick') is undefined, so we create arr = [], store it in the Map, push {cb: fnA, alive: true}. The Map now has 'tick' -> [w0]. We return a handle whose release will flip w0.alive.bus.subscribe('tick', fnB) — Map has 'tick'; push {cb: fnB, alive: true}. Array is [w0, w1]. Return a handle pointing at w1.bus.subscribe('tick', fnC) — Push {cb: fnC, alive: true}. Array is [w0, w1, w2]. Return a handle pointing at w2.b.release() — The handle's closure flips w1.alive to false. The array is still [w0, w1, w2], but w1 is now dead.bus.emit('tick') — Look up 'tick', get [w0, w1, w2]. Snapshot to [w0, w1, w2]. Iterate:
w0.alive is true → call fnA() → logs a.w1.alive is false → skip.w2.alive is true → call fnC() → logs c.Output: a, then c. b never fired, and the order of a before c was preserved because we walked the array in subscription order.
bus.on('e', (x) => x + 1) followed by bus.off('e', (x) => x + 1) removes nothing — those are two different function objects. Handles sidestep this entirely because the handle, not the callback, is the unsubscribe token. Whenever a subscriber doesn't control its callback's identity (inline arrows, partially applied functions, methods bound to this), prefer handles.subscribe during emit the array grows; if it calls release, a flag flips. There is no engine lottery here: ECMA-262 says the iterator walks a live index, so a subscriber appended mid-emit will fire in the round that added it, and every engine agrees. That is exactly what you don't want — whether a listener runs would depend on the order the ones before it happened to subscribe in. arr.slice() makes a one-shot snapshot, so emit delivers to the subscribers that existed when it started — O(n), which is the cost of iteration anyway.wrapper.alive = false is just an idempotent re-assignment. Whatever you do, don't add a "already released" exception — callers will hate you.subscribe call returns a fresh handle, even when the callback is the same. Tests rely on this; the implementation enforces it by allocating a new { release } object each time.once(eventName, cb) — fires the callback exactly once, then auto-releases. Implement by wrapping the user's callback in a wrapper-internal (args) => { handle.release(); cb(...args); } so the release runs before the callback in case the callback throws.bus.subscribe('user.*', cb) fires for user.login, user.logout, etc. Either keep wildcards in a separate list scanned on every emit, or build a small prefix tree if you expect many of them.bus.subscribe(eventName, cb, { priority: 10 }) runs higher-priority subscribers first. Trivial extension: change the array push to an insertion that respects priority, or sort the snapshot inside emit before iterating.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.