Concurrencia: un fichero, un dueño
El manejador síncrono bloquea el fichero entero en exclusiva, y de ahí salen todos los problemas de varias pestañas sobre la misma base: qué implica exactamente, cómo se manifiesta y qué estrategias existen para repartir el acceso.
Hay una frase en la documentación oficial del proyecto que conviene leer dos veces antes de diseñar nada: la concurrencia de nivel de escritorio no existe en un navegador. No es una advertencia de tono defensivo ni una excusa por una implementación provisional; es la descripción exacta de una consecuencia que se deriva, sin margen, de la única forma que la plataforma ofrece de leer y escribir bytes de forma síncrona. Adquirir esa capacidad bloquea el fichero en exclusiva, y como el fichero es tu base de datos entera, cualquier razonamiento que hicieras sobre lectores concurrentes en un servidor deja de aplicar aquí. Esta lección es el precio central del nivel.
- Entender que el bloqueo es del fichero completo y por origen, no de la base ni de una tabla.
- Predecir qué le ocurre a la segunda pestaña que abre la misma base, y con qué error se manifiesta.
- Conocer las estrategias reales de reparto: reintento ante ocupado, liberación temprana y pausa cooperativa.
- Saber por qué el patrón de un solo escritor sigue ganando casi siempre, y qué renuncia implica.
El bloqueo es del fichero, y leer también bloquea
Empecemos por la afirmación más contraintuitiva, porque es la que rompe todos los modelos mentales importados del servidor. En una base sobre OPFS no existen los lectores concurrentes. Una consulta de solo lectura necesita bytes, los bytes llegan por el manejador síncrono, y obtener ese manejador implica tomar el bloqueo exclusivo del fichero. La distinción entre lector y escritor, sobre la que descansa el control de concurrencia de cualquier motor serio, se evapora en la capa de abajo: para OPFS solo hay abierto y cerrado.
Conviene ser preciso con el alcance. El bloqueo es del fichero, no de la base como concepto lógico: tu journal es otro fichero y se bloquea aparte. Y es del origen entero, no de la pestaña: mientras una hebra cualquiera de tu sitio mantenga el manejador vivo, ninguna otra hebra del mismo sitio puede abrirlo, esté en la pestaña que esté. Dicho de la forma más brutal posible: si tu aplicación está abierta en dos pestañas y ambas intentan tocar la misma base sin coordinarse, la segunda choca en el momento de abrir.
flowchart LR A[Pestana 1] --> H[Manejador sincrono] B[Pestana 2] -.bloqueada.-> H C[Pestana 3] -.bloqueada.-> H H --> F[datos.sqlite3 en OPFS] style H fill:#f9e2af,color:#11111b style F fill:#a6e3a1,color:#11111b
El VFS oficial no se resigna a ese choque, y su comportamiento por defecto es más listo de lo que parece: solo toma el manejador cuando de verdad lo necesita y lo suelta cuando lleva un rato sin usarlo. Si no consigue tomarlo, espera un instante y reintenta varias veces antes de rendirse. Esa política de adquisición perezosa y liberación tardía es la que hace que dos pestañas que hacen poco trabajo convivan sin que nadie note nada, y también la que hace que el problema aparezca justo cuando la aplicación empieza a ser útil.
Merece la pena nombrar con precisión los dos tipos de bloqueo que conviven aquí, porque confundirlos hace ininteligible cualquier diagnóstico. Están los bloqueos que SQLite pide como parte de su protocolo transaccional, que el motor conoce, contabiliza y sabe traducir a un error de base ocupada. Y están los bloqueos que OPFS impone sin que SQLite los haya solicitado, simplemente porque una operación necesitaba el manejador síncrono; la implementación los llama automáticos o implícitos, y el motor ni siquiera sabe que existen. Casi todos los fallos raros de concurrencia salen del segundo grupo.
Cómo se manifiesta el fracaso
Cuando la espera no basta, el fallo sube hasta tu código de dos formas distintas y conviene saber por qué. Si SQLite pidió el bloqueo de manera explícita, recibes el error de base ocupada que cualquier usuario del motor reconoce. Pero muchas operaciones necesitan el manejador de forma implícita —abrir el journal, por ejemplo— sin que SQLite haya solicitado bloqueo alguno, porque en su modelo esa operación no lo requería; el requisito lo impone OPFS por debajo. Esos fallos no tienen dónde traducirse y suben como errores genéricos de entrada y salida.
La mayoría del código que se rompe con dos pestañas abiertas no se rompe por contención real, sino porque propaga el primer error de ocupado como si fuera un fallo definitivo. Las pruebas de concurrencia del propio proyecto se reescribieron en 2026 precisamente para reintentar en lugar de morir al primer síntoma, y el resultado obligó a revisar al alza todas las cifras que la documentación había publicado durante años: donde antes se recomendaba no pasar de tres conexiones, las pruebas sostuvieron ocho o diez trabajadores concurrentes durante periodos largos. La diferencia no estaba en el navegador ni en el VFS. Estaba en si el cliente reintentaba.
La consecuencia es que no puedes filtrar por el tipo de error para saber si merece la pena reintentar, porque el fallo genérico de entrada y salida cubre tanto una contención transitoria como una cuota agotada o una base que desapareció. Un reintento ciego ante cualquier fallo de entrada y salida enmascara problemas reales; no reintentar nunca convierte cada segunda pestaña en un fallo. La salida razonable es reintentar un número pequeño de veces y, si se agotan, presentar el error como lo que probablemente es: algo que ya no es transitorio.
Ese matiz cambia la forma de escribir la capa de acceso. El error de ocupado no es una excepción: es una respuesta legítima del protocolo, tan normal como un código de reintento en una API de red, y merece el mismo tratamiento —reintento con espera creciente, un número máximo de intentos y una rendición explícita que la interfaz sepa mostrar—. La versión 3.53 añadió además una utilidad específica para asistir en este patrón, señal de que el propio proyecto considera el reintento parte del uso normal y no una defensa opcional.
async function conReintento(fn, intentos = 5) {
for (let i = 0; i < intentos; i++) {
try {
return await fn();
} catch (e) {
const ocupado = /BUSY|IOERR/i.test(String(e && e.message));
if (!ocupado || i === intentos - 1) throw e;
await new Promise((r) => setTimeout(r, 20 * 2 ** i)); // espera creciente
}
}
}
Las estrategias de reparto
Hay tres palancas reales, y ninguna es gratis.
Antes de tocarlas, una comprobación de diagnóstico que ahorra tiempo: si tu problema aparece con una sola pestaña abierta, no es contención y ninguna de estas palancas te va a ayudar. Lo más probable es que tengas una sentencia sin reiniciar, dos conexiones desde la misma hebra o un worker antiguo que no se cerró al recargar. Confirma primero que hay de verdad dos poseedores compitiendo.
La primera es acortar la posesión. Todas las recomendaciones oficiales apuntan al mismo sitio: no abras la base hasta que la necesites, no dejes sentencias preparadas sin reiniciar —una sentencia a medio consumir mantiene el fichero tomado—, nunca uses dos conexiones a la misma base desde la misma hebra, y sobre todo no dejes transacciones abiertas. Una transacción larga es un bloqueo largo, y ninguna configuración lo arregla. El trabajo se mide aquí en milisegundos de entrada y salida, no en filas ni en megabytes.
La segunda es la liberación inmediata de los bloqueos que OPFS impuso pero SQLite no pidió. El VFS oficial admite activarla por conexión mediante una opción en el nombre del fichero con forma de URI, y el efecto es exactamente el que promete: los bloqueos implícitos se sueltan en cuanto se puede en lugar de esperar a que pase el periodo de inactividad. El precio está medido y es enorme: en cargas intensivas de entrada y salida el tiempo de ejecución puede multiplicarse por cinco. Por eso viene desactivada, por eso la documentación la describe como último recurso, y por eso no debe activarse antes de haber demostrado que el problema es de contención.
// Ultimo recurso, y solo tras medir. El nombre del fichero admite opciones
const db = new sqlite3.oo1.OpfsDb("file:app.db?opfs-unlock-asap=1");
La tercera solo aplica al VFS de conjunto de manejadores reservados, y es de otra naturaleza: como ese VFS toma todos sus ficheros al instalarse, no puede repartir nada por su cuenta, pero desde la versión 3.50 ofrece pausar y reanudar. Pausarlo lo desregistra del motor y suelta sus manejadores, dejando los ficheros intactos para que otra pestaña los tome; reanudarlo los recupera si puede. Es concurrencia cooperativa explícita: tú decides quién tiene el turno y cuándo lo cede, con toda la responsabilidad de coordinación en tu código. Pausar falla si hay ficheros abiertos, y arrancar directamente en pausa no es posible, de modo que la coordinación tiene que cubrir también el instante de la instalación.
// Ceder el turno: cierra todo, pausa, avisa. Reanudar es la operacion inversa
async function cederTurno(pool, db) {
db.close(); // pausar falla si queda algo abierto
pool.pauseVfs(); // suelta los manejadores, deja los ficheros
canal.postMessage("turno-libre");
}
async function tomarTurno(pool) {
if (pool.isPaused()) await pool.unpauseVfs();
return new pool.OpfsSAHPoolDb("/app.db"); // rutas absolutas obligatorias
}
Hay además una regla que no es una estrategia sino una prohibición, y conviene tratarla como tal: nunca dos conexiones a la misma base desde la misma hebra. La documentación lo desaconseja por la posibilidad de ciertos interbloqueos, y el razonamiento es fácil de reconstruir: la segunda conexión esperaría un bloqueo que solo puede soltar la primera, y la primera está detenida en la misma pila que abrió la segunda. Nadie despierta a nadie. En una aplicación bien estructurada esto no ocurre nunca, pero aparece con facilidad en el código de pruebas y en las utilidades de migración.
Acortar la posesión
Transacciones cortas, sentencias reiniciadas, apertura tardía. Es la única palanca sin coste y la que resuelve la mayoría de los casos reales.
Reintentar ante ocupado
Espera creciente y un límite. Convierte la contención en latencia en lugar de en error. Duplica de facto el número de pestañas que tu aplicación soporta.
Liberación inmediata
Suelta los bloqueos implícitos cuanto antes. Mejora la convivencia y puede quintuplicar el tiempo de ejecución. Último recurso.
Pausa cooperativa
Ceder y recuperar el turno a mano en el VFS de manejadores reservados. Máximo control y máxima responsabilidad de coordinación.
El patrón que casi siempre gana
Todo lo anterior son formas de repartir un recurso que no se puede compartir. Existe una alternativa más simple, que consiste en no repartirlo: una sola hebra posee la base y todas las pestañas hablan con ella. La pestaña no abre nada; envía una petición, recibe un resultado y se entera de los cambios por un canal de difusión. La contención desaparece de raíz porque solo hay un dueño, para siempre.
flowchart TD P1[Pestana 1] --> W[Hebra duena de la base] P2[Pestana 2] --> W P3[Pestana 3] --> W W --> D[datos.sqlite3] W -.difunde los cambios.-> P1 W -.difunde los cambios.-> P2 W -.difunde los cambios.-> P3 style W fill:#a6e3a1,color:#11111b style D fill:#89b4fa,color:#11111b
Si buscas la cifra oficial de cuántas pestañas soporta una base sobre OPFS, no la vas a encontrar, y la ausencia es honesta. El límite fiable depende del entorno —navegador, dispositivo, versión—, de cómo se use la base y, sobre todo, de si el cliente reacciona al error de ocupado o lo propaga. Esa tercera variable es la que más pesa y la única que controlas por completo. Cualquier número que alguien te dé sin especificar las tres condiciones es un número inventado, incluido el que puedas medir tú mismo un martes por la tarde en tu portátil.
Ese patrón tiene dos piezas que los niveles siguientes desarrollan enteras: elegir cuál de las pestañas hospeda a esa hebra única —una elección de líder que debe rehacerse cuando la pestaña líder se cierra— y propagar los cambios a las demás. La API de bloqueos del navegador da la primera casi gratis; un canal de difusión da la segunda. Merece la pena adelantar que este patrón es exactamente lo que hace viable el VFS de conjunto de manejadores reservados, el más rápido de los tres: si de todos modos vas a tener un único dueño, la incapacidad de ese VFS para admitir conexiones simultáneas deja de ser un defecto.
Conviene señalar un efecto secundario del patrón que rara vez se anuncia y que resulta valioso: cuando una sola hebra posee la base, esa hebra se convierte también en el punto natural de notificación. Sabe exactamente qué se ha escrito y cuándo, de modo que puede avisar a todas las pestañas de qué tablas han cambiado sin que nadie tenga que sondear nada. Ese canal es la base sobre la que se construyen las consultas reactivas del nivel siguiente, y es gratis: aparece como consecuencia de una decisión que tomaste por concurrencia.
La renuncia también hay que decirla. Con un solo dueño, todas las consultas de todas las pestañas se serializan en una hebra, y una consulta pesada bloquea a las demás. Esa es una propiedad del diseño, no un fallo: la solución no es abrir más conexiones sino trocear el trabajo pesado, exactamente el mismo consejo que la contención exigía. Y hay un caso en el que el patrón se cae por sí solo: si tu aplicación necesita de verdad varias hebras trabajando a la vez sobre la misma base —un proceso de importación en segundo plano mientras el usuario consulta—, entonces necesitas los VFS con puente, con sus cabeceras de aislamiento y su reintento ante ocupado, y no hay atajo.
Merece la pena reconocer lo que hay debajo de esta lección, porque es una de esas simetrías que reordenan el mapa entero. El control de concurrencia de una base de datos moderna descansa sobre una jerarquía de granularidades cuidadosamente construida: bloqueos de fila, de página, de tabla, de fichero, más versiones múltiples para que un lector nunca espere a un escritor. Décadas de investigación dedicadas a no bloquear el fichero completo, porque hacerlo es la respuesta trivial y es también la que no escala. OPFS te devuelve, sin pedir permiso, a la única granularidad que quedaba descartada: el fichero entero, en exclusiva, y con las lecturas dentro. Es el modelo de concurrencia de un sistema operativo de los años ochenta, incrustado en la plataforma más moderna de la que disponemos, y no por descuido sino por una decisión de seguridad razonable —dar acceso síncrono a bytes arbitrarios a código que llega por la red exige, como contrapartida, garantizar que nadie más los está tocando—. Lo revelador es lo que hacemos con esa restricción, porque no la superamos: la esquivamos. Serializamos todo contra un único propietario y convertimos el problema de concurrencia en un problema de mensajería, que es exactamente la misma jugada que hace un actor, un objeto durable en el borde, un motor de base de datos de un solo hilo o cualquier sistema que decide que la forma más barata de coordinar es no tener nada que coordinar. Cuando en los próximos niveles construyas la elección de líder y el canal de difusión, no estarás rodeando una limitación del navegador. Estarás implementando, en pequeño, la arquitectura que los sistemas distribuidos serios eligen cuando pueden elegir, y descubriendo por el camino que el reparto de un recurso exclusivo entre pestañas de un mismo navegador tiene exactamente la misma forma que el reparto de una partición entre réplicas de un mismo clúster.
- Abre la misma base desde dos pestañas sin ninguna coordinación y anota el error exacto que recibe la segunda, distinguiendo si llega como base ocupada o como fallo genérico de entrada y salida.
- Añade reintento con espera creciente y repite el experimento con cuatro pestañas. Mide cuántas soporta antes de rendirse.
- Abre una transacción y déjala sin confirmar treinta segundos mientras otra pestaña intenta leer. Explica el resultado sin usar la palabra sorprendente.
- Activa la liberación inmediata de bloqueos implícitos y mide el mismo lote de escrituras con y sin ella. Comprueba si el factor que obtienes se parece al que anuncia la documentación.
- Implementa el patrón de un solo dueño con elección de líder y verifica que cerrar la pestaña líder transfiere la propiedad sin perder ninguna escritura en vuelo.