Por qué esto importa aquí: el líder tiene la base
El manejador síncrono de OPFS es exclusivo y no admite dos escritores, así que la pestaña que gana el bloqueo es la que abre el almacenamiento y las demás dejan de tocar el disco para hablar con ella.
Las dos lecciones anteriores construyeron una maquinaria de coordinación en abstracto: un nombre, una cola, un mandato. Ahora toca enchufarla al problema concreto que la hizo necesaria, y que ya conoces de sobra desde el nivel diez. El manejador de acceso síncrono de OPFS toma el fichero para él solo y no lo suelta de forma fiable; abrir una segunda pestaña rompe la aplicación. La combinación de las dos APIs resuelve eso, pero no lo resuelve gratis: convierte tu capa de datos en un servicio con una única instancia viva por origen, y a todas las demás pestañas en clientes de esa instancia. Merece la pena ver la arquitectura completa antes de escribirla, porque cada pieza tiene una razón y ninguna es decorativa.
- Encajar las dos APIs: el bloqueo decide quién escribe y OPFS impone que solo pueda haber uno.
- Montar la secuencia de arranque del líder y la de espera del seguidor sin condiciones de carrera.
- Construir el transporte entre pestañas con correlación de peticiones y conocer sus límites reales.
- Prever qué ocurre con las peticiones en vuelo cuando el líder desaparece a mitad de una respuesta.
Dos APIs que se completan
Cada una de las dos aporta exactamente lo que a la otra le falta, y verlo así evita la confusión habitual de creer que hacen lo mismo.
OPFS con manejador síncrono te da el rendimiento y la semántica de fichero que un motor de base de datos necesita: lectura y escritura posicional, sin promesas, sin copias intermedias. A cambio impone exclusividad y no te ofrece ninguna garantía de cuándo libera. Web Locks no te da acceso a ningún dato, no toca el disco y no sabe qué es un fichero. A cambio te ofrece la única propiedad que aquí no se puede improvisar: liberación inmediata y fiable cuando el dueño muere.
flowchart TB L[Pestana lider] --> B[Bloqueo exclusivo app-lider retenido] L --> W[Worker de datos] W --> H[Manejador sincrono de OPFS] H --> F[Fichero de la base] S1[Pestana seguidora uno] --> C[Canal de coordinacion del origen] S2[Pestana seguidora dos] --> C C --> L L --> C style B fill:#89b4fa,color:#11111b style H fill:#f38ba8,color:#11111b style C fill:#f9e2af,color:#11111b style F fill:#a6e3a1,color:#11111b
La regla que gobierna el diagrama es de una sola línea y no admite excepciones: solo el titular del bloqueo abre el fichero. Ninguna pestaña seguidora crea su manejador, ni siquiera para leer, ni siquiera un momento. Si alguna lo intenta, la exclusividad de OPFS la rechazará; y si por algún camino lo consiguiera —porque el líder se suspendió, porque hubo un relevo a medias— tendrías dos motores con cachés de páginas independientes escribiendo sobre el mismo fichero, que es la forma canónica de corromper una base de datos.
Vale la pena decirlo con claridad porque es la fuente de un malentendido común: el nombre del bloqueo no tiene ninguna relación con el fichero. Puedes llamarlo app-lider, pepito o sqlite-v3 y el navegador no comprobará jamás que quien lo sostiene sea quien abre el disco. El vínculo entre el bloqueo y el fichero lo estableces tú, y existe únicamente en la disciplina de tu código. Esto es idéntico a lo que ocurre con un mutex en cualquier lenguaje, que no sabe qué variable está protegiendo, y la consecuencia es la misma: si una sola ruta de tu programa toca el recurso sin pasar por el bloqueo, toda la protección deja de valer.
El líder abre, los demás preguntan
La secuencia de arranque tiene un orden que importa. El líder no debe anunciarse hasta que la base esté realmente abierta y respondiendo, porque un seguidor que reciba el anuncio antes de tiempo empezará a mandar consultas al vacío.
const canal = new BroadcastChannel('datos-v1');
const miId = crypto.randomUUID();
let epoca = 0;
competirPorElLiderazgo(async () => {
const worker = new Worker('/db.worker.js', { type: 'module' });
await esperarListo(worker); // el worker ya abrio el manejador de OPFS
epoca = Date.now();
const atender = async (ev) => {
const m = ev.data;
if (m.tipo === 'consulta') {
const filas = await consultarEnWorker(worker, m.sql, m.params);
canal.postMessage({ tipo: 'respuesta', para: m.de, id: m.id, filas, epoca });
}
};
canal.addEventListener('message', atender);
canal.postMessage({ tipo: 'lider', id: miId, epoca }); // ahora si
return () => {
canal.removeEventListener('message', atender);
worker.terminate(); // cierra el manejador y libera el fichero
};
});
El desmontaje del final es tan importante como el arranque. Cuando el líder abdica de forma ordenada, terminar el trabajador cierra el manejador de OPFS y devuelve el fichero antes de que la siguiente pestaña entre a por él. Si te limitas a soltar el bloqueo sin cerrar nada, el sucesor recibirá el turno y se encontrará el fichero todavía tomado, que es precisamente el error que todo esto pretendía evitar.
Del lado del seguidor, la interfaz debe ser indistinguible de la que tendría si la base estuviera en su propia pestaña. Ese es el objetivo de diseño: que el resto de la aplicación no sepa nunca si está hablando con un trabajador local o con otra pestaña.
const pendientes = new Map();
let contador = 0;
canal.addEventListener('message', ({ data }) => {
if (data.tipo !== 'respuesta' || data.para !== miId) return;
const p = pendientes.get(data.id);
if (!p) return;
pendientes.delete(data.id);
p.resolve(data.filas);
});
export function consultar(sql, params = []) {
const id = ++contador;
return new Promise((resolve, reject) => {
pendientes.set(id, { resolve, reject });
canal.postMessage({ tipo: 'consulta', de: miId, id, sql, params });
setTimeout(() => {
if (pendientes.delete(id)) reject(new Error('SIN_RESPUESTA_DEL_LIDER'));
}, 5000);
});
}
Ese temporizador de la última línea no es una precaución opcional: es la única defensa del seguidor. Un trabajador propio que muere dispara un evento de error; un líder que muere no dispara absolutamente nada en tu pestaña, y sin plazo máximo esas promesas quedarían pendientes para siempre y tu interfaz se congelaría sin un solo mensaje en la consola.
El canal y sus límites
Aquí hay un detalle de la plataforma que descoloca a casi todo el mundo la primera vez y que conviene conocer antes de diseñar el transporte. La solución elegante sería que el líder abriera un canal privado con cada seguidor —un MessageChannel por cliente, como se hace con un trabajador— y así evitar que cada mensaje llegue a todas partes. No se puede. Un puerto de mensajes es transferible pero no clonable, y la difusión por BroadcastChannel no admite lista de transferencia. No hay forma de entregar un puerto por ese camino.
La primera es la que acabas de leer: no puedes repartir puertos privados por el canal, así que todo el tráfico entre pestañas viaja por el mismo tubo compartido y cada mensaje se clona una vez por cada pestaña abierta, la reciba quien la reciba. Con cuatro pestañas y respuestas grandes eso es trabajo real en el hilo principal de gente que no ha pedido nada. La segunda es que el canal no se entrega mensajes a sí mismo: la pestaña que emite no recibe su propio envío, de modo que si el líder y quien consulta son la misma pestaña, la petición se pierde en silencio y necesitas un atajo local explícito para ese caso. Y la tercera es que no hay ninguna garantía de entrega ni acuse de recibo: si el destinatario está congelado o acaba de morir, tu mensaje desaparece sin error. Las tres empujan en la misma dirección de diseño, que es mover el volumen fuera del canal y usarlo solo para lo pequeño.
Las salidas prácticas son tres y se combinan. La primera es que los seguidores no pidan datos crudos sino resultados ya reducidos, porque el coste del transporte lo paga el tamaño de la respuesta. La segunda es que el líder no reenvíe filas cuando cambia algo, sino avisos de invalidación —cambió esta tabla, cambió este documento— y que cada seguidor decida si le interesa volver a preguntar. Y la tercera, donde esté disponible, es apoyarse en un trabajador compartido como punto de encuentro, que sí puede repartir puertos privados y elimina de golpe el problema del tubo único.
Direcciona todo mensaje
Cada envío lleva quién lo manda y para quién es. El filtrado en destino es obligatorio, no una optimización, porque todas las pestañas reciben todo y confundir una respuesta ajena con la propia es trivial.
Reduce antes de enviar
Pide al líder el resultado agregado y no el conjunto de filas. Cada byte que cruza el canal se clona tantas veces como pestañas haya abiertas del origen.
Notifica, no repliques
Tras una escritura, el líder difunde qué ha cambiado y nada más. Quien tenga esa vista en pantalla vuelve a consultar; quien no la tenga, se ahorra el trabajo entero.
Todo con plazo
Ninguna petición entre pestañas puede quedar sin plazo máximo. Un líder muerto no produce error, produce silencio, y el silencio sin temporizador es una interfaz congelada.
Cuando el líder cae con peticiones en vuelo
Falta el caso que decide si tu diseño es serio: el líder desaparece justo después de recibir una escritura y justo antes de contestarla. El seguidor tiene una promesa pendiente y ninguna información. Poco después, otra pestaña hereda el bloqueo, abre la base y se anuncia. ¿Qué hace el seguidor con la petición que se quedó sin respuesta?
La respuesta correcta depende de una propiedad que tienes que decidir tú por cada operación, y no de ningún mecanismo del navegador. Una lectura se puede reintentar siempre sin pensarlo. Una escritura solo se puede reintentar si es idempotente, es decir, si aplicarla dos veces deja el mismo resultado que aplicarla una. Si tu operación es “sumar uno a este contador”, reintentarla a ciegas puede sumar dos; si es “poner este campo a este valor con este identificador de operación”, puedes reintentarla mil veces sin daño.
canal.addEventListener('message', ({ data }) => {
if (data.tipo !== 'lider') return;
for (const [id, p] of pendientes) {
if (p.idempotente) reenviar(id); // seguro: repetir no hace dano
else { pendientes.delete(id); p.reject(new Error('RELEVO_DE_LIDER')); }
}
});
Y para lo que no es idempotente, la salida no es adivinar sino preguntar. Si cada operación de escritura lleva un identificador propio y el líder registra en la base los identificadores que ya aplicó, el sucesor puede responder con certeza si aquella escritura llegó a ocurrir. Eso es un registro de deduplicación, cuesta una tabla y un índice, y convierte un caso irresoluble en una consulta. Es el mismo mecanismo que usa cualquier pasarela de pagos seria por exactamente la misma razón.
Hay una lectura derrotista de todo este nivel que conviene desmontar, porque la he visto arruinar diseños enteros. Dice que la exclusividad de OPFS es una limitación provisional del navegador, que la elección de líder es un apaño para sortearla y que algún día una API mejor permitirá que todas las pestañas escriban a la vez y podremos tirar este código. Es falso en los tres puntos, y entender por qué cambia la forma en que diseñarás la capa de datos. Empieza por lo que significa que dos escritores compartan un fichero: cada uno mantiene en memoria su propia caché de páginas, y en cuanto uno escribe, la del otro miente. Para que eso funcione hace falta que ambos se coordinen en cada página que tocan, lo que exige bloqueos de grano fino, invalidación de cachés y un protocolo entre procesos que un sistema operativo puede ofrecer y un navegador no; SQLite lo resuelve así en un servidor con bloqueos de fichero del núcleo, y aun así su propia documentación desaconseja compartir una base por red precisamente porque esos bloqueos dejan de ser fiables. Lo que el navegador ha hecho no es quitarte una capacidad, es negarse a ofrecerte una ilusión que no podía sostener. Y fíjate en dónde acabas al aceptarlo: un único proceso que posee los datos y atiende peticiones de varios clientes por un canal de mensajes con identificadores de correlación, plazos, reintentos idempotentes y deduplicación. Eso es un servidor de base de datos. Has reconstruido, dentro de una sola máquina y sin que intervenga la red, exactamente la misma arquitectura cliente-servidor que el software local-first pretendía superar, y no por torpeza sino porque esa arquitectura es la respuesta correcta a un recurso que solo admite un escritor. La diferencia, y es toda la diferencia del mundo, está en dónde vive ese servidor: a un salto de memoria en lugar de a cien milisegundos de latencia, sin cuenta, sin conexión y sin dueño ajeno. Local-first nunca prometió eliminar la coordinación; prometió acercarla hasta que dejara de doler.
- Mueve la apertura del almacenamiento dentro del callback del líder y comprueba que ninguna pestaña seguidora crea un manejador de OPFS.
- Implementa el atajo local para el caso en que el líder y quien consulta son la misma pestaña, y verifica que sin él las consultas se pierden.
- Mide el coste del canal: envía una respuesta de varios megabytes con cuatro pestañas abiertas y observa el hilo principal de las que no pidieron nada.
- Sustituye ese envío por un aviso de invalidación y compara de nuevo. Anota la diferencia.
- Mata al líder justo después de enviarle una escritura y comprueba que el seguidor no se queda colgado, sino que falla con un mensaje comprensible o reintenta.
- Añade identificadores de operación y un registro de deduplicación, y demuestra que reenviar la misma escritura dos veces no la aplica dos veces.