Events #

Event-driven programming is the core paradigm of Node.js — almost every asynchronous operation in Node.js is built on the event system. HTTP servers emit events when requests come in, streams emit events when data is available, processes emit events when signals are received. Behind all of this is one class: EventEmitter. Understanding EventEmitter deeply opens the door to building components that are loosely coupled, observable, and easy to extend — because you can add new behavior without changing existing code, simply by adding new listeners.

Basic EventEmitter #

import { EventEmitter } from "events";

// create an EventEmitter instance
const emitter = new EventEmitter();

// register a listener — a function called when the event fires
emitter.on("pesan", (teks: string) => {
  console.log("Pesan diterima:", teks);
});

// emit the event with data
emitter.emit("pesan", "Halo dari EventEmitter!");
// Output: Pesan diterima: Halo dari EventEmitter!

// one event can have many listeners
emitter.on("pesan", (teks: string) => {
  console.log("Listener kedua:", teks.toUpperCase());
});

emitter.emit("pesan", "test");
// Output:
// Pesan diterima: test
// Listener kedua: TEST

// emit returns true if there are listeners, false if not
const adaListener = emitter.emit("event-tanpa-listener");
console.log(adaListener); // false

Typed EventEmitter #

The biggest problem with the built-in EventEmitter is the lack of type safety — you can emit events with any name and any payload without a TypeScript error. There are several patterns to solve this.

The Generic Class Pattern #

import { EventEmitter } from "events";

// define a map between event names and their payload types
interface EventMap {
  [event: string | symbol]: unknown[];
}

// a typed EventEmitter that enforces event names and payload types
class TypedEmitter<T extends EventMap> {
  private emitter = new EventEmitter();

  on<K extends keyof T & (string | symbol)>(
    event: K,
    listener: (...args: T[K] extends unknown[] ? T[K] : never) => void
  ): this {
    this.emitter.on(event as string, listener as (...args: unknown[]) => void);
    return this;
  }

  once<K extends keyof T & (string | symbol)>(
    event: K,
    listener: (...args: T[K] extends unknown[] ? T[K] : never) => void
  ): this {
    this.emitter.once(event as string, listener as (...args: unknown[]) => void);
    return this;
  }

  off<K extends keyof T & (string | symbol)>(
    event: K,
    listener: (...args: T[K] extends unknown[] ? T[K] : never) => void
  ): this {
    this.emitter.off(event as string, listener as (...args: unknown[]) => void);
    return this;
  }

  emit<K extends keyof T & (string | symbol)>(
    event: K,
    ...args: T[K] extends unknown[] ? T[K] : never
  ): boolean {
    return this.emitter.emit(event as string, ...args);
  }

  removeAllListeners(event?: keyof T & (string | symbol)): this {
    this.emitter.removeAllListeners(event as string | undefined);
    return this;
  }
}

// define events for a specific domain
interface PesananEvents extends EventMap {
  "pesanan:dibuat": [pesanan: Pesanan];
  "pesanan:dibayar": [pesananId: string, jumlah: number];
  "pesanan:dikirim": [pesananId: string, noResi: string];
  "pesanan:selesai": [pesananId: string];
  "pesanan:dibatalkan": [pesananId: string, alasan: string];
}

interface Pesanan {
  id: string;
  userId: string;
  items: Array<{ produkId: string; jumlah: number; harga: number }>;
  total: number;
}

// usage — TypeScript will error if the event name or payload is wrong
const pesananEmitter = new TypedEmitter<PesananEvents>();

pesananEmitter.on("pesanan:dibuat", (pesanan) => {
  // pesanan is inferred as Pesanan — autocomplete works
  console.log(`Pesanan baru: ${pesanan.id}, total: Rp ${pesanan.total}`);
});

pesananEmitter.on("pesanan:dibayar", (pesananId, jumlah) => {
  // pesananId: string, jumlah: number — the types are already correct
  console.log(`Pembayaran ${pesananId}: Rp ${jumlah}`);
});

// TypeScript error — "pesanan:tidak-ada" is not a valid event
// pesananEmitter.on("pesanan:tidak-ada", () => {}); // ✗ compile error

The EventEmitter Subclass Pattern #

For larger components, extend EventEmitter directly:

import { EventEmitter } from "events";

interface KeranjangEvents {
  "item:ditambah": [produkId: string, jumlah: number];
  "item:dihapus": [produkId: string];
  "keranjang:dikosongkan": [];
  "total:berubah": [total: number];
}

// declaration to combine event types into the class
declare interface Keranjang {
  on<K extends keyof KeranjangEvents>(
    event: K,
    listener: (...args: KeranjangEvents[K]) => void
  ): this;
  emit<K extends keyof KeranjangEvents>(
    event: K,
    ...args: KeranjangEvents[K]
  ): boolean;
}

class Keranjang extends EventEmitter {
  private items = new Map<string, { jumlah: number; harga: number }>();

  tambahItem(produkId: string, jumlah: number, harga: number): void {
    const existing = this.items.get(produkId);
    if (existing) {
      existing.jumlah += jumlah;
    } else {
      this.items.set(produkId, { jumlah, harga });
    }

    this.emit("item:ditambah", produkId, jumlah);
    this.emit("total:berubah", this.hitungTotal());
  }

  hapusItem(produkId: string): void {
    if (this.items.delete(produkId)) {
      this.emit("item:dihapus", produkId);
      this.emit("total:berubah", this.hitungTotal());
    }
  }

  kosongkan(): void {
    this.items.clear();
    this.emit("keranjang:dikosongkan");
    this.emit("total:berubah", 0);
  }

  private hitungTotal(): number {
    let total = 0;
    for (const { jumlah, harga } of this.items.values()) {
      total += jumlah * harga;
    }
    return total;
  }

  get jumlahItem(): number {
    return this.items.size;
  }
}

// usage
const keranjang = new Keranjang();

keranjang.on("item:ditambah", (produkId, jumlah) => {
  console.log(`+ ${jumlah}x ${produkId}`);
});

keranjang.on("total:berubah", (total) => {
  console.log(`Total: Rp ${total.toLocaleString("id-ID")}`);
});

keranjang.tambahItem("laptop-asus", 1, 15_000_000);
keranjang.tambahItem("mouse-logitech", 2, 350_000);
// Output:
// + 1x laptop-asus
// Total: Rp 15.000.000
// + 2x mouse-logitech
// Total: Rp 15.700.000

EventEmitter Methods #

on, once, off #

const emitter = new EventEmitter();

// on — a permanent listener, called every time the event is emitted
const handler = (data: string) => console.log("on:", data);
emitter.on("data", handler);

// once — a one-shot listener, automatically removed after the first call
emitter.once("koneksi", () => {
  console.log("Terhubung! (hanya sekali)");
});

emitter.emit("koneksi"); // "Terhubung! (hanya sekali)"
emitter.emit("koneksi"); // no output — the listener is already removed

// off / removeListener — remove a specific listener
// IMPORTANT: must reference the SAME function that was registered
emitter.off("data", handler);
emitter.emit("data", "test"); // no output — the handler is removed

// ANTI-PATTERN: you can't remove an inline arrow function
emitter.on("event", (x: number) => console.log(x)); // ✗ can't be removed later

// CORRECT: store a reference to the function
const myHandler = (x: number) => console.log(x);
emitter.on("event", myHandler);
emitter.off("event", myHandler); // ✓ can be removed

// removeAllListeners — remove all listeners for one event or all events
emitter.removeAllListeners("data"); // remove all "data" listeners
emitter.removeAllListeners();       // remove ALL listeners from all events

Listener Prepend #

// addListener — same as on
emitter.addListener("event", handler);

// prependListener — add a listener at the START of the queue (called first)
emitter.on("proses", () => console.log("Listener 1"));
emitter.on("proses", () => console.log("Listener 2"));
emitter.prependListener("proses", () => console.log("Listener 0 (prepend)"));

emitter.emit("proses");
// Output:
// Listener 0 (prepend)
// Listener 1
// Listener 2

// prependOnceListener — prepend + one-shot
emitter.prependOnceListener("proses", () => console.log("Sekali di awal"));

Inspecting Listeners #

const emitter = new EventEmitter();

const h1 = () => {};
const h2 = () => {};
emitter.on("event", h1);
emitter.on("event", h2);
emitter.on("lain", h1);

// eventNames — the list of events that have listeners
console.log(emitter.eventNames()); // ["event", "lain"]

// listenerCount — the number of listeners for a specific event
console.log(emitter.listenerCount("event")); // 2
console.log(emitter.listenerCount("lain"));  // 1

// listeners — an array of listener functions for a specific event
const listeners = emitter.listeners("event");
console.log(listeners.length); // 2
console.log(listeners[0] === h1); // true

// rawListeners — like listeners, but once wrappers are also returned
const raw = emitter.rawListeners("event");

The Listener Count Limit #

// the default maximum is 10 listeners per event
// if exceeded, Node.js shows a warning on stderr
const emitter = new EventEmitter();

// change the limit for a specific instance
emitter.setMaxListeners(20);

// change the global limit for all new EventEmitters
EventEmitter.defaultMaxListeners = 20;

// set to 0 for unlimited (careful: memory leaks!)
emitter.setMaxListeners(0);

// check the current limit
console.log(emitter.getMaxListeners()); // 20

// ANTI-PATTERN: adding listeners in a loop without cleanup
// this quickly hits the limit and causes memory leaks
function setupHandlerSalah(emitter: EventEmitter, count: number): void {
  for (let i = 0; i < count; i++) {
    emitter.on("event", () => console.log(i)); // ✗ listeners pile up
  }
}

// CORRECT: one listener that handles the logic inside
function setupHandlerBenar(emitter: EventEmitter, items: number[]): void {
  emitter.on("event", (data: unknown) => {
    for (const item of items) {
      console.log(item, data); // ✓ one listener, many items
    }
  });
}

The Error Event — Error Handling in EventEmitter #

The error event has special behavior in Node.js: if it’s emitted without a listener, the process crashes with an uncaught exception.

const emitter = new EventEmitter();

// ANTI-PATTERN: emitting an error without a listener
emitter.emit("error", new Error("Sesuatu yang buruk terjadi"));
// ✗ the process crashes: Error: Sesuatu yang buruk terjadi

// CORRECT: always register a listener for the "error" event
emitter.on("error", (err: Error) => {
  console.error("EventEmitter error:", err.message);
  // handle the error — don't let the process crash
});

emitter.emit("error", new Error("Sesuatu yang buruk terjadi"));
// ✓ Output: EventEmitter error: Sesuatu yang buruk terjadi

// a good pattern: a class extending EventEmitter always registers a default error handler
class KoneksiDB extends EventEmitter {
  private connected = false;

  constructor() {
    super();

    // default error handler — can be overridden by consumers
    this.on("error", (err: Error) => {
      console.error("[KoneksiDB] Unhandled error:", err.message);
    });
  }

  connect(url: string): void {
    try {
      // simulate a connection
      if (!url.startsWith("postgresql://")) {
        throw new Error(`URL tidak valid: ${url}`);
      }
      this.connected = true;
      this.emit("connect");
    } catch (err) {
      this.emit("error", err instanceof Error ? err : new Error(String(err)));
    }
  }

  query(sql: string): void {
    if (!this.connected) {
      this.emit("error", new Error("Belum terhubung ke database"));
      return;
    }
    // execute the query...
    this.emit("data", { sql, rows: [] });
  }
}

One-Shot Events and Promises #

Using once() with Promises is very useful for waiting for a specific event before continuing execution.

import { EventEmitter, once } from "events";

// events.once() — a helper to await a single event
async function tungguEvent(
  emitter: EventEmitter,
  namaEvent: string,
  timeoutMs?: number
): Promise<unknown[]> {
  if (timeoutMs !== undefined) {
    // with a timeout using AbortController
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);

    try {
      const args = await once(emitter, namaEvent, { signal: controller.signal });
      clearTimeout(timer);
      return args;
    } catch (err: any) {
      clearTimeout(timer);
      if (err.name === "AbortError") {
        throw new Error(`Timeout menunggu event "${namaEvent}"`);
      }
      throw err;
    }
  }

  return once(emitter, namaEvent);
}

// example: wait for the server to be ready before continuing
class Server extends EventEmitter {
  async start(port: number): Promise<void> {
    setTimeout(() => {
      this.emit("ready", port);
    }, 100); // simulate startup
  }
}

const server = new Server();
server.start(3000);

const [port] = await tungguEvent(server, "ready", 5000);
console.log(`Server siap di port ${port}`);

// the async iterator pattern for event streams
import { on } from "events";

async function prosesEventStream(emitter: EventEmitter): Promise<void> {
  // on() from 'events' creates an async iterator from events
  for await (const [data] of on(emitter, "data")) {
    console.log("Data diterima:", data);
    // break to stop the iteration
    if (data === null) break;
  }
}

The Observer Pattern with EventEmitter #

EventEmitter is a natural implementation of the Observer pattern — an object (observable) has a list of dependents (observers) that get notified when its state changes.

flowchart LR
    A[Observable\nEventEmitter] -- emit event --> B[Observer 1\nListener]
    A -- emit event --> C[Observer 2\nListener]
    A -- emit event --> D[Observer 3\nListener]

    E[Other code] -- emit\ntrigger --> A
// example: simple state management based on EventEmitter
interface AppState {
  user: { id: string; nama: string } | null;
  tema: "light" | "dark";
  bahasa: string;
}

type StateEvents = {
  [K in keyof AppState as `state:${K}:changed`]: [
    newValue: AppState[K],
    oldValue: AppState[K]
  ];
} & {
  "state:changed": [key: keyof AppState, newValue: unknown];
};

class Store extends EventEmitter {
  private state: AppState = {
    user: null,
    tema: "light",
    bahasa: "id",
  };

  getState(): Readonly<AppState> {
    return { ...this.state };
  }

  setState<K extends keyof AppState>(key: K, value: AppState[K]): void {
    const oldValue = this.state[key];

    if (JSON.stringify(oldValue) === JSON.stringify(value)) {
      return; // no change — no need to emit
    }

    this.state[key] = value;

    // emit a specific event for the changed key
    this.emit(`state:${key}:changed` as string, value, oldValue);

    // emit a general event for all changes
    this.emit("state:changed", key, value);
  }
}

// usage
const store = new Store();

// a specific observer for user changes
store.on("state:user:changed", (newUser, oldUser) => {
  if (newUser) {
    console.log(`Login: ${newUser.nama}`);
  } else {
    console.log(`Logout dari: ${oldUser?.nama}`);
  }
});

// an observer for all state changes
store.on("state:changed", (key, value) => {
  console.log(`State berubah: ${String(key)} =`, value);
});

store.setState("user", { id: "u1", nama: "Budi" });
// Output:
// Login: Budi
// State berubah: user = { id: 'u1', nama: 'Budi' }

store.setState("tema", "dark");
// Output:
// State berubah: tema = dark

Avoiding Memory Leaks #

Memory leaks with EventEmitter happen when listeners are added but never removed — especially in components that are recreated many times.

// ANTI-PATTERN: listeners aren't cleaned up when the component is destroyed
class KomponenSalah {
  private emitter: EventEmitter;

  constructor(emitter: EventEmitter) {
    this.emitter = emitter;

    // ✗ this listener is never removed even if KomponenSalah is garbage collected
    this.emitter.on("data", this.handleData.bind(this));
  }

  private handleData(data: unknown): void {
    console.log(data);
  }
  // no cleanup!
}

// CORRECT: always provide a destroy/cleanup method
class Komponen {
  private boundHandler: (data: unknown) => void;

  constructor(private emitter: EventEmitter) {
    // store a reference to the bound handler so it can be removed later
    this.boundHandler = this.handleData.bind(this);
    this.emitter.on("data", this.boundHandler);
  }

  private handleData(data: unknown): void {
    console.log(data);
  }

  // call this when the component is no longer needed
  destroy(): void {
    this.emitter.off("data", this.boundHandler);
  }
}

// the AbortController pattern for automatic cleanup
function listenSekali(
  emitter: EventEmitter,
  event: string,
  handler: (...args: unknown[]) => void,
  signal: AbortSignal
): void {
  if (signal.aborted) return;

  emitter.on(event, handler);

  // remove the listener when the signal is aborted
  signal.addEventListener("abort", () => {
    emitter.off(event, handler);
  }, { once: true });
}

// usage with AbortController
const controller = new AbortController();
const emitter = new EventEmitter();

listenSekali(emitter, "data", (d) => console.log(d), controller.signal);

emitter.emit("data", "pesan 1"); // processed
emitter.emit("data", "pesan 2"); // processed

controller.abort(); // automatic cleanup

emitter.emit("data", "pesan 3"); // not processed — the listener is removed

EventEmitter vs Other Patterns #

Use EventEmitter for:
  ✓ Components that need to be observed by many parties without direct coupling
  ✓ Domain events — something that "happens" in the system (order created, user logged in)
  ✓ Progress tracking — report the progress of long operations to observers
  ✓ Plugin systems — allow external code to react to internal events
  ✓ Integration with Node.js streams, HTTP servers, and other event-based APIs

Consider alternatives if:
  ✗ Only one listener — a callback or Promise is simpler
  ✗ Need strict type safety without boilerplate — consider libraries like mitt or eventemitter3
  ✗ Complex state management — consider patterns like Redux or Zustand
  ✗ Inter-service communication — use a message broker (Redis pub/sub, RabbitMQ, Kafka)

Summary #

  • Always register an error listener on every EventEmitter that can emit errors — if there’s no listener when emit("error", ...) is called, the Node.js process crashes.
  • Store listener references so they can be removed — inline arrow functions can’t be off()-ed because each write produces a different function; always save them to a variable.
  • once() from the events module lets you await a single event directly — far cleaner than wrapping with a manual new Promise().
  • A typed EventEmitter with interfaces is a worthwhile investment for large projects — it prevents event name typos and wrongly typed payloads that would only surface at runtime.
  • Monitor the listener count with listenerCount() and set a reasonable limit with setMaxListeners() — Node.js’s “possible memory leak” warning is a real signal, not just a notice.
  • Always provide a destroy() or cleanup method in components that register listeners on an external emitter — uncleaned listeners are the most common source of memory leaks with EventEmitter.
  • prependListener() for handlers that must run before other handlers — useful for middleware or interceptors that need to process events before the main handler.
  • Use on() from the events module to iterate events as an async iterator — very clean for processing event streams with for await...of.

← Previous: Buffer   Next: Timers →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact