Home / Backend / Cómo crear tu propio EventEmitter en Node.js paso a paso
Cómo crear tu propio EventEmitter en Node.js paso a paso

Cómo crear tu propio EventEmitter en Node.js paso a paso

22 October 2026 · 17 min de lectura ··

Entender los eventos construyendo uno mismo

Gran parte de Node.js funciona gracias a los eventos. Un servidor HTTP avisa cuando llega una petición, un stream de lectura anuncia que hay datos disponibles o que ha terminado, un socket informa de que se ha cerrado la conexión y un proceso hijo comunica que ha finalizado. Detrás de todos ellos hay una misma pieza: la clase EventEmitter del módulo events.

Usar EventEmitter es sencillo, y por eso muchos desarrolladores lo hacen durante años sin preguntarse cómo funciona por dentro. Sin embargo, implementarlo desde cero es uno de los mejores ejercicios para entender de verdad el modelo de programación de Node.js: por qué los oyentes se ejecutan en un orden determinado, por qué emitir un evento es síncrono, qué ocurre con el evento error o por qué aparece de vez en cuando un aviso sobre posibles fugas de memoria.

En esta guía vamos a construir nuestra propia clase de emisor de eventos, método a método, con una API compatible con la de Node.js. Después la usaremos en un ejemplo asíncrono realista, la compararemos con la implementación oficial y con EventTarget, y repasaremos los errores más habituales. Todo el código funciona con la versión de soporte a largo plazo activa de Node.js, la 24, y con cualquier versión reciente.

Qué es un emisor de eventos

Un emisor de eventos es un objeto que puede anunciar que algo ha ocurrido, identificando ese algo con un nombre, y al que otras partes del programa pueden suscribirse para reaccionar cuando suceda. Las funciones suscritas se llaman oyentes o listeners.

Este mecanismo es una variante del patrón observador en JavaScript: el emisor no sabe nada de quién lo escucha ni de lo que hará cada oyente, y los oyentes no necesitan saber cuándo ni por qué se produce el evento. Esto permite desacoplar componentes. Un módulo de pedidos puede anunciar que un pago se ha completado sin conocer al módulo de facturación, al de correo ni al de estadísticas, que se suscriben por su cuenta.

Se parece al modelo de publicación y suscripción, o pub/sub, pero hay una diferencia importante. En pub/sub suele existir un intermediario, un bus o un broker, entre quien publica y quien se suscribe, y a menudo la comunicación es asíncrona e incluso entre procesos. En un emisor de eventos, los oyentes se registran directamente en el objeto que emite y se llaman de forma síncrona dentro del mismo proceso.

Veamos cómo se usa el emisor oficial antes de construir el nuestro:

import { EventEmitter } from "node:events";

const pedidos = new EventEmitter();

pedidos.on("pagado", (id, importe) => {
    console.log(`Enviando factura del pedido ${id} por ${importe} €`);
});

pedidos.on("pagado", (id) => {
    console.log(`Reservando stock para el pedido ${id}`);
});

pedidos.emit("pagado", 1042, 58.4);
Enviando factura del pedido 1042 por 58.4 €
Reservando stock para el pedido 1042

Nuestro objetivo es reproducir este comportamiento y el de los métodos más importantes de la API.

La API que vamos a implementar

Estos son los métodos que tendrá nuestra clase, con el mismo nombre y comportamiento que en Node.js:

  • on y addListener: registran un oyente para un evento.
  • prependListener: registra un oyente al principio de la lista.
  • off y removeListener: eliminan un oyente concreto.
  • once: registra un oyente que se ejecuta una sola vez.
  • emit: dispara un evento, llamando a sus oyentes con los argumentos indicados.
  • listenerCount: devuelve cuántos oyentes tiene un evento.
  • rawListeners y listeners: devuelven la lista de oyentes.
  • removeAllListeners: elimina todos los oyentes de un evento o de todos.
  • eventNames: devuelve los nombres de los eventos con oyentes.
  • setMaxListeners: ajusta el umbral del aviso de posibles fugas de memoria.

Organizaremos el código en un módulo ES con esta estructura:

emisor-eventos
├── package.json
├── emisor.mjs
├── ejemplos.mjs
└── cronometrado.mjs

La estructura interna: un mapa de listas

Lo primero es decidir cómo almacenar los oyentes. Necesitamos asociar a cada nombre de evento una lista ordenada de funciones. Una estructura Map es ideal: admite cualquier tipo de clave, incluidos los símbolos que Node.js permite como nombres de evento, y no hereda propiedades indeseadas como ocurre con un objeto literal.

// emisor.mjs
export class Emisor {
    #oyentes = new Map();
    #maxOyentes = 10;
}

Los campos privados, marcados con #, impiden que el código externo manipule directamente la lista de oyentes. Es una ventaja frente a implementaciones antiguas que usaban propiedades públicas como this.listeners = {}, fáciles de corromper por accidente.

Con un objeto literal habría además un riesgo sutil: un evento llamado constructor o toString encontraría propiedades heredadas del prototipo. Map evita ese problema de raíz.

Registrar oyentes con on y addListener

El método addListener obtiene la lista del evento, o crea una nueva si no existe, y añade la función al final:

addListener(evento, fn) {
  if (typeof fn !== 'function') {
    throw new TypeError(`El oyente debe ser una función, se recibió ${typeof fn}`);
  }
  const lista = this.#oyentes.get(evento) ?? [];
  lista.push(fn);
  this.#oyentes.set(evento, lista);
  if (lista.length > this.#maxOyentes) {
    console.warn(
      `Posible fuga de memoria: ${lista.length} oyentes para "${String(evento)}"`
    );
  }
  return this;
}

on(evento, fn) {
  return this.addListener(evento, fn);
}

Hay tres detalles importantes.

La validación del tipo detecta pronto un error muy común: pasar el resultado de llamar a una función en lugar de la propia función. Sin ella, el fallo aparecería mucho después, al emitir el evento, y sería más difícil de localizar.

Devolver this permite encadenar llamadas, igual que en Node.js:

new Emisor()
    .on("conectado", () => console.log("Conectado"))
    .on("desconectado", () => console.log("Desconectado"));

Y el aviso cuando se supera un número de oyentes reproduce el comportamiento de Node.js, que avisa a partir de diez oyentes para un mismo evento. No es un límite, sino una señal de que quizá se están registrando oyentes en un bucle o en cada petición sin eliminarlos nunca.

on es simplemente un alias de addListener. Ambos nombres existen en la API oficial, y on es con diferencia el más usado.

A veces interesa que un oyente se ejecute antes que los ya registrados. Para eso existe prependListener, que inserta al principio de la lista:

prependListener(evento, fn) {
  const lista = this.#oyentes.get(evento) ?? [];
  lista.unshift(fn);
  this.#oyentes.set(evento, lista);
  return this;
}
const registro = new Emisor();
registro.on("log", () => console.log("segundo"));
registro.prependListener("log", () => console.log("primero"));
registro.emit("log");
primero
segundo

Disparar eventos con emit

El método emit es el corazón del emisor. Busca la lista de oyentes del evento y llama a cada uno, en orden de registro, con los argumentos recibidos. Devuelve true si había oyentes y false en caso contrario:

emit(evento, ...args) {
  const lista = this.#oyentes.get(evento);
  if (!lista || lista.length === 0) {
    if (evento === 'error') {
      throw args[0] instanceof Error
        ? args[0]
        : new Error(`Evento "error" no gestionado: ${args[0]}`);
    }
    return false;
  }
  for (const fn of [...lista]) {
    fn.apply(this, args);
  }
  return true;
}

Fíjate en que recorremos una copia del array de oyentes creada con el operador de propagación. Esto es fundamental: si un oyente se elimina a sí mismo durante la emisión, como hacen los registrados con once, modificar el array original mientras se recorre haría que se saltara el oyente siguiente. Al iterar sobre una copia, la emisión en curso no se ve afectada por los cambios.

Usar fn.apply(this, args) hace que dentro de los oyentes declarados con function, this apunte al emisor, igual que en Node.js. Las funciones flecha, en cambio, conservan el this del contexto donde se definieron.

Comprobemos el funcionamiento:

import { Emisor } from "./emisor.mjs";

const pedidos = new Emisor();

pedidos.on("pagado", (id, importe) => {
    console.log(`Enviando factura del pedido ${id} por ${importe} €`);
});
pedidos.on("pagado", (id) => {
    console.log(`Reservando stock para el pedido ${id}`);
});

console.log(pedidos.emit("pagado", 1042, 58.4));
console.log(pedidos.emit("cancelado", 1042));
Enviando factura del pedido 1042 por 58.4 €
Reservando stock para el pedido 1042
true
false

Emitir es síncrono

Una de las cosas que más sorprenden al aprender Node.js es que emit es completamente síncrono. Los oyentes se ejecutan en el momento de la llamada, uno tras otro, y emit no devuelve el control hasta que todos han terminado:

const sincrono = new Emisor();
sincrono.on("evento", () => console.log("oyente"));

console.log("antes");
sincrono.emit("evento");
console.log("después");
antes
oyente
después

Esto tiene consecuencias prácticas. Un oyente lento bloquea a todos los demás y al código que emitió el evento. Si un oyente lanza una excepción, los oyentes siguientes no se ejecutan y la excepción se propaga hasta quien llamó a emit.

Si necesitas que un oyente haga un trabajo pesado sin bloquear, puedes diferir su ejecución con setImmediate o queueMicrotask, o delegar el trabajo en una cola o en un hilo de trabajo:

pedidos.on("pagado", (id) => {
    setImmediate(() => generarInformePesado(id));
});

El evento error y su tratamiento especial

En Node.js, el evento error tiene un comportamiento especial: si se emite y no hay ningún oyente registrado para él, el emisor lanza el error como excepción. Si nadie la captura, el proceso termina.

Hemos reproducido ese comportamiento en nuestro emit. Un evento error sin oyentes detiene la ejecución:

const conexion = new Emisor();

try {
    conexion.emit("error", new Error("Fallo de conexión"));
} catch (err) {
    console.log("Capturado:", err.message);
}
Capturado: Fallo de conexión

El objetivo de esta regla es que los errores no pasen desapercibidos. Por eso, cualquier objeto que emita errores, como un stream, un socket o un cliente de base de datos, debería tener siempre un oyente para error:

conexion.on("error", (err) => {
    console.error("Error en la conexión:", err.message);
});

Eliminar oyentes con off y removeListener

Para dejar de escuchar un evento hay que eliminar el oyente pasando exactamente la misma función que se registró. El método busca la función en la lista, desde el final hacia el principio, y la elimina:

removeListener(evento, fn) {
  const lista = this.#oyentes.get(evento);
  if (!lista) return this;
  for (let i = lista.length - 1; i >= 0; i--) {
    if (lista[i] === fn || lista[i].oyenteOriginal === fn) {
      lista.splice(i, 1);
      break;
    }
  }
  if (lista.length === 0) this.#oyentes.delete(evento);
  return this;
}

off(evento, fn) {
  return this.removeListener(evento, fn);
}

Recorrer la lista de atrás hacia delante y eliminar solo la primera coincidencia reproduce el comportamiento de Node.js: si la misma función se ha registrado varias veces, cada llamada a off elimina una sola instancia, la más reciente.

Hay que tener cuidado con los límites del bucle. Un error clásico al escribir este método es usar i > 0 en lugar de i >= 0, con lo que el primer oyente de la lista nunca se puede eliminar.

La comparación con oyenteOriginal permite eliminar oyentes registrados con once, como veremos en la siguiente sección. Y borrar la entrada del mapa cuando la lista queda vacía mantiene limpia la estructura y hace que eventNames solo devuelva eventos con oyentes.

La consecuencia práctica más importante es que no puedes eliminar una función anónima, porque no tienes ninguna referencia a ella:

// No funciona: son dos funciones distintas aunque tengan el mismo código
pedidos.on("pagado", () => console.log("hola"));
pedidos.off("pagado", () => console.log("hola"));

// Correcto: guarda la referencia
const saludar = () => console.log("hola");
pedidos.on("pagado", saludar);
pedidos.off("pagado", saludar);

Oyentes de un solo uso con once

El método once registra un oyente que se ejecuta como máximo una vez. La técnica consiste en registrar un envoltorio que se elimina solo antes de llamar a la función original:

once(evento, fn) {
  const envoltorio = (...args) => {
    this.off(evento, envoltorio);
    return fn.apply(this, args);
  };
  envoltorio.oyenteOriginal = fn;
  return this.on(evento, envoltorio);
}

El envoltorio se elimina antes de ejecutar la función, de modo que si esta vuelve a emitir el mismo evento, no se produce una llamada recursiva. Además, pasa todos los argumentos a la función original con ...args, algo que es fácil olvidar en una primera implementación.

La propiedad oyenteOriginal guarda una referencia a la función original. Gracias a ella, off puede eliminar un oyente registrado con once usando la función original, que es lo que el usuario conoce, y no el envoltorio interno. Node.js usa exactamente la misma idea, con una propiedad llamada listener.

const servidor = new Emisor();

servidor.once("conectado", (host) => console.log(`Conectado a ${host}`));

servidor.emit("conectado", "db.local");
servidor.emit("conectado", "db.local");
console.log(servidor.listenerCount("conectado"));
Conectado a db.local
0

Y eliminar un oyente de un solo uso antes de que se ejecute funciona como se espera:

const avisar = () => console.log("Aviso");
servidor.once("apagado", avisar);
servidor.off("apagado", avisar);
console.log(servidor.emit("apagado"));
false

Consultar y limpiar los oyentes

Los métodos restantes permiten inspeccionar el estado del emisor y hacer limpieza:

listenerCount(evento) {
  return this.#oyentes.get(evento)?.length ?? 0;
}

rawListeners(evento) {
  return [...(this.#oyentes.get(evento) ?? [])];
}

listeners(evento) {
  return this.rawListeners(evento).map((fn) => fn.oyenteOriginal ?? fn);
}

removeAllListeners(evento) {
  if (evento === undefined) this.#oyentes.clear();
  else this.#oyentes.delete(evento);
  return this;
}

eventNames() {
  return [...this.#oyentes.keys()];
}

setMaxListeners(n) {
  this.#maxOyentes = n;
  return this;
}

La diferencia entre rawListeners y listeners reproduce la de Node.js: el primero devuelve las funciones tal como están almacenadas, incluidos los envoltorios de once, y el segundo devuelve las funciones originales. Ambos devuelven una copia, para que modificar el array resultante no altere el estado interno del emisor.

const panel = new Emisor();
const registrar = () => {};
const avisarUnaVez = () => {};

panel.on("alerta", registrar);
panel.once("alerta", avisarUnaVez);

console.log(panel.listenerCount("alerta"));
console.log(panel.listeners("alerta")[1] === avisarUnaVez);
console.log(panel.rawListeners("alerta")[1] === avisarUnaVez);
console.log(panel.eventNames());
2
true
false
[ 'alerta' ]

Un ejemplo realista: medir tareas asíncronas

Los emisores de eventos brillan cuando se usan como clase base. Muchas clases de Node.js, como los servidores, los streams o los procesos hijo, heredan de EventEmitter para notificar su ciclo de vida. Hagamos lo mismo con una clase que ejecuta tareas asíncronas y emite eventos al empezar, al terminar, al recibir datos y al fallar:

// cronometrado.mjs
import { Emisor } from "./emisor.mjs";

export class Cronometrado extends Emisor {
    async ejecutar(nombre, tarea) {
        this.emit("inicio", nombre);
        const t0 = performance.now();
        try {
            const resultado = await tarea();
            this.emit("datos", resultado);
            return resultado;
        } catch (err) {
            this.emit("error", err);
        } finally {
            this.emit("fin", nombre, Math.round(performance.now() - t0));
        }
    }
}
import { Cronometrado } from "./cronometrado.mjs";

const medidor = new Cronometrado();

medidor.on("inicio", (nombre) => console.log(`Iniciando ${nombre}`));
medidor.on("datos", (datos) =>
    console.log(`Recibidos ${datos.length} productos`)
);
medidor.on("fin", (nombre, ms) => console.log(`${nombre} terminó en ${ms} ms`));
medidor.on("error", (err) => console.error(`Error: ${err.message}`));

const consultarCatalogo = async () => {
    const respuesta = await fetch("https://api.tienda.example/productos");
    if (!respuesta.ok) throw new Error(`HTTP ${respuesta.status}`);
    return respuesta.json();
};

await medidor.ejecutar("catálogo", consultarCatalogo);
Iniciando catálogo
Recibidos 3 productos
catálogo terminó en 102 ms

La clase no sabe nada de cómo se muestran los resultados: solo anuncia lo que ocurre. Podríamos añadir un oyente que envíe las métricas a un sistema de monitorización, otro que escriba en un log y otro que actualice una barra de progreso, sin tocar ni una línea de Cronometrado. Ese desacoplamiento es la principal ventaja del patrón.

Desde hace varias versiones, fetch está disponible de forma nativa en Node.js, por lo que ya no hace falta instalar librerías como node-fetch para hacer peticiones HTTP.

Oyentes asíncronos: una trampa frecuente

Como emit es síncrono, no espera a que terminen los oyentes declarados con async. Llama a cada uno, obtiene una promesa y la ignora:

pedidos.on("pagado", async (id) => {
    await enviarCorreo(id);
    console.log("Correo enviado");
});

pedidos.emit("pagado", 1042);
console.log("emit ha terminado");
emit ha terminado
Correo enviado

El problema más grave es que, si la promesa se rechaza, el error no pasa por el evento error ni por ningún bloque try, y termina como un rechazo de promesa no gestionado, que en las versiones actuales de Node.js finaliza el proceso por defecto.

Hay dos soluciones. La primera es gestionar los errores dentro del propio oyente asíncrono. La segunda, disponible en el emisor oficial, es activar la opción captureRejections, que redirige los rechazos de los oyentes asíncronos al evento error:

import { EventEmitter } from "node:events";

const emisor = new EventEmitter({ captureRejections: true });

emisor.on("pagado", async () => {
    throw new Error("No se pudo enviar el correo");
});

emisor.on("error", (err) => console.error("Gestionado:", err.message));

emisor.emit("pagado");
Gestionado: No se pudo enviar el correo

Fugas de memoria y el aviso de máximo de oyentes

Cada oyente registrado mantiene una referencia a su función y, a través de ella, a todas las variables de su ámbito. Si se registran oyentes que nunca se eliminan, por ejemplo uno nuevo en cada petición HTTP sobre un emisor global, la memoria crece sin control.

Para ayudar a detectar estas fugas de memoria con oyentes, Node.js muestra un aviso cuando un evento supera los diez oyentes:

(node:454) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 x listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit

Antes de aumentar el límite con setMaxListeners, comprueba si realmente necesitas tantos oyentes o si estás olvidando eliminarlos. La práctica correcta es eliminar los oyentes cuando el objeto que los registró deja de existir, por ejemplo cuando se cierra una conexión o se desmonta un componente.

El EventTarget estándar acepta la opción signal al registrar oyentes, lo que permite eliminar varios a la vez cancelando un AbortController, y el módulo node:events incluye la función addAbortListener para reaccionar de forma segura a la cancelación de una señal.

Utilidades modernas del módulo events

El módulo node:events incluye funciones que integran los eventos con las promesas y los iteradores asíncronos, muy útiles con async/await.

La función once devuelve una promesa que se resuelve la primera vez que se emite un evento, con un array de sus argumentos. Si antes se emite error, la promesa se rechaza:

import { EventEmitter, once } from "node:events";

const servidor = new EventEmitter();
setTimeout(() => servidor.emit("listo", 8080), 10);

const [puerto] = await once(servidor, "listo");
console.log(`Servidor listo en el puerto ${puerto}`);
Servidor listo en el puerto 8080

La función on convierte un evento en un iterador asíncrono, que se puede recorrer con for await. Para detenerlo se usa un AbortController:

import { EventEmitter, on } from "node:events";

const sensor = new EventEmitter();
const controlador = new AbortController();

setTimeout(() => {
    sensor.emit("lectura", 21.5);
    sensor.emit("lectura", 22.1);
    sensor.emit("lectura", 23.4);
}, 10);

let lecturas = 0;
try {
    for await (const [temperatura] of on(sensor, "lectura", {
        signal: controlador.signal,
    })) {
        console.log(`Temperatura: ${temperatura} °C`);
        if (++lecturas === 3) controlador.abort();
    }
} catch (err) {
    console.log(err.name);
}
Temperatura: 21.5 °C
Temperatura: 22.1 °C
Temperatura: 23.4 °C
AbortError

EventEmitter frente a EventTarget

El navegador tiene su propio sistema de eventos, EventTarget, que es el que usan los elementos del DOM con addEventListener. Node.js también lo incluye de forma global, junto con Event y CustomEvent:

const canal = new EventTarget();

canal.addEventListener(
    "mensaje",
    (evento) => console.log("Recibido:", evento.detail),
    { once: true }
);

canal.dispatchEvent(new CustomEvent("mensaje", { detail: { texto: "hola" } }));
Recibido: { texto: 'hola' }

Las diferencias principales son estas. EventTarget es un estándar web y funciona igual en el navegador, Node.js, Deno y Bun, mientras que EventEmitter es específico de Node.js, aunque existen implementaciones para el navegador. Con EventTarget, los oyentes reciben un objeto Event en lugar de argumentos sueltos. El evento error no tiene tratamiento especial en EventTarget. Y EventTarget admite de serie opciones como once y signal.

En código que deba ejecutarse en distintos entornos, EventTarget es una opción cada vez más recomendable. En código específico de Node.js, EventEmitter sigue siendo lo más habitual por su API más directa y su integración con el resto del ecosistema.

Cuándo usar tu propia implementación

Para proyectos de Node.js, lo recomendable es usar el EventEmitter oficial, que está muy optimizado y probado. Nuestra implementación tiene un valor principalmente didáctico, pero un emisor propio puede tener sentido en algunos casos:

  • En código para el navegador que necesita una API al estilo de Node.js sin dependencias.
  • Cuando quieres un emisor tipado con TypeScript, donde cada evento tiene definidos sus argumentos.
  • En entornos restringidos donde no está disponible el módulo events.

Para el caso del tipado, hay librerías pequeñas y muy usadas, como mitt, eventemitter3 o nanoevents, que ofrecen emisores ligeros con buen soporte de tipos.

Conclusiones

Construir un emisor de eventos desde cero es una forma muy eficaz de entender uno de los pilares de Node.js. En esencia, se trata de un mapa que asocia nombres de eventos con listas de funciones: on añade funciones a la lista, off las elimina, once registra un envoltorio que se borra a sí mismo y emit recorre la lista llamando a cada función con los argumentos recibidos.

Los detalles marcan la diferencia entre una implementación de juguete y una robusta: validar que los oyentes son funciones, iterar sobre una copia de la lista durante la emisión, eliminar solo una instancia en cada llamada a off, conservar una referencia a la función original en los oyentes de un solo uso y tratar de forma especial el evento error.

También hemos visto las implicaciones prácticas del modelo: la emisión es síncrona, los oyentes asíncronos pueden provocar rechazos no gestionados si no se tratan, y los oyentes que nunca se eliminan son una fuente habitual de fugas de memoria. Las utilidades modernas del módulo node:events, como once y on, integran los eventos con las promesas y los iteradores asíncronos, y EventTarget ofrece una alternativa estándar y multiplataforma.

En proyectos reales, usa el emisor oficial o una librería ligera bien probada. Pero el conocimiento que da haberlo construido te ayudará a diseñar mejores APIs basadas en eventos y a diagnosticar con rapidez los problemas que aparecen al usarlas.