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.
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.
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.
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.
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; } }
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.
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.
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.
'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.
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.
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.
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 }
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.
| Method | Does | The detail that bites |
|---|---|---|
| on / addListener | append a listener; returns this for chaining | same fn twice = called twice |
| once | fire-once, self-removing | stored as a wrapper — identity gap (§03) |
| emit(name, ...args) | synchronous for-loop, registration order | returns boolean; 'error' throws if unhandled (§05) |
| off / removeListener | remove by identity | removes one instance if duplicated — the rest stay |
| removeAllListeners([name]) | bulk clear | footgun: clears listeners you didn't register |
| prependListener / prependOnceListener | insert at the front | the only way to control "runs first" |
| listeners / rawListeners | introspect | raw exposes once-wrappers (§03) |
| listenerCount / eventNames | counts & channel names | cheap, synchronous reads |
| setMaxListeners / getMaxListeners | per-emitter leak threshold | default 10, not a hard cap (§06) |
| 'newListener' / 'removeListener' | meta-events on (un)registration | 'newListener' fires before the add |
| events.once / events.on | promise / async-iterator bridges | on() buffers unbounded (§08) |
| captureRejections | route a rejecting async listener to 'error' | off by default — plain emit ignores returned promises |
the API table scrolls sideways →
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
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.
| Mechanism | Dispatch | Key delta vs EventEmitter |
|---|---|---|
| EventEmitter (Node) | sync for-loop | spread args; 'error' throws if unhandled; no completion channel |
| EventTarget (Web/Node) | sync, event object | addEventListener; capture/bubble; preventDefault; no error-throw special case |
| DOM events | 3-phase propagation | capture → target → bubble; delegation; stopPropagation |
| Observables (RxJS) | push, composable | explicit error + complete channels, operators; still no real backpressure |
| Signals (Solid/Vue) | pull / tracked | fine-grained dependency tracking, not named channels — a different tool |
| Channels / actors (Go, Erlang) | message passing | often bounded & backpressured; a concurrency primitive, not an in-process callback list |
the comparison table scrolls sideways →
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.
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.