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.
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;
}
}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.
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.
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.
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.on(). It is a memory optimisation you can see in a heap snapshot.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.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] ]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.
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.
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.
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.
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.
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.
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);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.
No listener means throw
"error" is the only event name emit treats differently, and it is treated differently in the harshest possible way.
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.
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.
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.
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 printedTwo 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.
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.
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.
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.
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.
emit. Parts 8 and 4: your listener is running inside the poll phase of a loop turn.emit. Part 9 shows the same 66 bytes producing different numbers of these.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.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.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.
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.
"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.on() inside a handler that runs repeatedly — a subscribe in a request handler, or a reconnect that never unsubscribes.Ten claims about emitters
Nearly all of these are the same misconception wearing different clothes: that an emitter is asynchronous.
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.
If you remember nothing else
An emitter is a map of arrays and a for loop.
emit() returns, on the caller's stack. If you want them later, you defer them yourself.emit and the remaining listeners never run.Part 7 builds directly on this one: a stream is an EventEmitter with a buffer and a rule about when to stop writing.