wandres.dev
WEB LOCKS Y LIDERAZGO · quién escribe

La API de bloqueos del navegador

Bloqueos con nombre en un espacio compartido por todo el origen, dos modos de adquisición y la propiedad que lo cambia todo: el bloqueo se libera solo cuando el contexto que lo sostiene deja de existir.

⏱ 17 min

El nivel anterior terminó con un diagnóstico incómodo: el navegador te entregó un sistema de ficheros real y un bloqueo exclusivo sobre él, pero no te entregó el núcleo que arbitra entre procesos. Falta la pieza que en un sistema operativo damos por descontada —alguien que limpie los bloqueos de los muertos— y sin ella cualquier coordinación entre pestañas es un castillo sobre arena. Esa pieza existe, se llama Web Locks, y es una de las APIs más pequeñas y peor conocidas de la plataforma: tres opciones, un método y una garantía. Esta lección se dedica entera a esa garantía, porque todo lo que viene después del nivel se apoya en ella.

🎯 Al terminar esta lección sabrás
  • Adquirir un bloqueo con nombre y entender por qué su duración es la de una promesa y no la de una llamada.
  • Situar el ámbito exacto del espacio de nombres: un origen, todas sus pestañas, ventanas y trabajadores.
  • Distinguir el modo exclusivo del compartido y saber cuándo cada uno es el correcto.
  • Reconocer la liberación garantizada como la propiedad que ninguna otra API de almacenamiento te ofrece.

Un bloqueo es un nombre y un turno

La API completa cabe en una frase: pides un nombre, el navegador te llama cuando ese nombre está libre y lo mantiene reservado mientras tu función siga trabajando. El nombre es una cadena cualquiera que eliges tú; no corresponde a ningún fichero, ninguna base de datos ni ningún recurso real de la plataforma. Es una convención entre las partes de tu propia aplicación, exactamente igual que un mutex con nombre en un sistema operativo.

const resultado = await navigator.locks.request('migracion-esquema', async (lock) => {
  // Aqui dentro, y solo aqui, el bloqueo es tuyo
  console.log(lock.name, lock.mode); // migracion-esquema exclusive
  return await migrarBaseDeDatos();
});
// Al salir de la funcion el bloqueo ya esta liberado

Repara en la forma, porque es deliberada y muy distinta de la que esperarías. No hay un adquirir y un liberar sueltos que puedas descuadrar olvidando el segundo en una rama de error. El bloqueo dura exactamente lo que tarde en asentarse la promesa que devuelve tu función: si resuelve, se libera; si rechaza, se libera igual y el rechazo se propaga a quien llamó. La API te impide escribir el error clásico de todo sistema de bloqueos, que es salir del camino feliz sin soltar.

Esa misma forma trae una trampa que conviene ver ahora y no dentro de tres semanas. Como el bloqueo se sostiene mientras la promesa esté pendiente, cualquier espera que metas dentro de la función alarga la retención: una petición de red, un temporizador, una cadena de operaciones sobre IndexedDB. Un bloqueo tomado alrededor de una llamada lenta convierte un candado en una cola, y la cola la sufre otra pestaña que el usuario tiene delante. La disciplina es la de siempre: dentro de la sección crítica, solo lo crítico.

Dos detalles menores de la firma que ahorran una consulta a la documentación. El valor con el que resuelve navigator.locks.request es el que devolvió tu función, así que puedes usarla como una envoltura transparente alrededor de cualquier operación sin cambiar el código que la llama. Y el objeto que recibe tu callback trae el nombre y el modo concedidos, lo cual parece redundante hasta que descubres que hay un caso donde ese objeto llega nulo, que es el de la opción de no esperar y que verás en la cuarta lección. Añade además una restricción que sorprende la primera vez: los nombres que empiezan por guion están reservados por la especificación y su petición se rechaza, así que no construyas identificadores por concatenación sin comprobar el primer carácter.

⚠️
Pedir el mismo bloqueo dentro de sí mismo es un abrazo mortal

La API no es reentrante y no tiene ninguna obligación de detectarlo. Si dentro del callback de un bloqueo exclusivo llamas a una función que pide ese mismo nombre, la segunda petición entra en la cola detrás de la primera, la primera nunca terminará porque está esperando a la segunda, y tu pestaña se queda bloqueada para siempre sin ningún error, sin excepción y sin traza. No hay tiempo de espera por defecto que te rescate. La única defensa es estructural: que la adquisición viva en un punto único y explícito de tu código, y que el resto reciba el permiso ya concedido en lugar de volver a pedirlo.

El ámbito: un espacio de nombres por origen

El detalle que hace útil a esta API es dónde vive el gestor que reparte los turnos. No es de la pestaña ni del documento: es del origen. Todas las ventanas, pestañas, marcos incrustados del mismo origen, trabajadores dedicados, trabajadores compartidos y el trabajador de servicio comparten un único espacio de nombres y compiten en la misma cola.

flowchart TB
T1[Pestana A ventana] --> M[Gestor de bloqueos del origen]
T2[Pestana B ventana] --> M
W1[Worker dedicado de A] --> M
SW[Service worker] --> M
M --> R1[Nombre db-lider en modo exclusivo]
M --> R2[Nombre indice-busqueda en modo compartido]
style M fill:#89b4fa,color:#11111b
style R1 fill:#f38ba8,color:#11111b
style R2 fill:#a6e3a1,color:#11111b

Ese ámbito coincide punto por punto con el de OPFS y con el de IndexedDB, y esa coincidencia no es casual: es lo que permite usar un bloqueo con nombre para proteger un recurso de almacenamiento sin que se escape ningún participante. Cualquier contexto que pueda abrir tu base de datos puede también entrar en la cola del bloqueo que la protege.

Que la API esté disponible dentro de los trabajadores importa mucho más de lo que parece a primera vista, porque es lo que permite colocar la coordinación exactamente donde vive el dato. Si tu motor de base de datos ya se ejecuta en un trabajador dedicado, ese trabajador puede pedir el bloqueo por su cuenta sin pasar por el hilo principal, y así la adquisición y el acceso al fichero ocurren en el mismo contexto y desaparece una ventana de carrera que de otro modo tendrías que cerrar a mano: la que va desde que el hilo principal obtiene el permiso hasta que su mensaje llega al trabajador que lo va a usar.

Tres precisiones que evitan sorpresas. La primera es que hace falta un contexto seguro, así que en desarrollo cuenta con localhost o con https y nunca con un fichero abierto directamente. La segunda es que los bloqueos no persisten en absoluto: no sobreviven a un recargado, no se guardan en disco y no existen mientras no haya al menos un contexto vivo del origen pidiéndolos. Y la tercera es que un marco incrustado de otro origen tiene su propio gestor, y en los navegadores que particionan el almacenamiento por sitio de primer nivel, tu propio origen incrustado en otro sitio también recibe un espacio separado del que tiene cuando es la página principal.

💡
Ponle apellido a tus nombres de bloqueo

Los nombres son cadenas libres y globales al origen, lo que significa que tu aplicación, cualquier biblioteca que incluyas y cualquier prueba automatizada comparten el mismo saco. Un nombre genérico como db es una colisión esperando a ocurrir, y una colisión aquí no produce un error visible sino algo peor: dos subsistemas sin relación esperándose mutuamente. Prefija con el nombre del subsistema y añade una versión que puedas incrementar cuando el protocolo cambie, del estilo sqlite-lider-v2. El día que despliegues una versión nueva mientras un usuario tiene abierta la anterior, esa versión en el nombre es lo único que evita que las dos se coordinen creyendo que hablan el mismo idioma.

Los dos modos

Todo bloqueo se pide en uno de dos modos, y la elección determina con quién puedes convivir. El modo por omisión es el exclusivo, que es el que quieres cuando dudes.

🔒

Exclusivo

El valor por defecto. Mientras se sostiene, ninguna otra petición del mismo nombre avanza, sea del modo que sea. Es el modo del escritor y el de cualquier operación que deba ocurrir una sola vez en todo el origen.

👥

Compartido

Se concede a varios solicitantes a la vez siempre que ninguno de ellos sea exclusivo. Es el modo del lector: muchas pestañas pueden mirar en paralelo, y en cuanto un escritor entra en la cola, todos ellos son la razón de que espere.

🎟️

La cola

Las peticiones que no se pueden conceder no fallan: esperan, sin límite de tiempo y en orden de llegada por nombre. Esperar es el comportamiento normal, no la excepción, y por eso el punto donde adquieres importa tanto.

🔎

La consulta

navigator.locks.query devuelve una instantánea de lo que hay tomado y de lo que hay esperando en el origen. Sirve para depurar y para pintar diagnósticos, nunca para decidir, porque el estado puede haber cambiado antes de que la leas.

// Lector: convive con otros lectores
await navigator.locks.request('indice', { mode: 'shared' }, async () => {
  return await leerIndice();
});

// Escritor: no convive con nadie
await navigator.locks.request('indice', { mode: 'exclusive' }, async () => {
  await reconstruirIndice();
});

// Diagnostico, no decision
const { held, pending } = await navigator.locks.query();
console.table(held.map((l) => ({ nombre: l.name, modo: l.mode })));

La consulta devuelve dos listas, la de bloqueos tomados y la de peticiones pendientes, y cada entrada trae el nombre, el modo y un identificador de cliente que el navegador asigna a cada contexto. Con eso puedes montar en diez minutos un panel de depuración que muestre en vivo quién tiene qué y quién está esperando a quién, y ese panel te ahorrará horas la primera vez que una pestaña se quede colgada sin explicación. Pero repite la advertencia mentalmente cada vez que lo mires: lo que ves ya es pasado.

La pareja exclusivo y compartido es la misma que llevan décadas ofreciendo los bloqueos de lectura y escritura de cualquier sistema, con la misma intención: que la lectura, que es la operación frecuente y que no daña, no se serialice sin necesidad. El nivel dedica una lección entera a las consecuencias de esa asimetría, porque tiene más aristas de las que parece.

Conviene subrayar algo que la palabra bloqueo oculta y que a estas alturas ya deberías sospechar: la API no protege absolutamente nada. No sabe qué recurso tienes en mente al pedir un nombre, no comprueba que quien lo sostiene sea quien accede al dato y no impide que otra parte de tu código haga lo que quiera sin pedir permiso. Es exactamente lo mismo que ocurre con un mutex en cualquier lenguaje, que tampoco sabe qué variable está custodiando. Lo que compras es una convención respetada por quien la respeta, y la corrección depende por entero de que todas las rutas de acceso al recurso pasen por el mismo nombre. Una sola que no lo haga anula la protección completa sin producir ningún síntoma hasta el día que produzca uno muy caro.

La liberación que no depende de ti

Y ahora la propiedad que justifica todo el nivel. Cuando el contexto que sostiene un bloqueo deja de existir —la pestaña se cierra, el proceso de renderizado cae, el sistema mata la aplicación por presión de memoria, el trabajador se termina—, el navegador libera ese bloqueo. No hace falta que tu código se entere, ni que llegue a ejecutarse un manejador de descarga, ni que exista nada parecido a un latido. La limpieza la hace quien tiene autoridad para hacerla, que es el propio navegador, y la hace siempre.

// Un bloqueo retenido a proposito, sin ninguna intencion de soltarlo
navigator.locks.request('escritor-unico', async () => {
  await abrirLaBase();
  atenderPeticiones();
  await new Promise(() => {}); // nunca resuelve: el bloqueo se sostiene
});

// Si esta pestana muere de cualquier forma, el navegador libera.
// La siguiente de la cola entra sin que nadie detecte nada.

Compara esa garantía con lo que ya conoces. El manejador de acceso síncrono de OPFS también es exclusivo, pero su liberación depende de que se destruya el trabajador que lo abrió, y el instante en que eso ocurre no está bajo tu control. Un candado casero montado sobre localStorage con una marca de tiempo es todavía peor: si la pestaña que lo puso muere, la marca se queda ahí, y no hay forma honesta de distinguir un dueño vivo y ocupado de un dueño muerto. Toda la literatura de bloqueos con caducidad existe para intentar adivinar esa diferencia, y adivinarla mal produce dos escritores simultáneos.

📝
La garantía tiene un límite, y se llama congelación

Conviene acotar la promesa antes de apoyar el nivel entero en ella, porque la garantía cubre la destrucción del contexto y solo eso. Una pestaña que el navegador congela para ahorrar recursos no está destruida: sigue existiendo, sigue reteniendo sus bloqueos y no ejecuta ni una instrucción. Para el resto del origen es indistinguible de una pestaña ocupadísima. Ese hueco es el tema de la última lección del nivel y no se cierra con nada que ofrezca esta API, sino con un diseño en el que equivocarse no haga daño. Anótalo ahora y sigue: la liberación al morir es fiable, la reacción ante la congelación es cosa tuya.

Y una consecuencia de diseño que se deriva de todo lo anterior y que gobierna el resto del nivel: como el bloqueo no sobrevive a su contexto, cualquier estado que dependa de tenerlo debe vivir dentro del callback y morir con él. Nada de banderas globales, nada de instancias guardadas en un módulo que sigan ahí cuando el bloqueo ya cambió de dueño. El ámbito del callback y el ámbito del privilegio deben coincidir exactamente, porque cuando dejan de coincidir es cuando aparecen los dos escritores simultáneos que todo esto pretende evitar.

Lo que compras aquí no es exclusión mutua: es detección de fallos

Merece la pena detenerse en por qué esta API tan pequeña resuelve un problema tan grande, porque la respuesta no es la que sugiere su nombre. Escribir exclusión mutua entre pestañas es fácil y muchos lo han hecho: una marca en localStorage, un mensaje por un canal de difusión, un contador atómico en memoria compartida. Todos esos inventos funcionan perfectamente mientras nadie se muera. El problema irreductible de la coordinación distribuida nunca fue repartir el turno, sino saber si quien lo tiene sigue vivo, y ese problema es demostrablemente irresoluble desde dentro del sistema: en una red asíncrona no puedes distinguir un participante caído de uno lento, y por eso cuarenta años de sistemas distribuidos han acabado siempre en la misma resignación, que son las concesiones temporales, los latidos y los tiempos de espera, es decir, apuestas sobre la vida ajena que a veces se pierden y producen dos dueños del mismo recurso al mismo tiempo. Un conjunto de pestañas es exactamente ese sistema, con una diferencia decisiva a tu favor: existe un participante que no es un igual y que sí sabe la verdad. El navegador no infiere que una pestaña murió, lo sabe, porque fue él quien destruyó el proceso; no estima que un trabajador terminó, lo terminó él. Web Locks es la interfaz por la que ese conocimiento privilegiado se pone a tu disposición, y por eso te ahorra el componente más difícil y más frágil de cualquier diseño distribuido, el detector de fallos, entregándotelo perfecto en lugar de probabilístico. Todo lo que construyas en las cuatro lecciones siguientes —elección de líder, sucesión automática, relevo sin intervención del usuario— no es más que el aprovechamiento sistemático de ese único regalo. Y por eso la regla práctica del nivel es tan tajante: nunca escribas tu propio candado entre pestañas. No porque el tuyo vaya a ser peor código, sino porque el tuyo, por bueno que sea, tendrá que adivinar precisamente lo que este ya sabe.

⚔️ Toca la API con las manos
  1. Abre tu aplicación en dos pestañas y pide desde ambas el mismo bloqueo exclusivo con una espera larga dentro. Observa el orden en que se conceden.
  2. Repite el ejercicio en modo compartido y comprueba con marcas de tiempo que ambas entran a la vez.
  3. Mata la pestaña que sostiene el bloqueo desde el gestor de tareas del navegador y mide cuánto tarda la otra en recibirlo.
  4. Pinta en pantalla el resultado de navigator.locks.query cada segundo y observa cómo se mueven las listas de tomados y pendientes.
  5. Provoca a propósito el abrazo mortal reentrante en una pestaña de pruebas para reconocer su síntoma: silencio absoluto, sin error alguno.