wandres.dev
WEB WORKERS II · SharedWorker y las pestañas

El patrón de reserva: una pestaña líder y workers dedicados

Cuando no puedes contar con el worker compartido, la salida es elegir una pestaña líder con la API de bloqueos y enrutar hacia su worker dedicado las peticiones de las demás, con épocas e idempotencia como red de seguridad.

⏱ 18 min

Las tres lecciones anteriores dejan el terreno despejado: el worker compartido resuelve el problema pero no está garantizado, el canal de difusión avisa pero no arbitra, y el Service Worker es único pero no permanece. Lo que queda es construir a mano la propiedad que el worker compartido te regalaba, y hacerlo apoyándose en la única pieza de la plataforma que puede sostenerla, que es la API de bloqueos, porque es la única que observa de verdad la muerte de una pestaña en lugar de inferirla. El resultado es un patrón con nombre propio —pestaña líder con workers dedicados— que llevan dentro casi todas las bibliotecas serias de almacenamiento en el cliente, y que conviene entender pieza a pieza porque es el mismo esqueleto que reaparecerá, a escala planetaria, en la parte de sincronización de este track.

🎯 Al terminar esta lección sabrás
  • Montar la forma general del patrón: un worker dedicado por pestaña y un único dueño efectivo de la base.
  • Elegir líder con un bloqueo exclusivo y entender por qué su liberación automática es la propiedad decisiva.
  • Enrutar peticiones y respuestas con dos saltos, midiendo la factura que eso supone.
  • Sobrevivir al relevo con épocas, identificadores de operación e idempotencia.

La forma del patrón

Todas las pestañas arrancan su propio worker dedicado, porque un worker dedicado está disponible siempre y aísla el trabajo pesado del hilo de interfaz. Lo que distingue al líder no es tener worker, sino abrir la base. El worker del líder es el único que toca IndexedDB o OPFS; los de las seguidoras se limitan a hacer de proxy, empaquetando las peticiones de su pestaña y esperando la respuesta que llegue desde el otro lado.

flowchart TB
L[Pestana lider] --> WL[Worker dedicado que abre la base]
WL --> D[IndexedDB u OPFS]
S1[Pestana seguidora 1] --> W1[Worker proxy]
S2[Pestana seguidora 2] --> W2[Worker proxy]
W1 -->|peticion| WL
W2 -->|peticion| WL
WL -->|respuesta y avisos| W1
WL -->|respuesta y avisos| W2
style WL fill:#cba6f7,color:#11111b
style D fill:#a6e3a1,color:#11111b

Antes de seguir conviene descartar la simplificación que todo el mundo intenta: dejar que la pestaña líder abra la base en su propio hilo principal y ahorrarse un worker. Falla por tres motivos independientes. El primero es que devuelve al hilo de interfaz el trabajo pesado que el nivel anterior sacó de ahí, y ahora multiplicado, porque el líder atiende además las peticiones de las demás. El segundo es que la parte más exigente de OPFS solo está disponible fuera de la ventana, de modo que ni siquiera es una opción si tu capa la usa. Y el tercero es el que más duele en producción: el hilo principal de una pestaña de fondo se ralentiza de forma agresiva, así que el líder atendería a las seguidoras a paso de tortuga precisamente cuando el usuario está trabajando en otra de ellas.

Que las seguidoras conserven su worker no es simetría estética: es lo que permite que el relevo sea barato. Cuando el líder muere y otra pestaña toma el mando, esa pestaña no tiene que arrancar un contexto nuevo ni cargar el motor desde cero; su worker ya está caliente y solo cambia de papel, de proxy a dueño. Un diseño donde la seguidora no tiene worker convierte cada relevo en un arranque completo justo en el momento de máxima urgencia.

La elección y la muerte del líder

El mecanismo es un bloqueo exclusivo con un nombre acordado y una promesa que jamás se resuelve. Suena a truco y no lo es: el bloqueo se sostiene mientras dure la función que lo recibió, así que no terminar nunca es la forma canónica de decir “soy el líder hasta que muera”.

const miId = crypto.randomUUID();
let epoca = 0;

async function competirPorElLiderazgo() {
  await navigator.locks.request('datos-lider', { mode: 'exclusive' }, async () => {
    epoca = await incrementarEpocaPersistida();   // contador monotono en disco
    await abrirBaseEnMiWorkerConEspera();         // OPFS puede tardar en soltarse
    await guardarLiderEnDisco({ id: miId, epoca });
    anunciar({ tipo: 'lider', id: miId, epoca });
    await new Promise(() => {});                  // retener mientras viva
  });
}

Lo que hace viable todo el patrón es una única propiedad: cuando la pestaña que sostiene el bloqueo desaparece —cerrada, caída, aniquilada por el sistema por falta de memoria—, el navegador lo libera solo, sin cooperación de ningún código, y la siguiente petición de la cola entra automáticamente. Ninguna aplicación puede darse esa garantía a sí misma, por las razones que la lección 3 desarrolló: desde dentro de un documento no se puede distinguir a un muerto de un lento. El navegador sí puede, porque es él quien destruye los documentos.

Quedan dos asperezas que hay que tratar con nombre propio. La primera es que el nuevo líder puede encontrarse el fichero todavía retenido por el manejador del anterior, que se libera al destruirse su worker y no en el mismo instante en que se soltó el bloqueo; por eso la apertura lleva reintentos con espera creciente y no una única tentativa. La segunda es el líder zombi: una pestaña congelada en la caché de retroceso puede haber perdido su bloqueo y despertar minutos más tarde convencida de que sigue mandando, con peticiones a medio camino.

// Vallado por epocas: rechazar sin discutir lo que llega de un mando caducado
function atender(peticion) {
  if (peticion.epoca < epocaActual) {
    return { error: 'EPOCA_CADUCADA', epocaActual };
  }
  return ejecutar(peticion);
}
ℹ️
La época es un testigo de vallado, no un número de versión

El contador que se incrementa en cada relevo cumple exactamente la misma función que en los sistemas distribuidos se llama testigo de vallado, y la idea es tan simple como potente: en lugar de intentar impedir que un líder caducado actúe —cosa imposible, porque no puedes alcanzar a un proceso congelado—, se etiqueta cada operación con la época en que se emitió y se rechaza en el destino todo lo que llegue con una etiqueta vieja. El zombi puede despertar y hablar cuanto quiera; simplemente no será escuchado. Persistir el contador es imprescindible, porque tiene que ser monótono a través de recargas y cierres del navegador, y basta con una clave dedicada en la base.

El enrutado, sus dos saltos y su factura

Con el líder elegido queda transportar peticiones. Hay dos rutas, y elegir bien tiene consecuencias medibles. La ruta simple usa el canal de difusión con sobres identificados; funciona en todas partes y tiene el defecto conocido de que cada mensaje se clona para todas las pestañas, incluidas las que no tenían nada que ver. La ruta buena establece un canal privado por seguidor, con un extremo de canal entregado por el Service Worker según la centralita de la lección anterior, y reduce el tráfico a los dos interesados.

// Cliente: promesas pendientes indexadas por identificador de operacion
const pendientes = new Map();

function pedir(operacion) {
  const idOp = operacion.idOp ?? crypto.randomUUID();   // estable entre reintentos
  return new Promise((resolve, reject) => {
    pendientes.set(idOp, { resolve, reject, operacion });
    puertoAlLider.postMessage({ ...operacion, idOp, epoca: epocaConocida });
  });
}

function alRecibir(respuesta) {
  const p = pendientes.get(respuesta.idOp);
  if (!p) return;                       // duplicado tardio: ignorar
  pendientes.delete(respuesta.idOp);
  respuesta.error ? p.reject(respuesta.error) : p.resolve(respuesta.dato);
}

La factura de este diseño hay que mirarla de frente en lugar de descubrirla en producción. Una consulta desde una pestaña seguidora recorre cuatro fronteras de clonado estructurado en la ida y la vuelta: de su hilo principal a su worker, de su worker al del líder, y los dos saltos simétricos al regresar. Eso convierte una lectura que en el líder cuesta décimas de milisegundo en algo perceptiblemente más caro para las demás pestañas, y sugiere tres correctivos que conviene aplicar desde el principio: agrupar peticiones en lotes en lugar de emitirlas de una en una, devolver resultados en formatos que se clonen barato, y mantener en cada seguidora una caché de solo lectura alimentada por los avisos del líder, para que la mayoría de las lecturas ni siquiera crucen la frontera.

💡
Mide las dos posiciones por separado desde el primer día

Una trampa sutil de este patrón es que rinde estupendamente en la pestaña donde desarrollas, que es siempre la líder porque es la primera que abriste. Toda tu percepción de rendimiento procede entonces del caso privilegiado, mientras que la mayoría de tus usuarios con varias pestañas viven en el otro. Instrumenta la latencia etiquetando cada medición con el papel que desempeñaba la pestaña, líder o seguidora, y mira las dos distribuciones separadas. La diferencia entre ambas es la factura real del enrutado, y es el número que debe decidir cuánta caché local merece la pena y qué operaciones conviene agrupar en lotes.

⚠️
Entrega al menos una vez: tus escrituras tienen que ser idempotentes

Durante un relevo hay peticiones en vuelo cuyo destino desapareció, y el seguidor no puede saber si llegaron a ejecutarse o no. Solo tiene dos políticas posibles, y las dos son incorrectas si el sistema no colabora: reintentar, con riesgo de duplicar; o abandonar, con riesgo de perder. La salida no está en el transporte sino en el modelo de datos, y consiste en que cada operación lleve un identificador estable que se conserve en los reintentos y que el líder registre en la misma transacción en que aplica el efecto. Así, el reintento de algo ya aplicado se reconoce y se responde con el resultado anterior en lugar de repetirlo. Es exactamente el mismo mecanismo que usan las pasarelas de pago para que un botón pulsado dos veces no cobre dos veces, y es la razón por la que el reintento pasa a ser seguro.

Ventana sin líder

Entre la muerte de uno y la toma de posesión del siguiente no hay a quién preguntar. Las seguidoras deben encolar y mostrar un estado de reconexión, nunca un error.

🔁

Reenvío de lo pendiente

Al anunciarse un líder nuevo, cada seguidora reenvía sus operaciones pendientes con el mismo identificador y la época actualizada.

📖

Lecturas locales

Una caché de solo lectura por pestaña, invalidada por los avisos del líder, evita la mayoría de los viajes y hace el patrón tolerable en la práctica.

🚑

Degradación honesta

Si nada funciona, modo de solo lectura con un mensaje veraz. Una interfaz que miente sobre haber guardado es peor que una que se declara limitada.

Lo que queda abierto para el nivel siguiente

El esqueleto ya está completo, pero la pieza sobre la que se apoya se ha usado aquí en su forma más elemental. El nivel 18 desarma la API de bloqueos entera: los dos modos, compartido y exclusivo, que permiten distinguir a muchos lectores de un solo escritor; la petición que no espera si el recurso está ocupado, que es la forma correcta de preguntar si ya hay líder sin quedarse encolado; la cancelación mediante señal, que evita colas infinitas de aspirantes; la consulta del estado de los bloqueos, que da un censo real de titulares y pretendientes; y la disciplina de arrendamientos con renovación, para los casos en que retener un bloqueo indefinidamente no es aceptable. Con esas herramientas, la elección casera de esta lección se convierte en un protocolo con propiedades enunciables.

Queda además una pregunta que este nivel deja abierta a propósito y que el siguiente responde con datos: si el patrón de reserva funciona en todas partes, ¿merece la pena seguir manteniendo la rama del worker compartido? La respuesta razonable es que sí, y no por elegancia, sino porque ahorra un salto de mensajes a todas las pestañas y elimina de raíz el relevo y su ventana sin servicio. Pero exige la disciplina que ya señalaba la lección 2: una única interfaz, dos implementaciones detrás, y la batería de pruebas ejecutándose completa en ambas configuraciones. Una rama que no se prueba no es una optimización, es una segunda aplicación que se estropeará sin que nadie lo note.

El worker compartido te daba un singleton por decreto; aquí lo construyes por consenso

Conviene cerrar el nivel mirando lo que se acaba de construir con la distancia suficiente, porque su alcance es mucho mayor que el problema que lo motivó. Lo que has escrito no es un apaño para navegadores incompletos: es un sistema distribuido en miniatura, con todas sus partes canónicas y sin ninguna de sus comodidades. Hay nodos con identidad propia, que aparecen y desaparecen sin avisar. Hay un servicio de arbitraje externo del que se obtiene la exclusión mutua, porque los participantes no pueden acordarla entre ellos. Hay elección de líder, con la cola del bloqueo haciendo de sucesión. Hay detección de fallos delegada en quien sí puede observarlos. Hay épocas monótonas que valla las órdenes de un mando caducado. Hay entrega al menos una vez, y por tanto identificadores de operación e idempotencia para que el reintento no destruya nada. Hay reconciliación tras la partición, cuando una pestaña congelada vuelve a la vida con una visión del mundo que ya no es cierta. Cada uno de esos nombres tiene detrás décadas de literatura, y los estás usando en una lista de tareas que corre en un solo navegador. Esa es la razón profunda por la que este nivel existe justo antes de la parte de relojes, causalidad y convergencia: el problema de las pestañas es el mismo problema de la sincronización entre dispositivos, reducido a una escala donde puedes verlo entero, reproducirlo en dos ventanas y depurarlo sin desplegar nada. Quien lo resuelve aquí con las ideas correctas —vallado en vez de confianza, idempotencia en vez de exactamente una vez, arbitraje en vez de acuerdo por temporizador— llega al capítulo de sincronización con el vocabulario ya adquirido y descubre que solo cambia la latencia. Quien lo parchea con temporizadores llega a ese capítulo creyendo que empieza algo nuevo, y en realidad va a repetir los mismos errores con un radio de daño mucho mayor.

⚔️ Construye el patrón completo y mátalo a propósito
  1. Arranca un worker dedicado en cada pestaña y haz que solo el titular del bloqueo abra la base. Verifica con cuatro pestañas que hay exactamente una conexión.
  2. Cierra la pestaña líder de golpe desde el gestor de tareas del navegador y mide cuánto tarda otra en tomar el relevo y responder la primera consulta.
  3. Implementa la apertura con espera creciente y demuestra que el relevo funciona aunque el manejador de OPFS anterior tarde en liberarse.
  4. Añade el contador de épocas persistido y comprueba que una pestaña recuperada de la caché de retroceso ve rechazadas sus peticiones caducadas.
  5. Dota a tus escrituras de identificadores de operación y confirma que reenviar la misma operación dos veces no duplica ningún registro.
  6. Compara la latencia de una lectura en la pestaña líder y en una seguidora, y decide con esos números qué parte de tus lecturas merece una caché local.
  7. Ejecuta la misma batería de pruebas con la rama del worker compartido activa y con ella desactivada. Si tu aplicación no se comporta igual, el patrón todavía no está encapsulado.