emit↻listeners
Vol. III · the synchronous for-loop
contents

00 Explorable explanation · EventEmitter from first principles

Why emit is a synchronous for-loop over an array of callbacks — and not the event loop at all.

Almost every confusion about EventEmitter dissolves the moment you internalize one fact: emit('data', x) walks an array of listener functions and calls each one, in order, on the current call stack, before it returns. It is not asynchronous. It does not touch libuv. It is a for loop. Press play to watch a single emit run to completion while the event loop sits untouched — then notice that the line after emit only runs once every listener has finished.

▶ one emit(), stepped · the array · the stack · the loop
00 / 09
FRAME 00 · —

Press play to run one emit() to completion

Chips are the listeners registered on the 'data' channel; the middle column is the JS call stack; the blue ring is the event loop. Watch the connector: the chip the loop reaches becomes the frame on top of the stack.

how to read the colour — in the figures AND in the prose, one colour answers one question: which machine owns this? the emitterthe channel map, and the for-loop that walks it your listenersthe callbacks, and registering or removing them the call stacksynchronous execution — always, in this document libuv & the kernelthe asynchronous machine underneath the failure paththe 'error' channel, throws, leaks dotted underlinean idea rather than a machine — hover for the definition
the one-line spine emit is a synchronous for-loop over an array of callbacks → everything else (once, off, ordering, the 'error' special case, leak warnings, the lack of backpressure) follows from that one fact → which is exactly why streams have to bolt buffering and flow-control on top.

01 The core problem · one-to-many decoupling

A piece of code knows something happened — but not who cares.

A socket receives bytes. A child process exits. A timer fires. The code that detects the occurrence should not have to know the list of things that want to react to it — that list changes over the program's life, and hard-wiring it couples producer to consumer forever. The EventEmitter is one concrete answer: the producer announces on a named channel, and any number of consumers subscribe and unsubscribe freely.

This is the classic Observer pattern / publish–subscribe, and it rests on inversion of control: a listener hands over a function and surrenders control of when it runs. It is fundamentally a push model — the emitter shoves notifications at listeners — which is the opposite of the pull model you meet with async iterators. Three forces shape the whole design. Tap each.

the unifying question Throughout this volume the question is always the same: when this channel fires, which functions run, in what order, on whose stack — and what happens to the ones nobody registered for? Hold that and the rest is derivable.

02 Build it yourself · the whole thing is ~30 lines

A Map of names to arrays of functions. That's the entire idea.

This is the centerpiece. Once you've built it, Node's full API is just named conveniences over what you wrote. The core is a Map<eventName, Function[]>: on pushes a function onto the array for a name; emit loops that array calling each. Below is a live emitter driving exactly that data structure. Click on a few times, then emit and watch the for-loop sweep the array left to right. Add a once listener and watch it remove itself after firing. Everything you see is mirrored in the internal-state panel.

⊞ live emitter · watch the internal Map mutate

Here is the implementation the widget is running. It is short on purpose — read it next to the panel above and the mapping is exact.

class Tiny {
  events = new Map();                 // name → [fn, fn, …]

  on(name, fn) {
    const a = this.events.get(name) ?? [];
    a.push(fn); this.events.set(name, a);
    return this;                          // chainable, like Node
  }
  once(name, fn) {
    const w = (...args) => {              // the wrapper ≠ fn
      this.off(name, w); fn(...args);     // remove self, then call
    };
    w.listener = fn;                       // remember the original
    return this.on(name, w);
  }
  off(name, fn) {
    const a = this.events.get(name); if (!a) return this;
    const i = a.findIndex(f => f === fn || f.listener === fn);
    if (i !== -1) a.splice(i, 1);
    return this;
  }
  emit(name, ...args) {
    const a = this.events.get(name);
    if (!a || a.length === 0) return false;   // emit's boolean
    for (const fn of a.slice()) fn.call(this, ...args); // snapshot!
    return true;
  }
}
three details that are not accidents (1) emit returns a boolean — trivially "did the array have length." (2) once stores a wrapper, not your function, which creates an identity gap we exploit in §03. (3) emit iterates a.slice() — a snapshot — so a listener that adds or removes listeners mid-emit can't corrupt the loop (§04). Node's real lib/events.js does all three, plus a micro-optimization: a lone listener is stored as a bare function and only promoted to an array on the second on.

03 once & the identity gap · listeners vs rawListeners

What you registered is not what's stored.

Because once wraps your function in a self-removing closure, the array holds the wrapper, not your function. That gap would bite when you removed a once-listener early — a plain identity scan for yourFn finds nothing, because what sits in the array is the wrapper. Node closes it two ways, so in practice off(name, yourFn) does work: the wrapper is stamped with a .listener property pointing back at your function, and removal matches against either one. rawListeners() is the escape hatch for reaching the wrapper directly. Try removing the once-listener both ways.

⌖ once('data', g) — try to remove it
the rule of thumb listeners(name) returns your original functions (it unwraps once-wrappers via .listener) — good for introspection. rawListeners(name) returns the actual stored functions including wrappers — the one you need if you want to remove a once-listener you no longer hold a reference to. In practice, keep the reference, or use an AbortController signal (§06) and let it do the removal for you.

04 Mutation during dispatch · the snapshot

A listener that edits the listener list — during the loop.

What happens if a listener calls on or off while emit is still iterating? This is reentrancy, and the naive answer — iterate the live array — produces classic bugs: remove the current listener and the index shifts, silently skipping the next one; add a listener and it fires in the same round it was registered. Node sidesteps all of it by copying the array before the loop. The dispatch you started sees a frozen list; mutations land for next time. Run both models side by side: the middle listener removes itself and registers a new one.

⇄ listeners [A, B, C] — B removes itself & adds D, mid-emit
0 / 4
STEP 0 · —

what you can rely on Within a single emit: ordering is registration order, every listener present at the moment emit was called runs exactly once, and any on/off performed by a listener takes effect only on the next emit. This is the same array-clone-per-emit you wrote in §02 with a.slice().

05 The 'error' event · the single biggest footgun

Emit 'error' with no listener and the process throws.

Every other event is harmless to emit with zero listeners — the for-loop just finds an empty array and returns false. 'error' is special-cased: if you emit('error', err) and nobody is listening, the emitter throws the error synchronously, and an unhandled throw on the main path takes the whole process down. Node did this deliberately — a silently dropped error is far more dangerous than a loud crash. Toggle a listener on or off, optionally attach an errorMonitor, and fire an error to see the three outcomes.

⚠ emit('error', new Error('boom'))
the rule, and the subtlety Always attach an 'error' listener to any long-lived emitter (sockets, streams, child processes). errorMonitor is for observing errors for logging/metrics without counting as a handler — so it does not suppress the throw. A thrown error inside a normal listener is a different thing entirely (§ below): it propagates synchronously and aborts the remaining listeners.

Thrown exceptions inside an ordinary listener

Distinct from the 'error' channel: if listener #2 of three throws, the exception unwinds straight up through emit (because emit is a plain for-loop on your stack), so listener #3 never runs and the throw surfaces wherever emit was called. Wrap emit in a try/catch and you catch it — proof that dispatch is synchronous.

try {
  ee.emit('tick');     // listener #2 throws…
} catch (e) {
  // …caught HERE, synchronously. listener #3 was skipped.
}

06 Memory leaks · the maxListeners warning

The #1 practical bug: listeners added and never removed.

EventEmitter has no garbage collection of subscriptions — a listener lives until you remove it. On a long-lived emitter (process, a socket, a global bus) it is fatally easy to add a listener per request and never take it off; the array grows without bound and so does the memory each closure captures. Node can't know your intent, so it ships a heuristic: once a single channel passes 10 listeners it prints a MaxListenersExceededWarning — not a hard limit, just a nudge that you probably forgot to clean up. Add listeners past the threshold and watch it fire; then mitigate.

⚑ one channel · the leak detector
the modern cleanup story Three mitigations, in order of preference: once for genuinely one-shot listeners (self-removing); the { signal } option wired to an AbortController, so one abort() removes a whole batch; and disciplined off pairing for everything else. Raise setMaxListeners only when you legitimately expect more than ten — never to silence a warning you haven't diagnosed.

07 EventEmitter ≠ the event loop · clearing the big confusion

Two orthogonal machines that meet at exactly one point.

The deepest, most common misconception is that emit "schedules" listeners on the event loop. It does not. libuv's event loop is the OS-facing I/O scheduler; emit is a synchronous for-loop. They are unrelated — until they meet at one seam: when I/O completes, the kernel marks the descriptor ready, libuv's poll phase collects it and runs the JS callback registered for it, and that callback calls emitter.emit('data', chunk). Step through the seven frames below and watch which machine owns the thread at each one. The asynchrony lives entirely in when the callback fires; once it fires, emit and all its listeners run to completion inside that single tick before the loop moves on.

⇌ one I/O completion, stepped · where the loop hands off to emit
0 / 6
STEP 0 · —

the OS-internals tie-in process is itself an EventEmitter, and some of its events bridge the kernel straight into your code: 'SIGINT', 'SIGTERM' are POSIX signals the kernel delivers; Node catches them and surfaces them as ordinary emitter events you can listen for. 'exit', 'beforeExit', 'uncaughtException', and 'unhandledRejection' are the runtime's own lifecycle, exposed through the same synchronous-dispatch machinery.

08 Bridging to promises & async iteration · the buffering caveat

Two adapters — and the one that quietly grows unbounded.

Push-based events and pull-based async/await don't compose directly, so node:events ships two bridges. events.once(ee, name) returns a promise that resolves on the next emission of name (and rejects on 'error'), with AbortSignal support — perfect for "await this one event." events.on(ee, name) returns an async iterator you consume with for await — and here is the trap: events that arrive while you are not currently awaiting are buffered, and by default that queue has no ceiling. Current Node does accept highWaterMark and lowWaterMark options here, but they are only honoured on emitters that implement pause() and resume() — and a plain EventEmitter implements neither. So against a plain emitter there is still no backpressure to be had, and a fast producer against a slow loop body grows memory without limit.

≈ events.on() · producer → unbounded buffer → for-await body
import { once, on } from 'node:events';

// one event, as a promise — resolves once, then you're done
const [code] = await once(child, 'exit');

// a stream of events — but buffers without limit between awaits
for await (const [chunk] of on(socket, 'data')) {
  await slowProcess(chunk);   // ← producer keeps filling the queue meanwhile
}
the explicit handoff to streams This is precisely why you cannot safely model a fast byte source as a raw EventEmitter: it has no flow control. Streams exist to add the missing pieces — an internal buffer with a highWaterMark, a write() that returns false, and a 'drain' event — turning unbounded push into bounded, backpressured flow. EventEmitter is the substrate; streams are the discipline layered on top.

09 Node's actual API · everything maps onto §02

Fifteen-odd methods, all named conveniences over a Map of arrays.

With the primitive built, the real surface is unsurprising. Every row below is something you could add to the 30-line Tiny class in an afternoon. Hover the cross-referenced terms; the genuinely subtle ones got their own sections above.

MethodDoesThe detail that bites
on / addListenerappend a listener; returns this for chainingsame fn twice = called twice
oncefire-once, self-removingstored as a wrapper — identity gap (§03)
emit(name, ...args)synchronous for-loop, registration orderreturns boolean; 'error' throws if unhandled (§05)
off / removeListenerremove by identityremoves one instance if duplicated — the rest stay
removeAllListeners([name])bulk clearfootgun: clears listeners you didn't register
prependListener / prependOnceListenerinsert at the frontthe only way to control "runs first"
listeners / rawListenersintrospectraw exposes once-wrappers (§03)
listenerCount / eventNamescounts & channel namescheap, synchronous reads
setMaxListeners / getMaxListenersper-emitter leak thresholddefault 10, not a hard cap (§06)
'newListener' / 'removeListener'meta-events on (un)registration'newListener' fires before the add
events.once / events.onpromise / async-iterator bridgeson() buffers unbounded (§08)
captureRejectionsroute a rejecting async listener to 'error'off by default — plain emit ignores returned promises

the API table scrolls sideways →

two more worth knowing exist events.errorMonitor (the observe-without-handling symbol, §05) and EventEmitterAsyncResource, which preserves async-context across listener invocations so AsyncLocalStorage survives the dispatch. You will reach for the latter rarely, but when you need request context inside a listener it is the only correct tool.

10 Patterns & TypeScript · using it well

Streams are emitters. Typed emitters are a one-liner. Cleanup is a discipline.

Almost every I/O object in core is an EventEmitter — net.Socket, http.Server, child_process, fs.watch, worker_threads — so the semantics above are not academic; they govern real production code. The remaining questions are practical: how to type the event→payload map, when to inherit vs compose, and how to avoid the leaks and spaghetti that events invite. Tap each pattern.

// Typed events in TS via a payload map + generic EventEmitter
type Events = {
  data: [chunk: Buffer];
  done: [code: number];
  error: [err: Error];
};
class Job extends EventEmitter<Events> {}   // Node ≥ recent: native generic

const j = new Job();
j.on('data', (chunk) => chunk.length);   // chunk: Buffer, fully inferred
j.emit('done', 0);                       // wrong payload type → compile error
the anti-patterns to name out loud Event soup: when control flow only exists as a web of emits, no one can trace it — prefer a promise or a direct call when there is exactly one consumer. The missed-first-event bug: emitting from inside a constructor, before any caller has had the chance to attach a listener, so the very first event vanishes (remember §01 — no buffering, no replay). Hidden control flow: the general cost of events is that the call graph becomes implicit; weigh that against the decoupling you actually need.

11 Comparative · the wider decoupling landscape

The same push idea, with different ergonomics and guarantees.

EventEmitter is Node's house style, but it is one point in a large design space. The browser's EventTarget is the most direct cousin; Node even ships a Web-compatible EventTarget for cross-platform code. Further out sit Observables, reactive signals, and channel/actor models — each making a different trade between composition, completion semantics, and flow control.

MechanismDispatchKey delta vs EventEmitter
EventEmitter (Node)sync for-loopspread args; 'error' throws if unhandled; no completion channel
EventTarget (Web/Node)sync, event objectaddEventListener; capture/bubble; preventDefault; no error-throw special case
DOM events3-phase propagationcapture → target → bubble; delegation; stopPropagation
Observables (RxJS)push, composableexplicit error + complete channels, operators; still no real backpressure
Signals (Solid/Vue)pull / trackedfine-grained dependency tracking, not named channels — a different tool
Channels / actors (Go, Erlang)message passingoften bounded & backpressured; a concurrency primitive, not an in-process callback list

the comparison table scrolls sideways →

read the source The single best capstone is to read Node's lib/events.js — it is one of the most approachable files in the runtime. You will recognize all of it: the single-listener optimization, the array-copy-on-emit, the once wrapper with its .listener back-reference, the maxListeners warning, the rejection-capture path. Everything you built in §02, validated.

12 The one sentence that regenerates the list

Every behavior was one decision, made once.

the thesis emit is a synchronous for-loop over an array of callbacks — and once, off, ordering, the snapshot, the 'error' throw, the leak warning, and the unbounded async-iterator buffer are all just consequences of that one fact — which is exactly why streams must layer buffering and backpressure on top to make a fast byte source safe.

Hold the for-loop in your head and the rest is derivable. The API is conveniences; the gotchas are corollaries; the event loop is a separate machine that merely decides when your emit gets called. That is the whole of it.

Back to JIT-Atlas