Topics
v6.0.0
On this page · sections

JavaScript EventEmitter

Can you notify listeners you will never import — without skipping one of them when someone unsubscribes mid-emit?

Level: Hard · Coding Round · Design Patterns

Why This Question Matters

  • Most frontend systems need decoupled events: analytics, sockets, plugins, auth → UI.
  • This question checks whether you can design a small API with a real subscribe/unsubscribe lifecycle.
  • Snapshot-on-emit is the production bug: mutate-while-notify skips listeners or leaks them.
  • Companies ask it because the same shape is Redux, Node EventEmitter, and browser events.

Problem

“Write an EventEmitter: on, off, emit, and once. Listeners are identified by function reference.”

class EventEmitter { on(event, fn) {} off(event, fn) {} emit(event, ...args) {} once(event, fn) {} }

on / off

  • on registers fn for event. The same function may be registered more than once.
  • off removes by ===. Unknown event or missing fn is a no-op.
  • After once(event, fn), off(event, fn) must still work — the caller never sees your wrapper.

emit / once

  • emit calls current listeners with ...args. Return true if any ran, else false.
  • once runs at most once, then unregisters. Unregister before calling fn so a nested emit cannot fire it again.
  • If a listener offs another listener during emit, do not skip anyone who was already registered for this cycle.

Clarify before you code

  • Event names are strings.
  • on / once should be O(1). emit is O(n). off may scan the list.

Example

const bus = new EventEmitter(); const log = (value) => { console.log(value); }; bus.on("message", log); bus.emit("message", 1); // 1 bus.off("message", log); bus.emit("message", 2); // no output

once:

bus.once("login", () => { console.log("Logged in"); }); bus.emit("login"); // Logged in bus.emit("login"); // no output

Mental Model

Publisher ↓ event name ↓ [ fn1, fn2, fn3 ]

The publisher knows the event, not the consumers.

Map { "message" → [fn1, fn2] "login" → [fn3] }

off needs the same function object you passed to on.

Solution

JavaScript Map

Store listeners per event:

event → listeners[]

on pushes. off filters by === (and wrapper.listener for once). emit iterates a copy.

To move a once listener off the list before it runs:

this.off(event, wrapper); fn.apply(this, args);

Implementation

class EventEmitter { constructor() { this.events = new Map(); } on(event, fn) { const listeners = this.events.get(event) ?? []; listeners.push(fn); this.events.set(event, listeners); return this; } off(event, fn) { const listeners = this.events.get(event); if (!listeners) { return this; } const next = listeners.filter( (listener) => listener !== fn && listener.listener !== fn ); if (next.length === 0) { this.events.delete(event); } else { this.events.set(event, next); } return this; } once(event, fn) { const wrapper = (...args) => { this.off(event, wrapper); fn.apply(this, args); }; wrapper.listener = fn; return this.on(event, wrapper); } emit(event, ...args) { const listeners = this.events.get(event); if (!listeners?.length) { return false; } for (const listener of listeners.slice()) { listener.apply(this, args); } return true; } }

Why This Works

There are three details interviewers probe.

on / off identity

This works:

const handler = () => {}; bus.on("message", handler); bus.off("message", handler);

This does not:

bus.on("message", () => {}); bus.off("message", () => {});
(() => {}) === (() => {}); // false

once wrapper

emit ↓ wrapper ↓ off(wrapper) ↓ original fn

Unregister before calling fn so a nested emit cannot fire it again.

wrapper.listener = fn so off(event, fn) still finds the wrapper.

Snapshot on emit

Avoid iterating the live array. A listener may off another listener.

const first = () => bus.off("event", second); const second = () => console.log("second");

listeners.slice() is the list at emit start. Later on/off affect the next emit.

Edge Cases

Empty event

bus.emit("missing"); // false

No throw. Order of other events unchanged.

Empty after off

Delete the Map key when the array is empty so unused event names do not leak.

Duplicate on

An array stores [handler, handler]. emit runs twice. Say this out loud — duplicate subscriptions.

Complexity

For n listeners on that event:

OperationTimeSpace
on()O(1)—
once()O(1)—
off()O(n)—
emit()O(n)—
Structure—O(n)

emit is O(n) because every listener must run.

Use Cases

Application events

Fan out "user:login" without the auth module importing every consumer.

Analytics

Publish "checkout:complete"; tracking subscribes and unsubscribes with the page.

WebSocket fan-out

One socket emit("message", payload) to UI listeners.

Plugins

Host emit("ready"); plugins on("ready", init).

Component lifecycle

on in mount, off in unmount — same function reference both times.

Senior Follow-Ups

1. Why snapshot listeners before emitting?

Q. Why listeners.slice() (or [...set])?

Answer: Listeners may on/off during notify. Iterating the live array can skip a listener or pick up a new one mid-cycle. The snapshot is this emit only.

“I snapshot so subscribe/unsubscribe cannot change the list this event is walking.”

2. Why does off() require function identity?

Q. Why can’t I on and off with two inline arrows?

Answer: Each () => {} is a new function object. off compares with ===. Keep a named handler and pass it to both.

“If the API removes by reference, the caller must keep the original function.”

3. How does once() work?

Q. How do you implement once(event, handler)?

Answer: Register a wrapper. It offs itself, then calls handler. Store wrapper.listener = handler so off(event, handler) still works.

“once is a wrapper that unregisters before invoke. I keep a pointer to the original fn for off.”

4. EventEmitter vs PubSub?

Q. Are they different data structures?

Answer: No. Same Map of name → listeners. Different method names and who owns the channel.

EventEmitterPubSub
on / off / emitsubscribe / unsubscribe / publish
Object owns eventsShared broker / topics

“Who owns the channel, and how coupled are publishers and subscribers? The Map is the same.”

5. What happens with duplicate subscriptions?

Q. on("x", handler) twice — one call or two?

Answer: Define the contract. An array allows [handler, handler] → fire twice. A Set stores the reference once. Do not let the data structure decide by accident.

“If duplicates are meaningful, I use an array. If once per function, I use a Set.”

6. How should listener errors be handled?

Q. One listener throws. Do the others still run?

Answer: Two contracts. Propagate: simple, remaining listeners skip. try/catch per listener: isolation, different error story. For a small EventEmitter, propagate unless they ask for a production bus.

“I would define the error contract. Small emitter: let it throw. Production bus: catch and report so one bad subscriber does not kill the rest.”

7. What would you test?

Q. What cases before you say you are done?

Answer:

✓ on + emit delivers args ✓ off by same reference ✓ off with a different arrow is a no-op ✓ emit missing event → false ✓ once fires once ✓ off(original) removes once wrapper ✓ emit snapshot: first offs second, second still runs this cycle ✓ empty event key deleted ✓ duplicate on — whatever contract you chose

Same as PubSub

Do not write a second class. Wrap the emitter if they want subscribe:

function createPubSub() { const bus = new EventEmitter(); return { subscribe(topic, handler) { bus.on(topic, handler); return () => bus.off(topic, handler); }, publish(topic, data) { return bus.emit(topic, data); }, }; }

Returning unsubscribe from subscribe is the UI habit (unmount). Pattern background: Patterns, FP & Architecture.

Senior Engineer Interview Answer

“I’d store listeners in a Map of event → array. on pushes, off filters by identity including once wrappers, emit iterates a snapshot, once wraps then unregisters before calling.”

If they ask for PubSub:

“Same Map. subscribe/publish are on/emit. I’d return unsubscribe from subscribe and not duplicate the algorithm.”

Interview Checklist

✓ Map event → listeners ✓ on registers ✓ off by function identity ✓ once wrapper + original fn ✓ snapshot during emit ✓ duplicate behavior is intentional ✓ empty event cleanup ✓ error contract stated ✓ emit O(n), on O(1)

EventEmitter = named events + identity + snapshot. PubSub is the same Map.

Last updated on