Events & Streams

Lesson 1 of 5

EventEmitter

"Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically." — Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns (1994)

Part II was about results that arrive once. A promise settles, exactly one time, and everything built on it — await, the combinators, cancellation — assumes that single transition. This Part is about results that arrive repeatedly, and about data that is too large to hold at all.

It starts with EventEmitter, because most of Node is made of it. A TCP socket emits data, end, error and close. An HTTP server emits request and connection. A readable stream emits data, readable, end and error. The process itself emits exit, uncaughtException and warning. If you have written Node at all, you have used EventEmitter — the question is whether you have used it deliberately.

The class is about a hundred lines of quite ordinary JavaScript, and the reason to know it precisely is that its behaviour differs from promises in exactly the places you would not think to check. emit is completely synchronous. Listeners run in registration order, on the caller's stack, before emit returns — which is the opposite of the always-asynchronous guarantee the last three chapters relied on. Nothing is queued, nothing is deferred, and a slow listener blocks the emitter.

There is no events module in this playground, so — as with the module loaders — the chapter builds one. The implementation below reproduces the behaviour of real Node's EventEmitter, including the subtle parts, and every claim was checked against it.

The big question

An emitter has three listeners for the same event. The first one removes the second, then emit continues. Does the second listener run?

Yes — on this emit, but not the next one. emit takes a copy of the listener array before iterating it, so removals during dispatch affect subsequent emits and not the one in progress. Measured against real Node: the first emit runs a, b, c even though a removed b; the second runs a, c.

That is the right design, and the reason is worth seeing. Iterating the live array while a listener mutates it would skip the element after the removed one — the classic bug of deleting from an array you are looping over. Copying costs an allocation per emit and makes dispatch predictable regardless of what listeners do. It also means a listener that removes itself still finishes the current dispatch, which is exactly how once is implemented.

A result that arrives more than once

The distinction from a promise is not a detail; it decides which tool the problem needs.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Three properties fall out of that contrast, and each is a reason to pick one or the other.

A promise has memory; an emitter does not. Await a settled promise a year later and you get the value. Attach a listener one millisecond after an event fired and you have missed it permanently. That makes emitters unsuitable for anything where the consumer might arrive late — a "ready" signal that fires before the listener attaches is a classic and maddening startup bug.

A promise has exactly one outcome; an emitter has none in particular. There is no completion, no return value that composes, nothing for await to unwrap. This is why you cannot await an emitter directly, and why §5 is about bridging.

A promise's failure is part of its type; an emitter's is a convention. Rejection is one of the two settled states. An error event is just an event named "error" — with, as §3 shows, one special rule attached to it.

Everyday analogy

A promise is a tracked parcel: it arrives once, and if you were out, the status is still there when you check tomorrow. An emitter is a radio broadcast. It goes out at a particular moment to whoever is tuned in, and if you switch on a second later, that moment is simply gone — not delayed, not queued, gone.

The broadcast analogy also explains why the error event is special. A radio station transmitting an emergency warning that nobody is receiving is a serious failure, not a quiet one — which is why an EventEmitter that emits error with no listener does not shrug and continue. It throws, loudly, on the spot.

Building the emitter

The whole class, with the semantics that matter, in about fifty lines.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Five design decisions in that code, each matching real Node.

emit returns a boolean — whether anything was listening. Rarely useful, occasionally exactly what you need to decide whether to fall back.

once is on plus a self-removing wrapper. It removes itself before invoking the listener, so a listener that throws is still removed and cannot fire twice.

The wrapper carries .listener, so off(event, originalFunction) can find it. Without that, a once listener would be unremovable except by the wrapper you never see.

off removes only the first match. Registering the same function twice creates two listeners and needs two removals — a genuine surprise, and a real source of half-removed handlers.

The listener array is copied before dispatch. The big question's subject, and the reason a listener may safely modify the listener list.

A little history

EventEmitter is one of the oldest things in Node — present from 2009, before promises existed in the language and before async/await was imaginable. It was the answer to a real problem: a callback expresses "tell me when this finishes", and a socket needed to say "tell me every time bytes arrive", which a single callback cannot express.

Its design is the Observer pattern from the 1994 Design Patterns book, almost unmodified, and it arrived in Node through the browser's DOM event model, which is why the vocabulary is so familiar. The notable divergence is that DOM events have propagation — capture and bubble phases through a tree — while Node's emitters are flat. That was a deliberate simplification and a good one; propagation is the part of the DOM model that most reliably confuses people.

What has aged least well is that emitters predate promises and never converged with them. The bridges in the last section — events.once returning a promise, events.on returning an async iterator — arrived in Node 11 and 12, a decade later, and they are genuinely useful. But the two abstractions still describe overlapping ideas in incompatible vocabularies, and a modern Node codebase converts between them constantly. Streams, in the next chapter, sit awkwardly across the same seam.

emit is synchronous — and error is special

This is where instincts trained on promises actively mislead.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Three rules, all of which contradict something you learned in Part II.

Listeners are called synchronously, in order, on the caller's stack. emit is a function call loop. If you need a listener's work deferred, you must defer it yourself.

A slow listener blocks everything. emit returned after 30 ms because the listener spun for 30 ms. This is the Part I blocking problem arriving through a side door: a CPU-heavy listener on a hot event stalls the entire loop, and the stack trace during profiling shows emit, not your handler.

An error event with no listener throws. This is the one genuinely special-cased event name in the class. The reasoning is sound — an unobserved error is worse than a crash — and the consequence is that every long-lived emitter needs an error listener, or one bad socket takes the process down. The error is thrown synchronously from emit, so it surfaces wherever the emit happened, which is usually deep inside library code.

The engineer's notebook

Three questions when working with an emitter.

Is there an error listener? On every emitter that can fail — sockets, streams, child processes. Without one, a single failure throws from inside library code and terminates the process.

Could this listener block? emit is synchronous, so a CPU-heavy listener on a frequent event is a blocked loop that profiles as somebody else's code.

Who removes this listener? If the emitter outlives the listener's purpose, something has to call off. Request-scoped listeners on a process-scoped emitter are the standard leak.

Removal, duplicates, and mutation during dispatch

Every trap in this section produces a program that works and leaks, or works and half-works.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

The three traps have one thing in common: none of them throws.

Anonymous listeners are permanent. off compares function identity, and an inline arrow written twice is two different objects. Any listener you intend to remove must be stored in a variable — which is also why once exists, since it removes itself.

Duplicate registration is allowed and silent. The same function added twice fires twice per emit, and one off leaves one behind. This shows up when a setup function runs more than once — a reconnect handler, a re-initialised module — and the symptom is an action happening twice rather than an error.

Dispatch is unaffected by mutation, because of the copy. Worth knowing in both directions: a listener that adds a listener will not see it fire on the current emit either.

Left unaddressed, these produce the classic leak. Node's MaxListenersExceededWarning at eleven listeners exists precisely for it — the number is arbitrary, and the warning is a heuristic for "you are probably adding in a loop and never removing".

In the real world

The most common emitter incident is a process-scoped emitter with request-scoped listeners. A handler attaches a listener to a shared client — a database connection, a message-bus consumer, a WebSocket — to await one reply, and never removes it. Each request adds one, none are removed, and the process slowly accumulates listeners that all still hold references to their request's closure: the request object, the parsed body, the user record. It presents as gradual memory growth with no obvious owner, and the only early signal is a MaxListenersExceededWarning in the logs that looks like noise.

The most damaging is the missing error listener. A socket resets, the emitter emits error, nothing is listening, and the throw comes from inside library code with a stack trace containing none of your frames — so it reads like a bug in the library. Since Node 15 an uncaught exception terminates the process, so one dropped connection restarts the service. Every long-lived emitter that can fail needs an error listener, even if it only logs.

The subtlest is ordering assumptions. Listeners run in registration order, which is easy to depend on accidentally: a listener that mutates shared state before another reads it works perfectly until someone reorders two on calls during a refactor. Nothing warns, because the coupling is invisible — it is expressed nowhere except the sequence of two unrelated-looking lines.

Bridging events and promises

Most real code needs both: an emitter for the ongoing stream, and a promise for "wake me when this particular thing happens".

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Node ships this bridge, and a second one for the repeating case:

import { once, on } from "node:events";

// once(): the next occurrence of one event, as a promise.
// It resolves to an ARRAY of the emitted arguments, not a single value.
const [sessionId, options] = await once(connection, "ready");

// on(): every occurrence, as an async iterable.
for await (const [chunk] of on(socket, "data")) {
  process(chunk);              // yields arg arrays, same as once()
}

Three things to know when using either.

once resolves to an array, because an event can carry several arguments and a promise resolves to one value. Destructure it.

Always handle error too. Both helpers do this for you; a hand-rolled bridge that forgets leaves the promise pending forever when the operation fails, which is a hang rather than an error.

on as an async iterator does not apply backpressure. Events keep arriving whether or not your loop has consumed the previous one, and they queue in memory. That is exactly the problem streams exist to solve, and it is the subject of the next two chapters.

Common Mistakes

  1. Expecting an emitter to remember. A listener attached after the event fired has missed it permanently — the classic "ready fired before I subscribed" startup bug.

  2. Assuming emit is asynchronous. Listeners run synchronously on the caller's stack, before emit returns.

  3. Doing heavy work in a listener. emit waits for it, so a CPU-bound listener on a hot event blocks the loop.

  4. No error listener on a long-lived emitter. The emit throws from inside library code, and since Node 15 that terminates the process.

  5. Trying to remove an anonymous listener. off compares identity; an inline arrow written twice is two objects.

  6. Registering the same function twice by accident. Two listeners, two calls per emit, and one off removes only one.

  7. Request-scoped listeners on a process-scoped emitter. The standard leak; each retains its whole request closure.

  8. Ignoring MaxListenersExceededWarning. It is a heuristic for exactly that leak, not noise.

  9. Depending on listener order implicitly. It is registration order, and a refactor that swaps two on calls silently changes behaviour.

  10. A hand-rolled event-to-promise bridge without an error path. On failure the promise stays pending forever — a hang, not an error.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
When would you use an EventEmitter instead of a promise?
Model answer — A promise represents one eventual result. It settles once, it remembers that outcome, and I can await it later and still get the value. An emitter represents something that happens repeatedly and remembers nothing — a socket emitting data every time bytes arrive, or a server emitting request. So I use a promise when there is exactly one answer and an emitter when there are many, or when the number is not known in advance. The consequence I would highlight is that emitters have no memory: if an event fires before I attach a listener, I have missed it permanently, which is a common startup bug with a ready event. The other difference that catches people is timing — emit calls its listeners synchronously, in registration order, before emit returns, whereas a promise handler is always deferred to a microtask. So the intuitions I built around promises do not transfer.
Mid
A service crashes with an error whose stack trace contains none of your code. Logs show only "Error: read ECONNRESET". What happened?
Model answer — That is almost certainly an error event emitted on an emitter with no error listener. error is the one specially cased event name: if nothing is listening, emit throws the error object rather than returning, and because emit is synchronous the throw comes from wherever the emit happened — which is inside library code, so my frames are not in the trace. Since Node 15 an uncaught exception terminates the process, so one reset connection restarts the service. The reasoning behind the design is defensible — an unobserved error is worse than a crash — but it means every long-lived emitter that can fail needs an error listener even if it only logs. So I would audit sockets, streams, database clients and child processes for a missing handler. The related thing I would check is whether we are handling error but not close, because those are separate events and cleanup usually belongs on close.
Senior
A service leaks memory in production. The only clue is a MaxListenersExceededWarning that nobody investigated. Trace it.
Model answer — That warning is a heuristic for exactly this: it fires at eleven listeners on one event, which almost always means something is adding in a loop and never removing. The classic shape is request-scoped listeners on a process-scoped emitter — a handler attaches a listener to a shared database client or message-bus consumer to wait for one reply and never calls off. Each retained listener is a closure holding that request's objects, so the leak is not the listener, it is everything the listener captured. I would confirm with a heap snapshot and look at what is retaining the closures, and check emitter.listenerCount on the long-lived objects to identify which event is growing. The fixes are to use once where one occurrence is expected, since it removes itself, and to make any hand-rolled wait remove its listener on every exit path — success, error and cancellation — which is exactly why an event-to-promise bridge should also listen for error, otherwise a failure leaves both the promise pending and the listener attached. Raising setMaxListeners to silence the warning is the wrong response unless I have genuinely established that a large fixed number of listeners is correct.
Company lens — how the same topic gets asked
micro1Practical: implement on, once, off and emit from scratch.
GooglePrecision: why emit copies the listener array, and why it is synchronous.
MetaDebugging: a crash with no application frames — the missing error listener.
AmazonJudgement: listener lifecycle on long-lived emitters, and diagnosing the leak.

Hands-on

Hands-on

Four exercises. Exercise 1 is the class itself — the checks include the copy-before-dispatch behaviour and the error special case — and exercise 3 is the bridge you will write in real code.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Summary

  • A promise is one remembered outcome; an emitter is many remembered by nobody. A listener attached late has missed the event permanently.
  • emit is synchronous — listeners run in registration order, on the caller's stack, before emit returns.
  • A slow listener blocks the loop, and profiles as time spent in emit rather than in your handler.
  • emit copies the listener array before dispatching, so removals during dispatch affect the next emit, not the current one.
  • once is on plus a self-removing wrapper, which carries .listener so off(original) can find it.
  • off removes only the first match, so a function registered twice needs two removals.
  • Anonymous listeners cannot be removed — off compares identity.
  • An error event with no listener throws, which since Node 15 terminates the process.
  • MaxListenersExceededWarning is a leak heuristic, usually request-scoped listeners on a process-scoped emitter.
  • Bridge to a promise with events.once — it resolves to an argument array, and it handles error so failures reject rather than hang.

The bridge at the end of this chapter has a gap the last paragraph named: an async iterator over events keeps receiving whether or not you have consumed the previous one, and the surplus queues in memory. That is fine for a click handler and fatal for a file. The next chapter is about streams — the abstraction Node uses for data that does not fit in memory, and which exists precisely to make "arrives in pieces" composable. The chapter after it is about the property that makes streaming actually work rather than merely postponing the problem: backpressure, the ability of a slow consumer to tell a fast producer to wait.

Editorial reflection

The error event is the most interesting design decision in this class, and I think it is right for reasons people rarely state. Making one event name special is ugly — it is a wart in an otherwise uniform API, and it means emit("error") can throw while emit("data") cannot. But the alternative is worse: an error that nobody is listening for would be silently discarded, and a system that silently discards errors is one where failures become corrupted data instead of crashes. The designers chose a loud, ugly, correct behaviour over a clean, quiet, dangerous one, and that is the trade I would make too.

What has aged badly is the relationship with promises. EventEmitter predates them by years and was never reconciled with them, so modern Node code spends a surprising amount of effort converting between two abstractions that both mean "something will happen later". The bridges exist and work, but every one of them is a small pile of listener registration and cleanup that must be right on three paths — success, error, cancellation — and getting the error path wrong turns a failure into a hang. That is a lot of ceremony for a very common operation.

The property I would most like to see acknowledged more often is that emit is synchronous. It is genuinely surprising after Part II, it is the mechanism behind a whole class of blocked-loop incidents, and it is almost never mentioned in tutorials — which happily show a listener doing heavy work as though emit were a queue. It is not a queue. It is a for loop calling your functions, and everything that follows from Part I about blocking applies inside it.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.