EventEmitter Patterns
Use Node.js EventEmitter for publish-subscribe patterns, with typed events and async handlers.
Recipe
Quick-reference recipe card — copy-paste ready.
import { EventEmitter } from "node:events";
const emitter = new EventEmitter();
// Subscribe
emitter.on("message", (text: string) => {
console.log("got:", text);
});
// One-shot subscribe
emitter.once("ready", () => {
console.log("ready fired once");
});
// Unsubscribe
const handler = (n: number) => console.log(n);
emitter.on("tick", handler);
emitter.off("tick", handler);
// Publish
emitter.emit("message", "hello");
emitter.emit("ready");
// Always handle errors — unhandled 'error' crashes the process
emitter.on("error", (err) => {
console.error("emitter error:", err);
});
emitter.emit("error", new Error("boom"));When to reach for this: Decoupling producers from consumers inside a single Node process — job queues, log pipelines, stream lifecycle, plugin hooks.
Working Example
A typed EventEmitter wrapper class for a job queue.
import { EventEmitter } from "node:events";
// Type map: event name to argument tuple
interface JobEvents {
"job:start": [id: string];
"job:progress": [id: string, percent: number];
"job:complete": [id: string, result: unknown];
"job:error": [id: string, err: Error];
}
class JobQueue extends EventEmitter {
override on<K extends keyof JobEvents>(
event: K,
listener: (...args: JobEvents[K]) => void
): this {
return super.on(event, listener as (...args: unknown[]) => void);
}
override emit<K extends keyof JobEvents>(
event: K,
...args: JobEvents[K]
): boolean {
return super.emit(event, ...args);
}
async run(id: string, task: (report: (p: number) => void) => Promise<unknown>) {
this.emit("job:start", id);
try {
const result = await task((percent) => {
this.emit("job:progress", id, percent);
});
this.emit("job:complete", id, result);
} catch (err) {
this.emit("job:error", id, err as Error);
}
}
}
const queue = new JobQueue();
queue.on("job:start", (id) => console.log(`[${id}] start`));
queue.on("job:progress", (id, pct) => console.log(`[${id}] ${pct}%`));
queue.on("job:complete", (id, result) => console.log(`[${id}] done`, result));
queue.on("job:error", (id, err) => console.error(`[${id}] failed`, err));
await queue.run("job-1", async (report) => {
for (let i = 0; i <= 100; i += 25) {
report(i);
await new Promise((r) => setTimeout(r, 50));
}
return { ok: true };
});What this demonstrates:
- Typed overrides for
onandemitgive full autocomplete and payload safety. - Error events are explicit — no silent failures.
- The producer (
run) and consumers (theonhandlers) are fully decoupled.
Deep Dive
How It Works
EventEmitter keeps an internal map of event name to an array of listener functions. emit iterates that array synchronously, calling each listener in the order it was registered. There is no built-in queue and no backpressure — listeners run on the current tick unless they themselves schedule async work.
on appends, once appends a wrapper that removes itself after the first call, off (alias removeListener) removes by reference, and removeAllListeners(event?) clears everything.
The 'error' event is special: if nothing is listening when you emit('error', err), Node throws the error and, if uncaught, terminates the process.
Variations
Typed EventEmitter with generics. The JobQueue pattern above is the idiomatic approach in modern Node + TypeScript. Libraries like tsee or typed-emitter package the same idea.
Async iteration with events.on(). Consume events like a stream:
import { on, EventEmitter } from "node:events";
const emitter = new EventEmitter();
setTimeout(() => emitter.emit("data", 1), 10);
setTimeout(() => emitter.emit("data", 2), 20);
for await (const [value] of on(emitter, "data")) {
console.log("value:", value);
if (value === 2) break;
}once as a Promise. Great for waiting on a single lifecycle event:
import { once, EventEmitter } from "node:events";
const emitter = new EventEmitter();
setTimeout(() => emitter.emit("ready", "ok"), 50);
const [value] = await once(emitter, "ready");
console.log(value); // "ok"Extending EventEmitter. Subclass when the emitter is the primary identity of your object (the JobQueue example). Compose (have an internal #emitter) when events are a secondary concern.
Removing all listeners. emitter.removeAllListeners() with no argument nukes every event; with an argument, only that event. Handy in teardown but dangerous if you do not own the emitter.
Max listeners warning. Node prints MaxListenersExceededWarning at 11 listeners per event. Raise with emitter.setMaxListeners(50) or globally via EventEmitter.defaultMaxListeners. The warning is a leak hint, not an error.
TypeScript Notes
Define an event type map as an interface where each key is the event name and each value is a tuple of the listener arguments — for example interface Events \{ start: [id: string]; progress: [percent: number] \}. Use this map with a generic base class like TypedEmitter<Events> (from typed-emitter), or write your own overrides of on, once, off, and emit that constrain the event name with K extends keyof Events and the args with Events[K]. This gives you autocomplete on event names and compile-time checking of payload shapes.
Gotchas
- Forgetting
errorhandlers crashes the process. Emitting'error'with no listener throws. Always attach anerrorlistener before any code path that can emit one. - MaxListeners warning at 10. Adding the same handler in a loop, or subscribing inside a React effect without cleanup, quickly exceeds the default. The warning is printed once per emitter per event — easy to miss.
emitis synchronous. Listeners run in registration order on the same tick. A slow synchronous listener blocks every other listener and the event loop.oncedoes not prevent other registrations. It only wraps that one handler. Otheronlisteners keep firing forever.- Anonymous listeners cannot be removed.
emitter.off("x", () => {})passes a brand-new function reference and removes nothing. Store the handler in a variable if you ever need to remove it. - Throwing inside a listener. A synchronous throw propagates out of
emitand can take down unrelated listeners. Wrap risky work in try/catch or use async handlers that reject into anerrorevent. - Async listeners are fire-and-forget.
emitdoes not await anything. Rejected promises in listeners become unhandled rejections unless you attach.catchinside the listener.
Alternatives
| Option | Best For | Notes |
|---|---|---|
node:events EventEmitter | Node-only services | Built in, zero deps, sync emit. |
EventTarget / CustomEvent | Universal (Node 19+, browsers, workers) | Web standard, slightly more verbose, supports AbortSignal for cleanup. |
mitt | Tiny universal pub-sub | ~200 bytes, no wildcard, no once — just on/off/emit. |
nanoevents | Small typed emitter | First-class TypeScript generics, returns an unsubscribe function. |
RxJS Subject | Streams, operators, backpressure | Heavy, but unmatched for complex async pipelines. |
| Node streams | Byte/object streams with backpressure | Use when order and flow control matter, not just signaling. |
FAQs
What happens if I emit an error event with no listener?
Node throws the error synchronously out of emit. If nothing catches it, the process crashes with an Unhandled 'error' event message. Always attach an error listener before the first emit.
Is emit synchronous or asynchronous?
Synchronous. Listeners run in registration order on the current tick, blocking the event loop until they return. Async listeners are fire-and-forget — emit does not await them.
How do I remove a listener I added with once?
Save the reference and pass it to off: const h = () => {}; emitter.once("x", h); emitter.off("x", h). You can also call removeAllListeners("x") if you own the emitter.
Why does Node warn about 11 listeners?
MaxListenersExceededWarning fires at 11 listeners on a single event — a heuristic for detecting leaks. If you legitimately need more, call emitter.setMaxListeners(50) or set EventEmitter.defaultMaxListeners globally.
How do I type events in TypeScript without a library? (TypeScript)
Define an interface mapping event names to argument tuples, then subclass EventEmitter and override on and emit with generic signatures constrained by K extends keyof YourEvents. See the JobQueue working example on this page.
Should I extend EventEmitter or compose it? (TypeScript)
Extend when events are the object's primary identity (a bus, a queue, a stream). Compose with a private #emitter field when events are a secondary concern — that keeps your public API smaller and avoids exposing every EventEmitter method.
Can I await an event?
Yes — use events.once(emitter, "name") which returns a promise resolving to the emitted arguments as an array. It also rejects on 'error', which makes it safer than a hand-rolled wrapper.
How do I consume events as a stream?
Use events.on(emitter, "name") which returns an async iterator. You can for await over it and break to stop listening. Pair with an AbortSignal option for clean cancellation.
What is the difference between off and removeListener?
None — off is an alias added for parity with the DOM. Both remove a listener by reference.
Why does my anonymous listener keep firing after I tried to remove it? (Gotcha)
emitter.off("x", () => {}) creates a brand-new function and passes it to off, which finds no match. Store the handler in a variable or use once if you only need it one time.
What should I use instead of EventEmitter in the browser? (Gotcha)
EventTarget with CustomEvent is the web standard and works everywhere, including Node 19+. For ultra-small universal pub-sub, reach for mitt or nanoevents. Do not ship node:events to the browser.
How do I prevent unhandled rejections from async listeners?
Wrap the async body in a try/catch and forward failures to the emitter: emitter.on("x", async (v) => { try { await work(v); } catch (err) { emitter.emit("error", err); } }). emit will not await your handler, so you must catch inside.
Related
docs/nodejs-scripts/http-server.md— HTTP servers, which are themselvesEventEmitterinstances.docs/browser-apis/postmessage-basics.md— cross-window messaging, a browser-side pub-sub pattern.docs/react-hooks/use-effect.md— subscribing and cleaning up event listeners from React.