Pub/Sub y event emitters: el arte de no conocerse
El patrón publicador-suscriptor lleva el desacoplamiento del Observer un paso más allá al meter un intermediario —un canal, un bus de eventos— entre emisor y receptor, hasta que ninguno conoce al otro. EventTarget y EventEmitter son sus encarnaciones en el navegador y en Node; entender sus fortalezas y sus límites explica por qué no bastan para gobernar estado.
El Observer desacopla al sujeto de lo que hacen sus observadores, pero el sujeto todavía guarda una referencia a cada uno: se conocen. Pub/Sub da el último paso y mete un intermediario en medio —un canal, un tema, un bus— para que publicador y suscriptor jamás se toquen. EventTarget en el navegador y EventEmitter en Node son las dos encarnaciones que usarás a diario, y sus límites son tan instructivos como sus virtudes.
- Diferenciar el Observer directo del pub/sub mediado por un canal.
- Manejar
EventTargetconaddEventListener,dispatchEventyCustomEvent. - Manejar el
EventEmitterde Node conon,once,offyemit. - Nombrar los límites del modelo: sin valor actual, sin tipos, sin backpressure.
Del Observer al Pub/Sub: el intermediario
En el Observer, el sujeto mantiene la lista de observadores: están desacoplados en tipo —el sujeto solo conoce el contrato update— pero acoplados en referencia, porque el sujeto sostiene a cada observador. Pub/Sub inserta un tercero entre ambos. El publicador emite hacia un canal identificado por un nombre; el suscriptor escucha ese mismo nombre; y ninguno tiene una referencia al otro. El canal es el único que conoce a las dos partes.
La diferencia con el Observer es sutil pero decisiva. En el Observer, si tienes el sujeto en la mano puedes enumerar a sus observadores: la relación es directa y visible. En pub/sub el sujeto ni siquiera existe como tal; hay un canal anónimo y dos poblaciones —quienes publican y quienes escuchan— que solo comparten un nombre acordado. Esa anonimia es la fuente de toda su flexibilidad y de todos sus problemas.
Ese salto compra tres grados de desacoplamiento. Desacoplamiento en el espacio: emisor y receptor no se conocen ni se importan. En el tiempo: la emisión y la reacción pueden ocurrir en momentos distintos, y el modelo se presta a lo asíncrono. Y en la sincronización: el emisor no se bloquea esperando a que los receptores terminen.
A cambio pagas un precio que recorrerá toda la lección: la indirección hace el flujo más difícil de seguir. Para saber quién reacciona a un evento hay que buscar por todo el código quién escucha ese nombre, y esa búsqueda —a diferencia de seguir una llamada directa— no la puede hacer por ti ninguna herramienta con garantía total.
EventTarget: el estándar del navegador, y ya de todos
EventTarget es el pub/sub de la plataforma web, y en 2026 es verdaderamente universal: existe como global en el navegador, en Node, en Deno, en Bun y en los Workers al borde. Su API es addEventListener, removeEventListener y dispatchEvent, y CustomEvent transporta una carga en su propiedad detail. La forma idiomática moderna de dar de baja no es guardar la función y llamar a removeEventListener, sino atar la suscripción a un AbortSignal:
class Termostato extends EventTarget {
#temp = 20;
set(t: number) {
this.#temp = t;
this.dispatchEvent(new CustomEvent("cambio", { detail: t }));
}
}
const termo = new Termostato();
const ac = new AbortController();
termo.addEventListener(
"cambio",
(e) => console.log("nueva temp", (e as CustomEvent<number>).detail),
{ signal: ac.signal }, // la baja se ata a este signal
);
termo.set(22);
ac.abort(); // cancela TODAS las suscripciones ligadas a este signal
El idioma del AbortSignal es la mejor herramienta que la plataforma ofrece para el problema que veremos en la próxima lección: un solo abort desmonta de golpe todas las escuchas que compartan ese signal, en vez de obligarte a recordar cada removeEventListener por separado.
Domar la falta de tipos
El EventTarget crudo acepta cualquier cadena como nombre de evento y cualquier cosa como carga, así que un error de tecleo se vuelve un fallo mudo. El patrón de 2026 es envolverlo en una clase con métodos tipados que oculten las cadenas mágicas:
type Eventos = { cambio: number; error: Error };
class Emisor<M> extends EventTarget {
emitir<K extends string & keyof M>(tipo: K, detalle: M[K]) {
this.dispatchEvent(new CustomEvent(tipo, { detail: detalle }));
}
en<K extends string & keyof M>(tipo: K, fn: (d: M[K]) => void, o?: AddEventListenerOptions) {
this.addEventListener(tipo, (e) => fn((e as CustomEvent<M[K]>).detail), o);
}
}
Ahora emisor.emitir("cambio", 22) compila y emisor.emitir("cambo", 22) no: el compilador recupera el control que el nombre de cadena le había arrebatado. Es el mismo movimiento que hacen las librerías de eventos tipados y los emitters que envuelven websockets o colas de mensajes.
Ese envoltorio no cambia el mecanismo —sigue siendo EventTarget por debajo— pero cura dos de las cuatro grietas: pone tipos donde había cadenas libres y concentra el alta y la baja en un único lugar. Las otras dos, la ausencia de valor actual y el grafo implícito, son estructurales, y ningún envoltorio las remedia: para eso hay que cambiar de primitivo.
EventEmitter: el estándar de Node
Del lado del servidor, el EventEmitter de Node es el patrón por antonomasia —lo heredan los streams, los sockets y los procesos hijos—. Su API es on para escuchar, once para escuchar una sola vez, off para dar de baja y emit para publicar. Por defecto es síncrono: emit invoca a cada escucha en orden, una tras otra, antes de devolver el control.
import { EventEmitter } from "node:events";
const bus = new EventEmitter();
bus.on("pedido", (id: number) => console.log("procesa", id));
bus.once("arranque", () => console.log("solo la primera vez"));
bus.emit("arranque"); // dispara
bus.emit("arranque"); // ya no dispara: era once
bus.emit("pedido", 42);
Node incluye dos puentes muy 2026 hacia otros modelos: events.once(emitter, nombre) devuelve una promesa que resuelve con el próximo evento, y events.on(emitter, nombre) devuelve un iterador asíncrono que convierte un flujo de eventos push en algo que consumes con for await —un anticipo directo del eje push vs pull—.
Aunque EventTarget y EventEmitter resuelven lo mismo, sus convenciones difieren y conviene no mezclarlas: EventTarget empaqueta la carga en event.detail y da de baja con AbortSignal, mientras que EventEmitter pasa los argumentos sueltos al callback y da de baja con off. En código isomorfo —el que corre en navegador y servidor— EventTarget es hoy la apuesta segura, porque es el que existe en las dos orillas.
flowchart LR P1[Publisher A] -->|publish| B[Canal o bus de eventos] P2[Publisher B] -->|publish| B B -->|deliver| S1[Subscriber 1] B -->|deliver| S2[Subscriber 2] style B fill:#cba6f7,color:#11111b style P1 fill:#89b4fa,color:#11111b style P2 fill:#89b4fa,color:#11111b style S1 fill:#a6e3a1,color:#11111b style S2 fill:#a6e3a1,color:#11111b
Los límites que te expulsan hacia otros primitivos
Los event emitters son excelentes para eventos transversales —un clic, un mensaje de socket, un ciclo de vida— pero fracasan como columna vertebral del estado, y sus límites explican por qué. No son defectos de una librería concreta, sino consecuencias de qué es un evento: algo que ocurre y se desvanece.
Sin valor actual
Un evento es efímero: quien se suscribe después de la emisión no la recibe. No hay estado, solo instantes. El Subject de la lección anterior entregaba el valor presente al entrar; aquí no existe tal cosa.
Sin tipos
Los nombres de evento son cadenas y las cargas son libres. Sin un envoltorio tipado, un error de tecleo en "cambio" es un fallo silencioso que el compilador nunca ve.
Sin backpressure
No hay forma de frenar a un productor veloz ni de anunciar que ya no habrá más eventos. Un emisor rápido inunda a un consumidor lento sin que nadie proteste.
Grafo implícito
Quién escucha a quién queda disperso por el código. El desacoplamiento que ganas en flexibilidad lo pagas en trazabilidad: seguir el flujo se vuelve arqueología.
Hay más grietas técnicas. El manejo de errores es traicionero: en EventEmitter, un evento error sin escucha lanza y tumba el proceso, y como el despacho es síncrono, una excepción en una escucha puede interrumpir a las siguientes que iban a correr en esa misma emisión.
Y la propia gestión de memoria vuelve a morder: Node avisa con un MaxListenersExceededWarning al superar diez escuchas sobre un mismo evento, precisamente porque acumular suscripciones sin darlas de baja es la fuga más común del patrón. Es el mismo lapsed listener del Observer, ahora con una heurística que te lo grita, y es el tema entero de la próxima lección.
Pub/Sub seduce porque parece resolver el acoplamiento de una vez por todas: metes un canal en medio y de repente cualquiera puede emitir y cualquiera puede escuchar sin conocerse. Pero ese desacoplamiento no es gratis, es un préstamo que pagas en trazabilidad. En el Observer directo, mirar el sujeto te decía quién depende de él. Con un bus de eventos, esa información se disuelve: para reconstruir el grafo de reacciones tienes que buscar por todo el código base quién escucha cada nombre de cadena, y nada garantiza que lo encuentres todo. Por eso los event emitters brillan para lo que de verdad son eventos —cosas que ocurren, se anuncian y se olvidan: un clic, la llegada de un paquete, el cierre de una conexión— y fracasan cuando los fuerzas a ser el sustrato del estado de una aplicación. Un evento no es un estado: el estado persiste y tiene un valor actual que puedes consultar, mientras que un evento es un instante sin memoria. Confundirlos lleva al antipatrón clásico de reconstruir el estado escuchando eventos y guardando copias locales que se desincronizan. La regla que sobrevive a todas las modas: usa pub/sub para notificar que algo pasó, no para custodiar lo que las cosas son. Para lo segundo necesitas un primitivo con valor y con un grafo explícito de dependencias, y hacia él apunta el resto del nivel.
- Crea una clase que extienda
EventTargety emita unCustomEventcon carga endetailal cambiar un valor. - Suscríbete con la opción
{ signal }de unAbortControllery comprueba que un soloabortcancela varias escuchas a la vez. - En Node, monta un
EventEmitter, registra una escucha cononcey demuestra que solo dispara la primera emisión. - Provoca el límite del valor actual: emite un evento y suscríbete después; confirma que el nuevo suscriptor no recibe nada y explica por qué eso descalifica al emitter como almacén de estado.