EventEmitter Patterns
Use Node.js EventEmitter for publish-subscribe patterns, with typed events and async handlers.
Search across all documentation pages
Use Node.js EventEmitter for publish-subscribe patterns, with typed events and async handlers.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
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:
on and emit give full autocomplete and payload safety.run) and consumers (the on handlers) are fully decoupled.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.
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.
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.
error handlers crashes the process. Emitting 'error' with no listener throws. Always attach an error listener before any code path that can emit one.emit is synchronous. Listeners run in registration order on the same tick. A slow synchronous listener blocks every other listener and the event loop.once does not prevent other registrations. It only wraps that one handler. Other on listeners keep firing forever.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.emit and can take down unrelated listeners. Wrap risky work in try/catch or use async handlers that reject into an error event.emit does not await anything. Rejected promises in listeners become unhandled rejections unless you attach .catch inside the listener.| 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. |
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.
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.
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.
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.
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.
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.
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.
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.
off and removeListener?None - off is an alias added for parity with the DOM. Both remove a listener by reference.
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.
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.
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.
docs/nodejs-scripts/http-server.md - HTTP servers, which are themselves EventEmitter instances.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.Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥