wandres.dev
PATRONES AVANZADOS · undo, optimista, entity

Sincronizar entre pestañas: propagar el estado con el canal de difusión

Un usuario con tres pestañas abiertas de tu aplicación tiene tres copias independientes del store que divergen desde el primer clic. El canal de difusión del navegador permite que esas copias hablen entre sí, pero conectarlas no es un problema de transporte sino de diseño distribuido en miniatura: hay que decidir qué se propaga, cómo se marca lo que llega de fuera para no reenviarlo en bucle, quién manda cuando dos pestañas cambian lo mismo a la vez, qué hace una pestaña que llevaba una hora dormida, y qué categorías de estado no deben cruzar el canal jamás.

⏱ 19 min

En el momento en que un usuario abre una segunda pestaña de tu aplicación, dejas de tener un store y pasas a tener varias réplicas independientes de un mismo modelo, cada una con su propia línea temporal, sin conocimiento la una de la otra y divergiendo desde la primera interacción. La consecuencia es visible para el usuario y siempre le parece un error: cierra la sesión en una pestaña y la otra sigue mostrando su nombre, cambia el idioma y solo cambia donde lo tocó, añade un elemento al carrito y la otra copia sigue vacía. El canal de difusión del navegador resuelve el transporte entre esas réplicas en tres líneas, y esa facilidad es precisamente la trampa: lo que tienes delante no es un problema de mensajería sino un sistema distribuido en miniatura, con sus bucles, sus conflictos de escritura concurrente, sus réplicas dormidas y su pregunta incómoda sobre quién tiene razón. Esta lección se ocupa de esas cuatro cosas, porque el transporte no vuelve a aparecer después del primer apartado.

🎯 Al terminar esta lección sabrás
  • Explicar por qué varias pestañas son réplicas divergentes y qué categorías de divergencia importan.
  • Propagar acciones con el canal de difusión marcando el origen para evitar el eco infinito.
  • Resolver la escritura concurrente eligiendo una fuente de verdad y una política de reconciliación.
  • Determinar qué estado no debe cruzar el canal y por qué su propagación es un fallo, no una mejora.

Réplicas divergentes y qué divergencias importan

No toda divergencia entre pestañas es un problema, y confundirlas lleva a sincronizar demasiado, que es un error tan caro como no sincronizar nada. Hay estado que es legítimamente propio de cada pestaña: el desplazamiento de la página, el panel que el usuario tiene abierto aquí, el foco, la posición del cursor, el elemento seleccionado en esta vista. Propagar eso produce una experiencia poltergeist en la que una pestaña se mueve sola porque el usuario está trabajando en otra, y es un fallo peor que la divergencia que pretendía corregir.

Las divergencias que sí importan son tres. La primera es la identidad: sesión, autenticación, permisos, usuario activo. Su divergencia no es una molestia sino un problema de seguridad, porque una pestaña que sigue creyendo que hay sesión mostrará datos y aceptará operaciones que ya no deberían existir. La segunda son las preferencias globales del usuario —tema, idioma, unidades—, cuya divergencia es puramente irritante pero universalmente percibida como error. La tercera son los datos compartidos del dominio que el usuario reconoce como una sola cosa: el carrito, los borradores, el contador de no leídos.

Hay una cuarta categoría que se confunde con la tercera y conviene separarla: la caché de datos del servidor. Propagar su contenido entre pestañas es replicar una réplica, con todos los inconvenientes de la replicación y ninguna de sus ventajas, porque el original sigue estando a una petición de distancia. Lo que sí tiene sentido propagar es la invalidación, el aviso de que ciertos datos caducaron, dejando que cada pestaña decida si los vuelve a pedir ahora o cuando alguien los mire. Es la diferencia entre enviar el dato y enviar la noticia, y casi siempre gana la noticia.

🔐

Identidad

Sesión y permisos. Divergir aquí no es incomodidad, es una pestaña operando con autoridad que ya expiró.

🎛️

Preferencias

Tema, idioma, ajustes. El usuario los entiende como propiedad suya, no de la ventana, y espera verlos en todas partes.

🧾

Dominio compartido

Carrito, borradores, no leídos. Divergen de forma visible y erosionan la confianza en los datos.

🚫

Local de la pestaña

Scroll, foco, panel abierto, selección. Propagarlo produce una interfaz que se mueve sola y siempre es un error.

El canal y el middleware que reenvía

El canal de difusión es una tubería con nombre entre contextos del mismo origen: quien publica no se recibe a sí mismo, y todos los demás reciben una copia estructurada del mensaje. Encaja de forma natural con una arquitectura de acciones porque ya tienes la unidad de propagación resuelta: una acción es un objeto serializable que describe un cambio, exactamente lo que un canal necesita transportar. El middleware es el sitio idiomático para conectarlo, porque ve todas las acciones y puede decidir en el mismo punto qué sale y qué entra.

const canal = new BroadcastChannel('estado-app')
const PROPAGABLES = new Set(['sesion/cerrada', 'ajustes/temaCambiado', 'carrito/anadido'])

export const sincronizarPestanas: Middleware = (store) => {
  canal.onmessage = (e) => {
    // Se marca el origen para que el propio middleware no lo reenvie.
    store.dispatch({ ...e.data, meta: { ...e.data.meta, remota: true } })
  }
  return (next) => (accion) => {
    const resultado = next(accion)
    if (PROPAGABLES.has(accion.type) && !accion.meta?.remota) {
      canal.postMessage({ type: accion.type, payload: accion.payload, meta: { emitida: Date.now() } })
    }
    return resultado
  }
}

Tres decisiones están condensadas ahí y conviene hacerlas explícitas. La primera es la marca de origen: sin ella, la acción que llega del canal se vuelve a publicar y las pestañas se reenvían mensajes indefinidamente. La segunda es la lista blanca: se propaga lo que está enumerado, nunca todo, porque la lista negra deja pasar por omisión cada acción nueva que alguien añada y el fallo aparecerá lejos de quien lo causó. La tercera es que se propaga después de reducir, de modo que la acción se aplica localmente aunque el canal falle.

⚠️
Lo que viaja debe ser serializable y lo que llega no es de fiar

El canal usa el algoritmo de clonación estructurada, que copia datos pero no funciones, ni promesas, ni instancias de clases con métodos, ni nada que dependa del contexto de ejecución. Una acción con una devolución de llamada o una referencia a un nodo del documento lanza una excepción al publicarse, no al escribirse, así que el fallo aparece en tiempo de ejecución y solo con varias pestañas abiertas. Y en sentido inverso, todo lo que entra por el canal es entrada externa: puede venir de una versión antigua de tu aplicación que el usuario tiene abierta desde ayer, con un formato de carga útil que tu reducer actual ya no entiende. Versiona el mensaje y descarta lo que no reconozcas en vez de dejar que un reducer procese una forma que dejó de existir hace dos despliegues.

Concurrencia: eco, orden y quién manda

Propagar acciones funciona bien mientras los cambios sean conmutativos o raros, y deja de funcionar en cuanto dos pestañas modifican lo mismo casi a la vez. No hay reloj común, la marca de tiempo de cada pestaña procede de un dispositivo que puede estar desajustado, y el orden en que llegan los mensajes no es necesariamente el orden en que se emitieron. Es el problema clásico de la replicación multimaestro, reducido a un navegador pero con la misma estructura: sin coordinación, dos réplicas que aceptan escrituras acaban en estados distintos.

// Eleccion de lider: el bloqueo se mantiene mientras la pestana viva.
navigator.locks.request('lider-app', { mode: 'exclusive' }, async () => {
  store.dispatch(sesion.liderazgoAsumido())
  await new Promise(() => {}) // no resolver nunca: soltar el bloqueo al cerrarse
})

Hay tres arquitecturas posibles y elegir bien es casi todo el trabajo. La primera propaga acciones entre iguales, es la más simple y es correcta cuando los cambios son idempotentes o de campo único; la última escritura gana y nadie sufre. La segunda elige una pestaña líder mediante un bloqueo con nombre —la única primitiva del navegador que da exclusión mutua real entre contextos— y hace que solo el líder hable con el servidor y difunda los resultados, con las demás actuando como vistas; es la arquitectura correcta cuando hay conexiones persistentes, sondeo o trabajo caro que no debe multiplicarse por el número de pestañas. La tercera renuncia a la sincronización directa y designa al almacenamiento persistente como fuente de verdad, propagando solo la señal de que algo cambió para que cada pestaña vuelva a leer.

flowchart TD
P1[pestana uno] -->|accion propagable| C[canal de difusion]
P2[pestana dos] -->|accion propagable| C
C -->|marcada como remota| P1
C -->|marcada como remota| P2
L[bloqueo con nombre] --> LD[pestana lider]
LD -->|unica que habla con el servidor| S[servidor]
LD -->|difunde resultados| C
style C fill:#89b4fa,color:#11111b
style LD fill:#a6e3a1,color:#11111b

Queda el caso que rompe cualquiera de las tres si no se atiende: la pestaña dormida. Una pestaña en segundo plano recibe menos tiempo de ejecución, puede ser descartada por el navegador y, cuando el usuario vuelve a ella tras una hora, ha perdido todos los mensajes emitidos mientras no existía. La propagación incremental de acciones no es suficiente por sí sola, porque asume que nadie se pierde nada. La solución es tratar el retorno a primer plano como una reconciliación explícita: al recuperar visibilidad, la pestaña no reproduce lo que se perdió sino que vuelve a leer el estado autoritativo y descarta su versión de lo compartido.

💡
Reconciliar al despertar, no reproducir

Suscríbete al cambio de visibilidad del documento y trata cada retorno como un arranque en frío de lo sincronizado: relee la sesión, revalida las cachés de servidor, recarga las preferencias. Es mucho más simple que mantener un registro de mensajes con números de secuencia y confirmaciones, y es correcto por una razón de fondo: el estado autoritativo existe fuera del navegador, así que la reconstrucción siempre está disponible. Y como efecto colateral, esa misma ruta cubre gratis dos casos que de otro modo requerirían código propio, la pestaña que se abre por primera vez y la que estaba abierta durante un despliegue.

Señales en lugar de datos, y las alternativas al canal

Hay una simplificación que casi siempre mejora el diseño y que conviene considerar antes de escribir ningún reducer nuevo: propagar la noticia de que algo cambió en lugar de propagar el cambio. Una pestaña que recibe la señal de que el carrito se modificó simplemente invalida su caché y vuelve a pedirlo; una que recibe la señal de cierre de sesión descarta su estado y navega. Con eso desaparecen de golpe la serialización de cargas útiles, la compatibilidad entre versiones desplegadas, el orden de llegada y buena parte de los conflictos, porque nadie está replicando estado sino avisando de que el suyo caducó. El coste es una petición extra por pestaña y por cambio, que suele ser irrelevante y a veces ni siquiera existe si la capa de datos ya deduplica peticiones idénticas en vuelo.

// Propagar invalidaciones, no estado: mensajes diminutos y sin version.
canal.onmessage = (e) => {
  if (e.data.tipo === 'invalidar') {
    store.dispatch(api.util.invalidateTags(e.data.etiquetas))
  }
}

El canal de difusión tampoco es la única tubería disponible, y cada alternativa resuelve algo que él no. El evento de almacenamiento se dispara en las demás pestañas cuando una escribe en el almacenamiento local, lo que da propagación y persistencia con la misma operación, a costa de transportar solo cadenas y de acoplarte al formato de lo que persistes. El trabajador compartido ofrece un contexto único que todas las pestañas ven, y con él la fuente de verdad deja de estar en ninguna pestaña. Y el bloqueo con nombre no transporta nada pero da lo único que ninguna de las otras da, que es exclusión mutua real para elegir quién manda.

📡

Canal de difusión

Mensajes estructurados entre contextos del mismo origen. La opción idiomática y la más directa de conectar a un middleware.

💾

Evento de almacenamiento

Propaga y persiste a la vez, pero solo cadenas y acoplado al formato guardado. Útil para preferencias y poco más.

🧵

Trabajador compartido

Un contexto único para todas las pestañas. Saca la fuente de verdad del navegador visible y elimina el conflicto por diseño.

🔒

Bloqueo con nombre

No transporta datos: reparte autoridad. Es la primitiva que hace posible elegir una pestaña líder de forma fiable.

Cierra la lista de decisiones una que se olvida siempre y produce el peor fallo de todos: qué ocurre cuando el usuario abre dos pestañas con sesiones distintas o cambia de cuenta en una de ellas. Si el nombre del canal es fijo, las dos réplicas se hablan sin comprobar si pertenecen al mismo sujeto, y una acción de la cuenta nueva llega a una pestaña que todavía muestra la anterior. La defensa es tan barata como imprescindible: incorpora el identificador de sesión al nombre del canal, o inclúyelo en cada mensaje y descarta lo que no coincida con el tuyo. Sincronizar entre pestañas es sincronizar entre réplicas de un mismo usuario, y esa condición hay que comprobarla en vez de suponerla.

Dos pestañas son dos réplicas, y ninguna cantidad de mensajería te libra de decidir cuál tiene razón

Lo que hace valiosa esta lección no es la interfaz del canal de difusión, que se aprende en cinco minutos y se olvida sin consecuencias, sino la revelación que trae consigo: el navegador de un solo usuario ya es un sistema distribuido, y lo era antes de que tú decidieras nada. Varias réplicas del mismo modelo, sin memoria compartida, sin reloj común, comunicándose por mensajes que pueden perderse o llegar desordenados, con nodos que se duermen y despiertan sin previo aviso. Esa descripción es literalmente la de un sistema distribuido, y arrastra sus teoremas: no puedes tener a la vez consistencia perfecta y disponibilidad cuando la comunicación se interrumpe, y el retorno a primer plano de una pestaña dormida es exactamente una partición que acaba de curarse. La consecuencia práctica es que la pregunta correcta nunca es cómo mando este estado a las otras pestañas —eso es transporte, y el transporte es lo barato— sino quién es la autoridad sobre cada dato y qué ocurre cuando dos autoridades discrepan. Cuando esa pregunta se responde explícitamente, la implementación se vuelve casi mecánica y el código resultante es corto; cuando no se responde, el equipo escribe mensajería cada vez más elaborada persiguiendo síntomas, y el sistema se vuelve más frágil con cada mensaje nuevo que se añade para corregir el anterior. Y hay una segunda enseñanza escondida ahí, que cierra este nivel entero: la razón por la que todo esto es siquiera abordable es que tu aplicación representa el cambio como acciones serializables aplicadas a un estado inmutable. Un objeto que describe qué ha pasado y que puede viajar por un cable es la misma pieza que hizo trivial deshacer, que hizo precisa la reversión optimista, que permite el viaje en el tiempo y la reproducción de sesiones. Nada de esto se diseñó pensando en pestañas. Todas son consecuencias baratas de una única decisión de representación, y esa acumulación de consecuencias no previstas es la definición práctica de una buena arquitectura: no la que resuelve elegantemente el problema que tenías, sino la que sigue respondiendo cuando aparecen los que no imaginaste.

⚔️ Sincroniza dos pestañas y provoca sus tres fallos
  1. Clasifica todo el estado de tu aplicación en las cuatro categorías de la primera sección y escribe la lista blanca de acciones propagables a partir de esa clasificación, no a partir del código.
  2. Implementa el middleware con la marca de origen y demuestra el bucle infinito quitándola durante un momento, con un contador de mensajes recibidos.
  3. Intenta propagar una acción con una carga útil no serializable y comprueba dónde y cuándo aparece el fallo.
  4. Cierra la sesión en una pestaña y verifica que la otra queda sin acceso de inmediato; después razona qué pasaría si esa acción no estuviera en la lista blanca.
  5. Modifica el mismo campo casi a la vez en dos pestañas y observa el estado final; decide si tu caso admite que gane la última escritura o necesita una pestaña líder con bloqueo con nombre.
  6. Deja una pestaña en segundo plano diez minutos mientras trabajas en la otra, implementa la reconciliación al recuperar visibilidad y comprueba que converge sin reproducir ningún mensaje perdido.