Canales de difusión: avisar a todos sin compartir un hilo
Los canales de difusión notifican a todas las pestañas del mismo origen sin necesidad de un hilo común, resuelven muy bien la invalidación de cachés y no sirven como cerrojo porque no ofrecen orden, entrega ni membresía.
Entre no tener nada y tener un hilo compartido hay una pieza intermedia, disponible en todas partes y de una simplicidad casi insultante: un bus de mensajes por nombre, dentro del origen, al que cualquier documento puede asomarse. Con tres líneas, una pestaña avisa a las demás de que algo cambió. Esa facilidad es precisamente el peligro, porque invita a construir sobre ella cosas que no puede sostener. La disciplina de esta lección consiste en separar con nitidez dos usos que se parecen mucho y son radicalmente distintos: notificar, que es exactamente para lo que sirve, y coordinar, que es exactamente para lo que no sirve, por razones que no dependen de cuánto cuidado pongas al escribirlo.
- Conocer la semántica exacta del canal de difusión: quién recibe, quién no y con qué garantías.
- Usarlo bien para invalidar cachés y sincronizar interfaces, con versiones monótonas e idempotencia.
- Demostrarse a uno mismo por qué un protocolo de elección construido sobre él está roto.
- Reconocer los límites que impone el ciclo de vida de los documentos: congelación, pérdida y coste por receptor.
El canal, y lo que promete exactamente
Se construye con un nombre. Todos los objetos creados con ese mismo nombre dentro del mismo origen quedan conectados entre sí, incluidos los de otras pestañas, los de los iframes, los de los workers y los del Service Worker. Un mensaje enviado se entrega mediante clonado estructurado a todos ellos excepto al que lo envió, detalle que evita el eco pero que despista la primera vez, porque la pestaña que escribe no recibe su propio aviso y tiene que actualizarse por su cuenta.
const canal = new BroadcastChannel('datos');
// Emisor: avisa de QUE cambio, no manda el dato
async function guardarNota(nota) {
const version = await escribirEnIndexedDB(nota);
canal.postMessage({ tipo: 'cambio', entidad: 'nota', id: nota.id, version });
}
// Receptores: releen de la fuente de verdad
canal.onmessage = (e) => {
const { entidad, id, version } = e.data;
if (version <= versionVista.get(id)) return; // llegan desordenados
versionVista.set(id, version);
recargarDesdeIndexedDB(entidad, id);
};
Conviene situarlo frente a su antecesor, porque mucho código heredado sigue usándolo. Antes de esta API, la forma de avisar entre pestañas era el evento que emite localStorage al escribir, y funcionaba por accidente más que por diseño: obligaba a serializar todo a texto, contaminaba el almacenamiento con datos que no querías guardar, arrastraba el coste de una API síncrona que bloquea el hilo principal y solo servía para notificar entre documentos, nunca hacia un worker. El canal de difusión hace lo mismo de manera explícita, con clonado estructurado en lugar de texto, sin tocar el disco y alcanzando también a los contextos sin ventana. Si encuentras lo primero en tu base de código, migrarlo es una mejora sin contrapartidas.
Lo que el canal promete termina ahí, y conviene enumerar lo que no promete, porque la lista es larga y no está escrita en ningún sitio evidente. No hay orden total entre mensajes de emisores distintos: dos pestañas que emiten a la vez pueden llegar en cualquier orden y en órdenes distintos a cada receptor. No hay confirmación de entrega ni forma de saber si alguien escuchaba. No hay persistencia: una pestaña que abre un segundo después de un mensaje nunca sabrá que existió. No hay membresía: no puedes preguntar cuántos participantes hay. Y no hay transferencia: a diferencia de otros canales, no admite ceder un puerto ni un búfer, solo copias.
La tentación de enviar el registro completo por el canal es fuerte y casi siempre equivocada. Primero por coste: cada mensaje se clona una vez por receptor, así que difundir objetos grandes a cuatro pestañas es pagar cuatro serializaciones para un dato que ya está en disco. Segundo por corrección: si el mensaje se pierde o llega desordenado, las pestañas quedan con estados distintos y sin forma de detectarlo. Enviando solo un identificador y una versión, el peor caso de una pérdida es una interfaz momentáneamente atrasada que se corrige en la siguiente lectura, porque la verdad sigue estando donde debe estar, que es el almacenamiento compartido. El canal es un timbre, no un camión de mudanzas.
Lo que resuelve bien: invalidar, no transportar
Con esa regla en la mano, la lista de problemas que el canal resuelve limpiamente es respetable, y cubre buena parte de lo que una aplicación multipestaña necesita a diario.
Invalidar cachés en memoria
IndexedDB no emite eventos de cambio. El canal es la forma barata y estándar de decir a las demás pestañas qué clave ha quedado obsoleta para que la relean.
Propagar sesión y ajustes
Cerrar sesión en una pestaña debe cerrarla en todas. Lo mismo vale para el tema, el idioma o cualquier preferencia que el usuario espera ver aplicada en todas partes.
Descubrimiento y anuncios
Anunciar quién es el líder actual, o preguntar si hay alguno, es un uso legítimo: difundir un hecho ya decidido en otro sitio no es lo mismo que decidirlo aquí.
Coordinación cosmética
Evitar que cuatro pestañas muestren la misma notificación, o que todas animen a la vez un indicador de sincronización, tolera perfectamente la pérdida de un mensaje.
El criterio que separa los usos buenos de los malos se puede formular en una sola pregunta: ¿qué pasa si este mensaje se pierde? Si la respuesta es que la interfaz queda un momento desactualizada y se corregirá sola, el canal es la herramienta adecuada. Si la respuesta es que dos pestañas escribirán a la vez, que se duplicará un registro o que un fichero se corromperá, entonces estás usando un timbre como si fuera un cerrojo y el fallo no es cuestión de si ocurrirá, sino de cuándo.
Dos disciplinas hacen que los usos buenos sean robustos. La primera es la monotonía: todo aviso lleva una versión creciente, y el receptor descarta lo que no la supera, con lo que el desorden y la duplicación dejan de importar. La segunda es la idempotencia: procesar dos veces el mismo aviso debe dar el mismo resultado que procesarlo una, lo que se consigue reaccionando siempre con una relectura desde el almacenamiento en vez de aplicando un delta sobre el estado en memoria.
flowchart TB A[Pestana A escribe en IndexedDB] --> B[Emite aviso con id y version] B --> C[Pestana B recibe] B --> D[Pestana C recibe] B --> E[Pestana D congelada no recibe] C --> F[Relee de IndexedDB] D --> F E --> G[Queda obsoleta hasta que despierte] style B fill:#89b4fa,color:#11111b style F fill:#a6e3a1,color:#11111b style G fill:#f38ba8,color:#11111b
Por qué no es un mecanismo de exclusión mutua
Antes o después alguien propone construir la elección de líder sobre el canal, y el código que resulta siempre se parece a este. Merece la pena escribirlo entero, porque es más instructivo entender por qué falla que evitarlo por decreto.
// ANTIPATRON: esto no elige a nadie, solo lo parece
const miId = crypto.randomUUID();
let soyLider = true;
canal.onmessage = (e) => {
if (e.data.tipo !== 'candidatura') return;
if (e.data.id < miId) soyLider = false; // gana el identificador menor
};
canal.postMessage({ tipo: 'candidatura', id: miId });
setTimeout(() => { if (soyLider) abrirLaBase(); }, 200);
El fallo no está en un detalle corregible, está en cuatro sitios a la vez. El plazo de doscientos milisegundos es una conjetura sobre el tiempo de red de un sistema que no tiene ninguna cota superior conocida; una pestaña ocupada en una tarea larga responderá tarde y ambas se creerán líderes. Una pestaña que abre en el milisegundo doscientos uno nunca vio la ronda y se declarará líder sobre otro que ya lo era. Una pestaña congelada en la caché de retroceso no responde, se la da por ausente, y despierta convencida de su cargo. Y, sobre todo, no hay ninguna operación atómica: entre difundir la candidatura y decidir el resultado hay una ventana en la que otro puede hacer exactamente lo mismo, y ningún participante tiene autoridad para resolver el empate.
El vicio común a todos estos protocolos caseros es tratar la ausencia de respuesta como información. No lo es. En un sistema asíncrono, no recibir contestación en el plazo que tú elegiste es compatible con al menos cuatro mundos distintos: el otro está muerto, el otro está congelado, el otro está saturado, o tu mensaje se entregó pero su respuesta aún no llegó. Como el canal no ofrece confirmaciones, ni siquiera puedes distinguir “no me contestó” de “no le llegó”. Cualquier decisión de exclusión mutua basada en un temporizador hereda esa ambigüedad y la convierte en dos escritores simultáneos, que es precisamente el estado que querías impedir.
Puesto del revés, el diagnóstico se vuelve constructivo. Un mecanismo de exclusión mutua necesita dos ingredientes que este canal no tiene y que ninguna cantidad de código encima puede fabricar. El primero es una operación atómica de adquisición, algo equivalente a comparar e intercambiar, ejecutada por una autoridad que serialice las peticiones simultáneas y responda con un sí a una y un no a las demás; difundir una intención y esperar objeciones no es atómico ni de lejos. El segundo es un detector de fallos fiable, capaz de afirmar que el titular ya no está sin depender de que él coopere anunciándolo. La plataforma tiene las dos cosas, pero no aquí: las tiene en la API de bloqueos, porque el navegador serializa las peticiones y porque destruye él mismo los documentos y sabe cuándo lo ha hecho. Insistir en construirlas sobre un bus de mensajes es reescribir peor algo que ya está ahí.
Hay además dos limitaciones prácticas que el canal impone y que conviene conocer antes de apoyarse en él para nada delicado. La primera es la congelación: un documento en la caché de retroceso y avance no procesa mensajes, y en varios navegadores mantener un canal abierto con escuchadores puede impedir que la página entre en esa caché, con lo que eliges entre recibir avisos o navegar rápido hacia atrás. La segunda es el coste por receptor: cada mensaje se clona íntegro para cada destinatario, de modo que un bus por el que circula tráfico dirigido a un solo interlocutor obliga a todas las pestañas a deserializar mensajes que no les incumben, y eso se paga en el hilo principal de cada una.
Disciplinas para convivir con la difusión
Nada de lo anterior descalifica al canal; lo sitúa. Cuatro hábitos bastan para que resulte una pieza sólida dentro de una arquitectura que apoye sus garantías en otro sitio.
- Sobres con metadatos. Todo mensaje lleva emisor, tipo, versión del protocolo y época del líder. Ignorar los tipos desconocidos y descartar las épocas viejas es lo que permite que convivan pestañas con despliegues distintos.
- Un canal por conversación. Separar el bus de avisos generales de los canales dirigidos a un interlocutor concreto evita que todas las pestañas paguen la deserialización de un tráfico ajeno.
- Reconciliación al despertar. Al recuperar visibilidad o al volver de la caché de retroceso, releer el estado en lugar de confiar en lo que llegó. Los mensajes perdidos no se recuperan; el almacenamiento sí se relee.
- Cerrar el canal. Los canales abiertos mantienen vivas referencias y penalizan la caché de retroceso; cerrarlos al ocultarse la página y reabrirlos al volver es barato y limpio.
// Ciclo de vida honesto del canal, atado a la visibilidad del documento
let canal = null;
function abrir() {
canal = new BroadcastChannel('datos');
canal.onmessage = manejar;
reconciliarDesdeIndexedDB(); // lo perdido no se recupera: se relee
}
addEventListener('pagehide', () => { canal?.close(); canal = null; });
addEventListener('pageshow', abrir);
Aquí aparece por primera vez, en miniatura y con dos pestañas de por medio, la frontera que organiza toda la teoría de los sistemas distribuidos, y vale la pena cruzarla despacio. Un canal es un medio: transporta bytes de un sitio a otro con ciertas propiedades, en este caso muy modestas —sin orden entre emisores, sin entrega garantizada, sin persistencia, sin membresía—. Un protocolo es un conjunto de reglas que, a partir de un medio con propiedades débiles, construye una garantía fuerte que el medio no tenía: orden total, exclusión mutua, acuerdo sobre quién manda. La confusión entre ambas cosas es el error de diseño más repetido de la profesión, y adopta siempre la misma forma inocente: como el mensaje casi siempre llega, y casi siempre llega rápido, uno empieza a razonar como si llegara siempre y de inmediato. Sobre esa suposición tácita se construyen elecciones por temporizador, cerrojos por convención y detectores de fallos por silencio, todos ellos correctos en la máquina del desarrollador y todos ellos rotos en cuanto una pestaña se congela treinta segundos. Lo que hace especialmente valiosa esta lección es que aquí el resultado teórico se puede tocar con las manos: la imposibilidad de garantizar acuerdo en un sistema asíncrono donde un participante puede fallar no es una curiosidad académica, es exactamente la razón por la que tu elección de líder casera abrirá la base dos veces. Y la salida tampoco es teórica, es concreta y ya está en la plataforma: hace falta un árbitro externo que sí pueda observar la muerte de un participante en lugar de inferirla. El navegador puede, porque él crea y destruye los documentos. Tú no puedes, porque vives dentro de uno. Por eso la lección 5 no elige líder por acuerdo entre pestañas: se lo pide al navegador.
- Instrumenta un canal de invalidación entre pestañas con identificador y versión monótona, y comprueba que una interfaz obsoleta se corrige sola tras una escritura ajena.
- Emite dos mensajes simultáneos desde dos pestañas y registra en una tercera el orden de llegada. Repite el experimento veinte veces y anota si es estable.
- Implementa la elección por temporizador del antipatrón y provoca su fallo bloqueando el hilo de una pestaña con un bucle de medio segundo.
- Manda una pestaña a la caché de retroceso, emite avisos mientras está ahí y observa qué recibe al volver. Diseña la reconciliación que lo arregla.
- Difunde un objeto de un megabyte a cuatro pestañas y mide el tiempo de bloqueo del hilo principal en las que no les concernía el mensaje.
- Reescribe tu protocolo con sobres versionados y comprueba que una pestaña con el código antiguo sigue funcionando cuando le llega un tipo que no conoce.