EngineeringMarch 28, 2024 · 4 min readUpdated September 6, 2026

Observer Pattern in Node.js

Part of my Node.js Design Patterns series.

When an order comes in from a delivery platform, at least five things have to happen: print a kitchen ticket, update stock, notify the store app, push a metric, write the audit row. The first version of that code called all five in a row inside the webhook handler. Then the metrics service went down for an hour, the handler threw on step four, and the platform retried the webhook every minute, printing the same kitchen ticket sixty times.

The Observer pattern separates "this happened" from "who cares". The webhook handler emits one event. Five listeners subscribe. The handler no longer knows or cares that a metrics service exists. Node has shipped this pattern in the runtime since day one as EventEmitter, and more recently as the browser-compatible EventTarget.

The pattern in modern Node

orders.mjsJavaScript
import { EventEmitter } from "node:events";
 
export const orders = new EventEmitter();
 
// The producer: knows nothing about printers, stock, or metrics.
export function receiveOrder(payload) {
  const order = { id: payload.id, items: payload.items, receivedAt: Date.now() };
  orders.emit("order:received", order);
  return order;
}
app.mjsJavaScript
import { orders, receiveOrder } from "./orders.mjs";
 
orders.on("order:received", (order) => console.log("print ticket", order.id));
orders.on("order:received", (order) => console.log("reserve stock", order.items.length, "lines"));
orders.once("order:received", () => console.log("first order of the day, warm the cache"));
 
receiveOrder({ id: "A1", items: [{ sku: "latte" }, { sku: "croissant" }] });
receiveOrder({ id: "A2", items: [{ sku: "latte" }] });

Listeners run synchronously, in the order they were added, on the same tick as emit. That is a feature (ordering is predictable) and the source of the trap below.

The async listener trap

Most real listeners are async: they write to a database or call an API. EventEmitter does not await them and does not catch their rejections.

trap.mjsJavaScript
import { EventEmitter } from "node:events";
 
const bus = new EventEmitter();
 
bus.on("order:received", async (order) => {
  throw new Error(`metrics down while handling ${order.id}`);
});
 
process.on("unhandledRejection", (err) => {
  console.log("nobody caught:", err.message);
});
 
bus.emit("order:received", { id: "A1" });
console.log("emit returned fine, the failure is somewhere else now");

emit returns true, the handler moves on, and the rejection surfaces as an unhandled rejection somewhere later, which on Node 15 and up crashes the process by default. Three ways out, in the order I reach for them:

  1. Wrap every async listener so it catches and logs its own failure. A tiny helper does it:
safe.mjsJavaScript
export function safe(name, fn) {
  return async (...args) => {
    try {
      await fn(...args);
    } catch (err) {
      console.error(`listener ${name} failed:`, err.message);
    }
  };
}
  1. If the producer must wait for observers (the webhook must not return 200 until stock is reserved), do not use an emitter. Call the handlers explicitly with Promise.allSettled and decide what a partial failure means.

  2. If a listener's work must survive a crash (the audit row, the kitchen ticket), it does not belong in memory at all. Emit to a queue. In-process events are for things you can afford to lose.

EventTarget and AbortSignal

Node also has the web-standard EventTarget. It is slower than EventEmitter and has no once that returns a promise, but two things make it worth knowing. Its listeners take an AbortSignal, which is the cleanest way I know to unsubscribe a whole group at once:

target.mjsJavaScript
const target = new EventTarget();
const controller = new AbortController();
 
target.addEventListener("tick", () => console.log("tick"), { signal: controller.signal });
target.dispatchEvent(new Event("tick"));
controller.abort();
target.dispatchEvent(new Event("tick")); // nothing printed

And events.on() from node:events turns any emitter into an async iterator, so a consumer can for await over events with backpressure instead of piling callbacks.

Where it goes wrong

Memory leaks from forgotten listeners. A listener added per request and never removed is the classic Node leak, and the MaxListenersExceededWarning is Node telling you about it. Use once, or the AbortSignal shape above, or keep subscriptions at module scope.

Hidden order dependencies. Listener three quietly relies on listener two having updated stock first. It works until someone reorders the imports. If order matters, it is not an observer relationship, it is a pipeline; see Middleware.

Events as the entire architecture. When every module talks to every other through a global bus, nobody can read the call graph. Use events at real seams (an order arrived, a payment settled), and plain function calls everywhere else.

When not to use it

If there is exactly one reaction to an event, call the function. The pattern starts paying for itself at the second listener and stops paying when the producer needs to know the result.

Next: the Strategy Pattern, for the step that is done three different ways depending on which partner sent the order.

Nguyễn Hải Nam

Nguyễn Hải Nam

Project Management Lead. 16+ years from code to delivery. PMP®. Writing here about project management and engineering.

About me