EventEmitter is the pattern Node uses to let one part of a program announce that something happened, without needing to know who - if anyone - is listening. It's one of the oldest APIs in Node's node:events module, and one of the most foundational: streams, HTTP servers, sockets, and child processes are all, underneath their specific APIs, EventEmitters.
EventEmitter Basics covers the hands-on on/once/emit API, and Memory Leaks from Listeners, Typed EventEmitter, and Domain Events vs EventEmitter each dig into one specific concern. This page is the mental model underneath all of them: what emitting an event actually does, how it differs from a queue or a message bus, and where its synchronous, in-process design starts to matter.
EventEmitter is a synchronous, in-process publish/subscribe mechanism - one object maintains a list of named events, and any number of listener functions can subscribe to each name.
Insight: It decouples the code that detects something happening from the code that reacts to it, without needing either side to know about the other directly.
When to Use: Modeling in-process notifications (a job finished, a connection closed, a value changed), building a plugin/hook system, or working with any core Node API that already exposes events (streams, servers, sockets).
Limitations/Trade-offs: EventEmitter is entirely in-memory and synchronous per listener call - it has no persistence, no cross-process delivery, and a slow or throwing listener directly affects the emitter's caller.
Related Topics: Node's stream API, the event loop and call stack, message queues, the observer design pattern.
The core idea behind EventEmitter is the observer pattern: an object (the emitter) exposes named events, other code registers listener functions against those names, and calling emit(name, ...) runs every listener currently registered for that name. Neither side needs a reference to the other beyond the shared emitter instance itself - the emitter doesn't know or care who's listening, and a listener doesn't need to know what triggered it beyond the event's name and payload.
A useful analogy: think of an emitter as a building's fire alarm system rather than a phone call. Pulling the alarm (emit) doesn't dial a specific person - it sounds every registered alarm bell (listener) in the building at once, and the person pulling the lever doesn't need to know how many people are inside or who they are.
import { EventEmitter } from 'node:events';const uploads = new EventEmitter();uploads.on('completed', (fileId: string) => { console.log(`upload finished: ${fileId}`);});uploads.emit('completed', 'file-123'); // runs every 'completed' listener, in order
Node adopted this pattern early because so much of what a server does is inherently event-shaped: a socket receives data, a request completes, a child process exits. Rather than inventing a bespoke callback convention for each of those, Node standardized on one shared mechanism, and then built many of its own core classes directly on top of it.
The single most important mechanical fact about EventEmitter is that it's synchronous: calling emitter.emit(name, ...) invokes every registered listener for that name, one after another, on the current call stack, and emit() only returns after all of them have returned. This is fundamentally different from a message queue or a pub/sub broker - there's no buffering, no delivery guarantee, and no listener registered after an emit() call receives that particular emission.
emitter.on('tick', () => console.log('A'));emitter.on('tick', () => console.log('B'));emitter.emit('tick');console.log('C');// Output: A, B, C - both listeners run to completion,// synchronously, before emit() returns and 'C' logs.
That synchronicity has a direct consequence for error handling: if a listener throws, the exception propagates out of emit() itself, just like any other synchronous throw - it is not caught or swallowed by the emitter, and it stops any later listener for that same emission from running. For the special 'error' event specifically, Node adds one more rule on top: if an emitter emits 'error' with no listener registered for it, Node treats that as an uncaught exception and crashes the process, precisely because a silently ignored error is worse than a loud one.
The other mechanical detail worth internalizing is why so many Node core APIs extend EventEmitter rather than exposing a different callback shape: it gives every stream, server, and socket a uniform way to expose multiple, independently-subscribable signals ('data', 'error', 'close', 'end', and more) from a single object, instead of needing a separate constructor argument or method for each one. Once you understand plain EventEmitter, you already understand the shape of stream.Readable, net.Server, and child_process.ChildProcess - they layer domain-specific event names onto the exact mechanism described above.
EventEmitter's biggest structural limitation is also its defining trade-off: it's entirely in-process and in-memory. There's no persistence (a listener registered after an event fires never sees it), no delivery guarantee, and no cross-process or cross-restart durability - an emitter and its listeners must live in the same running process, sharing the same memory space.
That makes it a fundamentally different tool from a message queue or event bus, even though both are sometimes loosely called "events." Domain Events vs EventEmitter covers exactly where that line sits: EventEmitter suits fast, in-process, best-effort notification (UI-style updates, internal hooks, stream signaling), while queues (Kafka, SQS, RabbitMQ, or an outbox pattern) suit anything that needs to survive a restart, cross a process boundary, or guarantee at-least-once delivery.
Approach
Strength
Weakness
Best Fit
EventEmitter
Near-zero overhead; synchronous, predictable ordering; built into every relevant core API
In-process only; no persistence; a throwing listener affects the emitter's caller directly
Real infrastructure and operational overhead; asynchronous by nature
Cross-service events, anything that must not be lost
Direct callback / Promise
Simplest possible one-to-one notification; no extra abstraction
Doesn't scale past one consumer without manual fan-out
A single caller awaiting a single specific result
Two operational failure modes are worth naming because they show up repeatedly in production Node services. First, listener accumulation: an on() call with no matching off()/removeListener() keeps that listener - and anything it closes over - alive for the emitter's entire lifetime, which is a routine source of memory growth in long-running processes. Node's default maxListeners warning (10 per event name) exists specifically as an early smoke detector for this, not a hard limit. Memory Leaks from Listeners covers AbortSignal-based cleanup and when raising that limit is legitimate versus a sign of an actual leak.
Second, type safety at scale: a plain EventEmitter's emit/on signatures accept any string as an event name and any arguments as a payload, which means a typo'd event name or a mismatched payload shape is invisible to the TypeScript compiler by default. Typed EventEmitter covers constraining both ends - names and payload shapes - so those mistakes surface at compile time instead of silently doing nothing at runtime.
"emit() is asynchronous, like most other Node I/O." It's fully synchronous - every listener runs, in registration order, on the current call stack, before emit() returns; nothing about EventEmitter itself defers execution to a later tick.
"EventEmitter is basically a lightweight message queue." A queue buffers and persists messages for later or repeated delivery; EventEmitter has no buffer at all - if nothing is listening when emit() runs, that emission is simply gone.
"A thrown error inside a listener gets caught by the emitter." It propagates exactly like any synchronous throw, potentially interrupting later listeners for that same emission and surfacing at the emit() call site - the emitter provides no implicit try/catch.
"maxListeners is a hard cap that will start dropping listeners." It only triggers a warning (MaxListenersExceededWarning) once exceeded - it's a leak-detection heuristic, not an enforced limit, and raising it is sometimes the correct fix rather than always a red flag.
"Only custom application code uses EventEmitter - core Node APIs have their own thing." The opposite is true: streams, HTTP servers, sockets, and child processes are all EventEmitters underneath their specific method names, using this exact mechanism.
An in-process publish/subscribe mechanism where an object maintains named events, and calling emit(name, ...) synchronously runs every listener currently registered for that name.
Why do so many Node core APIs extend EventEmitter?
It gives any object a uniform way to expose multiple independently-subscribable signals from one instance - a stream's 'data', 'error', and 'end' events all ride the same mechanism instead of needing separate bespoke callback APIs.
Is `emit()` synchronous or asynchronous?
Synchronous - it calls every registered listener for that event name, in order, on the current call stack, and only returns once all of them have finished running.
What happens if a listener throws an exception?
The exception propagates out of the emit() call just like any normal synchronous throw - it isn't caught by the emitter, and it can prevent any listener registered after the throwing one (for that same emission) from running.
Why does an unhandled `'error'` event crash the process?
Node treats 'error' specially: if an emitter emits it and no listener is registered for that specific event, Node raises it as an uncaught exception rather than silently discarding it - a deliberate choice to make ignored errors loud instead of invisible.
Does a listener registered after `emit()` runs get that event?
No - EventEmitter has no buffering or replay; an emission that happens before a listener is registered is simply gone by the time that listener subscribes.
What's the difference between `on()` and `once()`?
on() registers a listener that runs every time the event fires; once() registers one that runs on the next occurrence only, then automatically removes itself - useful for one-time signals like "connection established."
Is EventEmitter a substitute for a message queue?
No - it's entirely in-process and in-memory with no persistence or delivery guarantee, while a queue (Kafka, SQS, RabbitMQ) is built specifically for durability and crossing process or service boundaries; they solve different problems even though both are colloquially "events."
Why does Node warn about more than 10 listeners on one event?
The default maxListeners warning is a leak-detection heuristic, not a hard cap - accumulating many listeners on one event name is a common symptom of forgotten on() calls without matching removal, so Node flags it early rather than letting it grow silently.
Can two different listeners for the same event run in parallel?
No - because emit() is synchronous, listeners for one emission always run one after another on the same call stack, never concurrently; "parallel" execution would require each listener to hand off to something asynchronous internally.
Does EventEmitter guarantee listener execution order?
Yes, for a given event name, listeners run in the order they were registered - emit() walks its internal list of listeners for that name sequentially, not in some arbitrary or reordered sequence.