Mission 02 · Node.js Internals Campaign

EventEmitter — a map of arrays, a for loop, and no queue anywhere

Five parts of this series have used one without naming it. An EventEmitter is an object with a map from event name to an array of functions: on() pushes, emit() walks the array and calls each one. emit() is a synchronous function call — it queues nothing and returns only once every listener has finished — and almost everything surprising about emitters is that one fact arriving late.

12 sections17 figuresone for loopon · emit · once · error
§ 0 · THE SHORT ANSWER FIRST

emit() is a for loop over an array

Every part of this series has used one without naming it. server.on("connection") in Part 8, req.on("data") in Part 9, worker.on("message") in Part 10 — all of it is one small class, and the class is much simpler than its reputation.

An EventEmitter is an object with a map from event name to an array of functions. on() pushes onto the array. emit() walks it and calls each function. That is the whole mechanism.

JAVASCRIPTTHE ENTIRE IDEA
class Tiny {
  constructor() {
    this._events = {};
  }

  on(name, fn) {
    if (!this._events[name]) this._events[name] = [];
    this._events[name].push(fn);
    return this;
  }

  emit(name, ...args) {
    const list = this._events[name];
    if (!list) return false;
    for (const fn of list) fn.apply(this, args); // <- synchronous
    return true;
  }
}
A map from name to functions, and a loop
§0 · the whole object
one emitter
_eventsa null-prototype objectone property, holding everything
→name
"tick"a function, or an array of thempromoted to an array on the second on()
→walk
emit("tick")calls each one, in order, on YOUR stackreturns when the last has finished
on() pushesemit() walksnothing is queuednothing is scheduled
The two dashed chips are the whole of what an emitter does NOT do. Everything else in this part follows from the solid two.
a lookup table and a for loop — no queue anywhere in the picture

The line that matters is the loop. emit() is a synchronous function call — it does not queue anything, it does not wait for a tick, and it does not return until every listener has finished. Almost everything people find surprising about emitters is that one fact arriving late.

!Lock this in

An emitter is not a message queue and not a channel. It is a lookup table of callbacks and a for loop, running on the stack you called it from.

§ 1 · THE DATA STRUCTURE ITSELF

One property, optimised for the common case

You have seen _events in Parts 8 and 9 without being told what is in it. It is an ordinary object, and Node keeps it deliberately cheap.

The object changes shape as you call things
§1 · build one and watch it
one listener is the function itself · two is an array · eleven is a warning
What one emitter actually stores
§1 · the same thing as a list
the emitteran ordinary object_eventsa map made with Object.create(null)"tick"fnfnfnfniwalks 0, 1, 2"error"fn"close" — no key here. nothing has listened,so emit("close") finds nothing to walkon("tick", fn)a push onto the arrayemit("tick")a for loop over that array:calls each fn, in the orderit was pushed
The whole mechanism in one drawing. on() is a push; emit() is a for loop riding the array with a cursor; listeners fire in insertion order because that is the order of the array. No queue, no scheduler, no other thread — and a name nobody listened to is a key that was never created.
_events
a null-prototype object
main
Created with Object.create(null), so a listener for an event called "constructor" or "toString" cannot collide with Object's prototype. That choice is why emitters accept arbitrary event names safely.
one listener → the function itself
not an array of one
node
The overwhelmingly common case is a single listener, so Node stores the function directly and only promotes to an array on the second on(). It is a memory optimisation you can see in a heap snapshot.
two or more → an array
in registration order
node
Order is the order you called on(), and it is stable. There is no priority mechanism — if order matters to your code, the coupling is already there whether you admit it or not.
_eventsCount
a plain integer
main
Kept alongside so that checking "does this emitter have any listeners at all" does not require walking the object.
_maxListeners
undefined until you set it
kernel
When unset, the shared default (10) applies. §7 is about what that number does and — more importantly — what it does not do.
a map, a count, and a threshold
JAVASCRIPTLOOK AT IT DIRECTLY
const { EventEmitter } = require("node:events");
const e = new EventEmitter();

e.on("tick", fn1);
console.log(e._events.tick);          // [Function: fn1]     <- the function

e.on("tick", fn2);
console.log(e._events.tick);          // [ [Function: fn1], [Function: fn2] ]
§ 2 · THE SINGLE MOST CONSEQUENTIAL FACT

Listeners run before emit returns

People reach for an emitter expecting a queue: fire an event, carry on, let the listeners run later. That is not what happens, and the difference is visible on the call stack.

How long each frame is on the stack
§2 · the whole call, at a glance
the stack, growing upwardevery frame sits ON the one below it —one stack: there are no lanes hereyour codethe frame that calls emitemit("tick")one for loop, start to finishlistener Ai = 0listener Bi = 1listener Ci = 2returns truethe next lineruns only nowtime →
The same call, drawn honestly: a flame graph. Each listener frame stands on emit's frame, which stands on yours — three towers, one stack. The line after emit() lives at the far right of the bottom bar, after every tower has come down, and "returns true" is nothing more than the loop running out of array.
the caller bar spans every listener — that is what synchronous means here
Where each listener actually runs
§2 · one emit, stepped
the control is the listener that defers — the only one that behaves like a queue

Two consequences follow immediately. A slow listener blocks the emitter's caller, because it is the caller's stack it is running on — this is the same thread from Parts 4 and 10, and nothing about an event name changes that. And a listener that runs after emit() returns is one you deferred yourself.

!Lock this in

If you want the listeners to run later, YOU have to defer — with setImmediate, queueMicrotask or a promise. The emitter will never do it for you.

§ 3 · WHAT HAPPENS IF THE ARRAY CHANGES WHILE YOU ARE WALKING IT

The in-flight emit is working from a copy

A listener can call on() or removeListener() on the same event it is handling. Since emit is a loop over an array, the obvious question is what that does to the loop.

Which listeners fire on THIS emit?
§3 · mutation during emit
the control is removal — the one case where the answer is not "the copy"

Node copies the listener array before walking it when there is more than one, so additions never join the emit in progress. Removal is the case worth knowing: once() is implemented by removing its wrapper before invoking your function, which is exactly why a once listener cannot fire twice even if the same event is emitted from inside it.

§ 4 · THERE IS NO SPECIAL CASE IN EMIT

A function that removes itself, then calls yours

once() looks like it needs support inside emit. It does not — it is an ordinary listener that happens to unregister itself first.

JAVASCRIPTROUGHLY WHAT once() REGISTERS
function onceWrapper(...args) {
  this.removeListener(name, onceWrapper);   // FIRST: unregister
  fn.apply(this, args);                     // THEN: run your function
}
onceWrapper.listener = fn;                  // so removeListener(name, fn) works

emitter.on(name, onceWrapper);
A wrapper around your function
§4 · what once() actually registers
what on() storedonceWrapperyour fn, inside itthe array cell holds the wrapper,NOT your fn — which is whyremoveListener needs .listenerfirstemitduring that emitonceWrapperyour fn1 · removes ITSELFremoveListener(wrapper), first2 · then calls your fnso a re-emit inside your fncannot fire it a second timefromnow onevery later emit[ ]the array is empty — nothingfires, and emit returns false
once() never stores your function — it stores a wrapper HOLDING your function, and the wrapper's first act on firing is to remove itself, before your fn runs. That order is the whole trick: even if your fn emits the same event again, the wrapper is already gone.
the array holdsonceWrapper — not your functionwhich is why removeListener needs .listener
→emit calls it
onceWrapper runs
1 · removeListenerthe wrapper takes ITSELF outBEFORE your code runs
→then
2 · your functionfinally invokedand the array no longer holds the wrapper
Because step 1 happens before step 2, re-emitting the same event from inside your handler cannot re-enter it: the copy that nested emit takes no longer contains the wrapper.
no special case in emit — just a listener that unregisters itself first

The ordering is the interesting part. Removal happens before your function runs, so re-emitting the same event from inside a once handler does not re-enter it. And the .listener property is why removeListener(name, yourFunction) can find a wrapper it never handed you.

§ 5 · THE ONE EVENT NAME WITH BEHAVIOUR ATTACHED

No listener means throw

"error" is the only event name emit treats differently, and it is treated differently in the harshest possible way.

What happens, by whether anyone is listening
§5 · emitting error
emit("error", err)nothing special yetis anything under "error"?yesthe ordinary loopeach listener is called with err —nothing throws, emit returns truenoerr is THROWNsynchronously, on this very stack —it unwinds through emit into the callerlisteners under any OTHER name do not count
One fork, decided by a key lookup. With a listener, "error" is an ordinary event. Without one, emit throws the error object itself on the same stack that called emit — which is why try/catch helps only if the emit happens inside it, and why an idle socket's error can end the process.
the control is the unhandled case — and it is not an exception you can catch

This is why every stream and socket example ends with an .on("error") handler. It is not defensive style; without it, a failure that Node intended to hand you becomes an uncaught exception that terminates the process by default.

!Lock this in

An emitter with no error listener converts a handled failure into a crash. The throw happens inside emit, on your stack, at the moment of failure.

§ 6 · WHAT THE LOOP DOES WHEN A LISTENER FAILS

The rest of the listeners never run

Because emit is a plain loop with no try/catch around each call, a listener that throws takes the loop with it.

JAVASCRIPTTHE THIRD LISTENER NEVER RUNS
e.on("tick", () => console.log("A"));
e.on("tick", () => { throw new Error("boom"); });
e.on("tick", () => console.log("C"));

e.emit("tick");
// A
// Error: boom          <- thrown out of emit(), on the caller's stack
//                         "C" is never printed
One listener throws; the rest are skipped
§6 · where the loop stops
ithe loop stops herelistener Ai = 0 · ranlistener Bi = 1 · THREWlistener Cnever calledno try/catch around each call —one bad listener takes the restof this walk with it. C is stillregistered for the NEXT emitthe same moment, as the stack:your codeemit("tick")listener Bthe throw unwinds
The loop has no safety net. B's throw ends the walk — C never runs — and the exception unwinds through emit's own frame into whatever called it. The listeners after the bad one miss THIS emit only: they are still in the array for the next one.
A — ranB — threwC — never reached
the loopno try/catch around each callso it cannot resume at C
→throw leaves emit
emit's calleroften deep inside Nodea socket read, a parser callback
→and up
nothing catchesuncaught exceptionthe same ending as §5
The dashed chip is the part people miss: C is not delayed or retried. It is skipped, so a partially-applied side effect is the NORMAL outcome of a throwing listener rather than an unusual one.
the array is walked until something throws — then it is abandoned

Two things follow. The throw propagates out of emit() to whoever called it, which may be deep inside Node — a socket's internal read, for instance — where there is nothing sensible to catch it. And listeners registered after the failing one are silently skipped, which makes a partially-applied side effect the normal outcome rather than an unusual one.

§ 7 · EMITTERS IN A RUNNING APPLICATION

The request cycle, and four others

Every asynchronous API you have used in this series is an emitter, and each one fires a sequence rather than a single event. Knowing the sequence is most of knowing the API — including which events you are obliged to handle.

Step through what actually fires
§7 · event sequences
the control is the aborted request — where the sequence you expected simply stops

Two patterns are worth naming across all of them. Data events repeat and terminal events do not: data fires once per chunk the kernel gave you, so its count is a property of the network rather than of your code. And every one of these sequences has a failure branch that replaces the rest of it, which is why an error handler is not optional decoration.

!Lock this in

An emitter API is a state machine you drive by listening. The events are the transitions, and the ones you do not listen for still happen.

§ 8 · WHERE THESE EVENTS COME FROM

Four callers, and none of them is a scheduler

It is worth being concrete about what is on the stack when your listener runs, because "the framework calls it" hides the mechanism this whole part is about.

What is on the stack underneath your listener
§8 · four callers, one loop
a libuv callbackpoll phase · data, closethe HTTP parserat a message boundaryyour own codejust a method callthe runtimebeforeExit · exitemit()the same for loopnone of them is a scheduler — every emit is an ordinary, synchronous call
Every emit in a running app is one of these four frames calling a function, and your listener runs on top of whichever it was. "Who calls emit" and "what is underneath my listener on the stack" are the same question — and the answer is never "a scheduler".
a libuv callbackpoll phaseconnection · data · close
the HTTP parserat a boundaryrequest · data · end
your own coderes.end(), or your emitterfinish · custom
the runtimeon the way outbeforeExit · exit
→all of them call
emit(name, …)the same for loop, every timeyour listener runs before the caller continues
None of these is a scheduler. Whatever is above your listener on the stack is one of these four, and it is waiting for you to return.
four callers · one for loop · always your thread
What is underneath each emit
§8 · the same thing as a list
a libuv callback
connection · data · close
kernel
The loop's poll phase found a readable descriptor, libuv called into C++, and C++ called emit. Parts 8 and 4: your listener is running inside the poll phase of a loop turn.
the HTTP parser
request · data · end · aborted
node
llhttp consumed bytes and hit a boundary — headers complete, a body chunk, the end of a message — and each boundary is an emit. Part 9 shows the same 66 bytes producing different numbers of these.
your own code
finish · custom events
main
res.end() eventually emits finish. An emitter you wrote yourself emits when you call emit. There is no difference in mechanism between Node's emitters and yours.
the runtime, on the way out
beforeExit · exit
worker
process is an EventEmitter. exit is emitted after the loop has stopped, which is why an exit handler cannot do anything asynchronous — there is no loop left to run it.
four callers · one for loop · always your thread

None of these is a queue or a scheduler. In every case something called emit as an ordinary function, and your listener ran to completion before that caller continued — which is the same sentence as §2, arriving from the direction of a real application.

§ 9 · WHAT THE NUMBER 10 ACTUALLY IS

A heuristic about your code, not a limit

Add an eleventh listener for one event on one emitter and Node prints a warning. It is worth being precise about what that warning is and is not.

Where the warning lands, and what it does not do
§9 · one array, growing
1234567891011 — warns, and is added
what happensa line on stderrand nothing else
what does NOT happenno rejection · no throw · no capthe eleventh fires exactly like the first
what it is guessingan array growing without boundwhich is the shape a leak takes
The struck chip is still in the array. That is the whole point: the threshold is a smoke alarm, not a fuse.
per event name, per emitter — ten listeners on one socket, not ten thousand sockets
What the threshold does
§9 · the same thing as a list
it is a WARNING, not a limit
the listener is still added
node
Nothing is rejected and nothing stops working. Node writes to stderr and continues — the eleventh listener fires exactly like the first ten.
per event name, per emitter
not per process
main
Ten "data" listeners on one socket warns; one listener on each of ten thousand sockets does not. The threshold is about a single array growing, because that is the shape a leak takes.
it is a guess about a bug
usually a right one
kernel
Listener arrays that grow without bound are almost always an on() inside a handler that runs repeatedly — a subscribe in a request handler, or a reconnect that never unsubscribes.
raising it hides the signal
setMaxListeners(0) removes it entirely
worker
Legitimate reasons to raise it exist, and a genuine leak looks identical from the outside. If you raise it, raise it on the ONE emitter that needs it, not process-wide.
a smoke alarm, not a fuse
§ 10 · THE MODELS WORTH CORRECTING

Ten claims about emitters

Nearly all of these are the same misconception wearing different clothes: that an emitter is asynchronous.

Which of these are true?
§10 · mental models
sort before you check — a guess you have committed to is worth more than a correction you read
!Lock this in

If you assume emit is a plain function call that runs its listeners immediately, in order, on your stack, you will predict this API correctly every time.

§ 11 · THE FOUR SENTENCES WORTH KEEPING

If you remember nothing else

An emitter is a map of arrays and a for loop.

What to carry away
§11 · the four sentences
emit is synchronouslisteners run before it returns§2
order is registration orderand the walk holds a COPY§1 · §3
error with no listener throwsthe one name with behaviour§5
a throwing listener skips the restno try/catch in the loop§6
Four sentences, one for loop. If you assume emit is a plain function call that runs its listeners immediately, in order, on your stack, you will predict this API correctly every time.
four sentences, one for loop
What to carry away
§11 · the same thing as a list
emit is synchronous
§2
main
Listeners run before emit() returns, on the caller's stack. If you want them later, you defer them yourself.
order is registration order
§1 · §3
node
And an in-flight emit walks a copy, so listeners added during it join the NEXT emit rather than this one.
error with no listener throws
§5
kernel
The one event name with behaviour attached, and the reason every stream example ends with an error handler.
a throwing listener skips the rest
§6
worker
There is no try/catch inside the loop, so the throw leaves emit and the remaining listeners never run.
four sentences, one for loop

Part 7 builds directly on this one: a stream is an EventEmitter with a buffer and a rule about when to stop writing.

Back to JIT-Atlas