wandres.dev
OPFS II · la restricción que lo cambia todo

La restricción: solo dentro de un Worker

Por qué createSyncAccessHandle está prohibido fuera de un Web Worker, qué garantía protege esa prohibición y por qué una regla que parece un detalle menor de la plataforma acaba decidiendo tu arquitectura entera.

⏱ 15 min

Hay restricciones de plataforma que se sortean con un polyfill, otras que se sortean con una bandera de configuración y otras que no se sortean en absoluto. Esta es de las terceras: el manejador de acceso síncrono de OPFS no existe fuera de un Web Worker, y no existirá. La lección no consiste en aprenderse la regla —cabe en una línea— sino en aceptar que una sola línea de la especificación puede reorganizar por completo la arquitectura de una aplicación, y en entender por qué esa línea está ahí y no va a moverse.

🎯 Al terminar esta lección sabrás
  • Enunciar la restricción con precisión y saber qué error obtienes al violarla.
  • Entender la garantía que protege: el hilo principal no puede bloquearse jamás.
  • Distinguir qué parte de OPFS sí vive en el hilo principal y por qué no te sirve.
  • Situar el contraste con alternativas como sql.js, que corren en el hilo principal y pagan otro precio.

La regla, sin matices

Enunciémosla una sola vez, con todas las palabras y sin adornos, porque es el hecho central del nivel entero y todo lo demás se deduce de aquí.

createSyncAccessHandle solo funciona dentro de un Web Worker. Llamarlo desde el hilo principal no lanza una excepción inmediata: devuelve una promesa, y esa promesa se rechaza con un DOMException de tipo InvalidStateError. El resto de OPFS —navegar directorios, obtener manejadores de fichero, crear y borrar entradas— funciona en ambos contextos. La frontera cae exactamente sobre la vía síncrona.

// En el hilo principal: TODO esto funciona
const raiz = await navigator.storage.getDirectory();
const fichero = await raiz.getFileHandle('datos.sqlite3', { create: true });
const blob = await fichero.getFile();
console.log(blob.size);

// Y esto NO
try {
  const h = await fichero.createSyncAccessHandle();
} catch (e) {
  console.error(e.name); // InvalidStateError
}

Conviene ser exacto con el sujeto de la regla. Lo que exige la especificación es un contexto de worker, y el ámbito sobre el que los motores lo implementan es el del worker dedicado: uno por pestaña, con un único propietario. Por eso el patrón que verás en cualquier aplicación real en producción es siempre el mismo —un worker dedicado que posee el fichero— y por eso la pregunta obvia de “¿y no puedo poner la base en un SharedWorker para compartirla entre pestañas?” no tiene la respuesta cómoda que esperas. Esa pregunta es el nivel 18 entero, y la lección 4 de este nivel explica por qué duele tanto.

Como el mismo módulo puede acabar cargándose en los dos contextos —es fácil que ocurra si compartes código entre la interfaz y el worker—, conviene una comprobación explícita que falle pronto y con un mensaje tuyo en lugar de con un InvalidStateError a cincuenta marcos de profundidad:

const enWorker =
  typeof WorkerGlobalScope !== 'undefined' &&
  self instanceof WorkerGlobalScope;

if (!enWorker) {
  throw new Error('La capa de datos debe instanciarse dentro de un Worker');
}

No es una comprobación de capacidad, es una comprobación de contexto, y esa distinción importa: no estás preguntando si el navegador soporta OPFS —eso se hace con navigator.storage y createSyncAccessHandle en el prototipo—, estás preguntando si tu propio código está donde debe. Los dos fallos se parecen en el síntoma y no tienen nada que ver en la causa.

⚠️
No hay bandera, no hay origin trial, no hay excepción

Esto no es una limitación de una implementación temprana pendiente de madurar. Es una decisión de diseño consciente, sostenida por todos los motores, y estable en 2026. Cualquier artículo que te prometa acceso síncrono a OPFS en el hilo principal está describiendo algo que no ocurre, o confundiendo el manejador con la API de escritura por streams, que es otra cosa y verás dentro de un momento.

Por qué el hilo principal no puede permitírselo

El hilo principal de una pestaña hace, en el mismo bucle, cinco cosas: ejecuta tu JavaScript, calcula el layout, pinta, despacha los eventos de entrada y ejecuta los callbacks de animación. No hay reparto: es una cola de tareas, una detrás de otra. Todo lo que bloquee esa cola bloquea las cinco cosas a la vez.

Una llamada síncrona a read sobre un fichero puede resolverse en microsegundos si la página está en la caché del sistema operativo. También puede tardar decenas de milisegundos si el disco está saturado, si el fichero está en un volumen cifrado, si el equipo está paginando o si el usuario tiene un disco mecánico. Ese peor caso es el que manda: durante ese tiempo la pestaña no responde al teclado, no repinta y no avanza ninguna animación. Multiplícalo por las miles de lecturas que hace una consulta compleja y el resultado no es una aplicación lenta, es una aplicación congelada. Y no habría forma de mitigarlo desde tu código: no puedes trocear una llamada síncrona, no puedes cederle el turno al bucle de eventos en mitad de una pila de WebAssembly, y no puedes prometer que el disco responderá rápido.

flowchart TB
M[Hilo principal] -->|createSyncAccessHandle| X[InvalidStateError]
M -->|createWritable| Y[Escritura por streams sin acceso posicional]
W[Worker dedicado] -->|createSyncAccessHandle| Z[Manejador sincrono completo]
Z --> DB[Motor de base de datos viable]
style X fill:#f38ba8,color:#11111b
style Z fill:#a6e3a1,color:#11111b
style DB fill:#a6e3a1,color:#11111b

Pon números al presupuesto. Una pantalla a sesenta imágenes por segundo te concede algo más de dieciséis milisegundos por fotograma para ejecutar tu código, calcular el layout y pintar; a ciento veinte, la mitad. Una sola lectura fría de disco puede comerse ese presupuesto entero, y una consulta que toque doscientas páginas puede comerse el de treinta fotogramas seguidos. La cifra que nadie discute es que a partir de unos cincuenta milisegundos de tarea bloqueante el usuario percibe que la interfaz no le responde. Con acceso síncrono a disco en el hilo principal, superar ese umbral no sería un fallo excepcional: sería el caso normal en cuanto la base creciera.

La plataforma ya cometió este error una vez y aprendió de él. localStorage es síncrono y vive en el hilo principal: cada getItem es una lectura de disco potencial que bloquea el renderizado, y es la razón por la que la API está congelada desde hace más de una década y ningún navegador ha intentado ampliarla. El XMLHttpRequest síncrono siguió el mismo camino: se desaconsejó, luego se restringió, y hoy provoca advertencias en la consola de todos los motores. La regla que aprendiste aquí es la generalización de esas dos lecciones, aplicada de forma preventiva en lugar de correctiva.

Lo que sí te deja hacer el hilo principal

OPFS no te deja sin nada en el hilo principal: te deja con una API distinta, y la diferencia entre ambas es justo lo que explica por qué necesitas el worker.

🌊

createWritable

Devuelve un FileSystemWritableFileStream: escribes por streams y confirmas al cerrar. Es asíncrono, funciona en el hilo principal y sirve perfectamente para guardar un fichero entero.

📄

getFile

Te da un File, que es un Blob: puedes leerlo entero o por rangos con slice, siempre de forma asíncrona. Suficiente para exportar, importar o mostrar un adjunto.

🚫

Lo que falta

No hay escritura posicional en el sitio, no hay tamaño síncrono y no hay truncado inmediato. Muchas implementaciones de createWritable escriben primero en un fichero temporal y lo intercambian al cerrar.

🧩

La consecuencia

Con esa semántica puedes guardar documentos, no puedes gestionar páginas de un B-tree. Una base de datos necesita modificar 4 KB en mitad de un fichero de dos gigabytes sin reescribirlo entero.

Dicho de otro modo: el hilo principal recibe una API pensada para ficheros, y el worker recibe una API pensada para almacenamiento en bloque. No es la misma herramienta con distinto envoltorio; son dos abstracciones diferentes, y solo una de ellas sostiene un motor transaccional.

ℹ️
La API del hilo principal no es inútil: es para otra cosa

Que no sirva para un pager no significa que sobre. createWritable y getFile son exactamente lo que quieres para importar una base de datos que el usuario arrastra a la ventana, para exportar una copia de seguridad a su disco, para escribir un adjunto grande sin cargarlo entero en memoria o para servir un fichero a un elemento multimedia. Una aplicación local-first madura usa las dos vías a la vez: la síncrona en el worker para el motor, la asíncrona en el hilo principal para todo lo que sea un fichero de verdad con vida propia.

El contraste: sql.js y el precio de la simplicidad

Para calibrar lo que cuesta la restricción, mira una alternativa que la esquiva por completo. sql.js es SQLite compilado a WebAssembly con un VFS en memoria: la base de datos entera es un Uint8Array dentro de la memoria lineal del módulo. No toca OPFS, no toca el disco, no necesita manejador síncrono y por eso corre sin problemas en el hilo principal.

// sql.js: sin worker, sin OPFS, sin restriccion
const SQL = await initSqlJs();
const bytes = await (await fetch('/base.sqlite')).arrayBuffer();
const db = new SQL.Database(new Uint8Array(bytes));

const filas = db.exec('SELECT id, titulo FROM notas'); // sincrono de verdad
// Para persistir: volcar la base ENTERA y guardarla
await guardarEnIndexedDB(db.export());

Nada de eso es un truco: es una elección de VFS distinta. El motor es el mismo SQLite, con el mismo planificador y las mismas garantías transaccionales dentro de la sesión; lo que cambia es a qué llama cuando pide una página. Ahí donde la ruta de OPFS llama a read sobre un fichero, la ruta en memoria hace una copia dentro de un vector de bytes, y ese único cambio la libera de la restricción entera de esta lección.

Esa simplicidad es real y no hay que despreciarla: cero infraestructura de mensajes, acceso síncrono desde el código de la interfaz, un modelo mental de una sola pieza. Y su precio también es real y aparece de golpe: la base entera tiene que caber en memoria, cada persistencia serializa el fichero completo aunque hayas cambiado una fila, y no existe durabilidad incremental —si la pestaña muere entre dos volcados, pierdes todo lo que hubiera desde el último. Funciona admirablemente hasta unas pocas decenas de megabytes y deja de funcionar más allá.

El contraste, puesto en columnas, es el resumen de todo el nivel:

  • Dónde vive la base. En la memoria del hilo principal con sql.js; en un fichero real dentro de un worker con OPFS.
  • Qué cuesta una escritura. Volcar y guardar la imagen completa; escribir las páginas afectadas.
  • Cuánto aguanta. Lo que quepa en memoria sin ahogar la pestaña; lo que permita la cuota del origen, que son gigabytes.
  • Qué pasa si la pestaña muere. Se pierde todo desde el último volcado; se conserva lo confirmado hasta el último flush.
  • Qué le cuesta a tu interfaz. Una consulta cara congela la pantalla; una consulta cara no la toca, pero toda lectura es asíncrona.
  • Qué complejidad añade. Ninguna; un worker, un protocolo de mensajes y un problema de exclusividad entre pestañas.

Ese cuadro es también el guion de la decisión: si tu aplicación cabe holgadamente en la columna de la izquierda, tomar la ruta de OPFS es pagar una arquitectura que no necesitas.

La ruta de OPFS invierte exactamente ese cuadro: escritura incremental de páginas, bases de varios gigabytes, durabilidad por transacción, y a cambio un worker obligatorio, una frontera de hilos y un problema de exclusividad entre pestañas. No es que una opción sea mejor: es que la restricción de la plataforma te obliga a elegir de forma explícita entre simplicidad arquitectónica y capacidad real, y a elegirlo el primer día, porque el cambio de una a otra no es una refactorización local.

La restricción no es un obstáculo: es la factura de una decisión que ya se tomó

Hay una forma perezosa de leer esta lección —“vaya fastidio, tendré que montar un worker”— y una forma que te hará mejor arquitecto. La restricción no está protegiendo a OPFS de ti: está protegiendo al usuario de una clase entera de aplicaciones inutilizables, y lo hace en el único momento en que aún es barato, que es cuando escribes la primera línea. Piensa en lo que pasaría si el manejador estuviera disponible en el hilo principal: funcionaría de maravilla en tu portátil, con la base caliente en caché y cien filas de prueba, y empezaría a fallar en producción sobre discos lentos, con volúmenes cifrados, con equipos paginando y con bases de un gigabyte, es decir, exactamente en las condiciones que nunca reproduces. El desastre sería difuso, tardío e imposible de atribuir. La plataforma ya vivió esa película con localStorage, que sigue ahí como monumento a la decisión contraria, y esta vez decidió cobrar por adelantado: en lugar de un fallo probabilístico en casa del usuario, un error determinista en tu consola el primer día. Ese es el patrón profundo, y se repite en todo el diseño de las APIs web modernas —desde la exigencia de contextos seguros hasta el aislamiento entre orígenes para SharedArrayBuffer—: convertir una violación de garantías en un error temprano y ruidoso. Aceptar la restricción de buena gana, en lugar de pelearla, es lo que separa una arquitectura local-first que sobrevive al primer usuario con veinte mil registros de una que se descubre insostenible cuando ya es tarde. Lo que viene ahora es contabilizar la factura completa, porque no se acaba en el worker.

⚔️ Comprueba la frontera
  1. Llama a createSyncAccessHandle desde el hilo principal y captura el error. Anota su name exacto y compáralo con lo que dice la especificación.
  2. Repite la llamada dentro de un worker dedicado sobre el mismo fichero y confirma que funciona.
  3. Escribe 4 KB en mitad de un fichero de 10 MB usando createWritable desde el hilo principal. Mide cuánto tarda y explica el resultado a la luz de su semántica de confirmación al cerrar.
  4. Carga una base de 200 MB en sql.js y mide el consumo de memoria de la pestaña. Estima a partir de ahí el umbral en el que esa opción deja de ser viable en un móvil.
  5. Redacta en tres frases la decisión arquitectónica que tomarías para una aplicación de notas de un solo usuario, y justifica por qué la restricción de esta lección inclina la balanza.