wandres.dev
SQLITE EN EL NAVEGADOR II · sobre OPFS, y a qué precio

Cuándo NO usar SQLite en el cliente

El peso del binario WebAssembly, el coste del arranque en frío y el aislamiento del origen, frente a los casos en los que IndexedDB o un almacén mucho más simple siguen siendo la respuesta correcta.

⏱ 18 min

Después de dos niveles demostrando que se puede tener un motor relacional completo dentro de una pestaña, corriendo casi a velocidad nativa sobre bases de varios gigabytes, toca la lección que casi nunca se escribe: la de cuándo no hacerlo. No por prudencia retórica ni para equilibrar el entusiasmo, sino porque las tres facturas que este montaje pasa —el binario, el arranque y el aislamiento del origen— se pagan antes de que el usuario vea nada, mientras que sus ventajas solo aparecen si la aplicación llega a necesitarlas. Un buen número de proyectos que hoy embarcan SQLite en el navegador estarían mejor servidos por veinte líneas sobre lo que el navegador ya traía puesto, y distinguir un caso del otro es la única forma de que este nivel te sirva de algo.

🎯 Al terminar esta lección sabrás
  • Cuantificar el peso real que SQLite compilado a WebAssembly añade a tu carga inicial.
  • Descomponer el arranque en frío en sus tramos y saber cuáles se pueden solapar y cuáles no.
  • Entender qué rompe en tu sitio el aislamiento del origen que el VFS con puente exige.
  • Disponer de un criterio explícito para decidir entre motor relacional, IndexedDB y algo más simple.

El peso que pagas antes de la primera consulta

Empecemos por lo comprobable. En la distribución oficial correspondiente a la versión 3.53, el módulo WebAssembly ronda los ochocientos cincuenta kibibytes sin comprimir y baja a unos trescientos noventa comprimidos con el algoritmo habitual. A eso se le suma la envoltura de JavaScript, algo más de doscientos kibibytes en crudo y unos sesenta comprimidos, más el proxy asíncrono si usas el VFS con puente, que es pequeño. En números redondos: cerca de medio megabyte por el cable y algo más de un megabyte que el navegador tiene que descomprimir, compilar y mantener en memoria.

Conviene poner esa cifra en contexto porque sola no dice nada. Medio megabyte es más de lo que pesa un framework de interfaz moderno entero. Es varias veces el presupuesto de carga inicial que cualquier guía de rendimiento recomendaría para una ruta crítica. Y es una cantidad que no baja: no hay sacudida de árbol que elimine el planificador de consultas ni el motor de expresiones, porque el binario no es JavaScript y no se poda por importaciones no usadas. Compilarlo tú mismo con menos extensiones ayuda algo; cambiar el orden de magnitud, no.

Y ese peso no se paga una sola vez de la forma en que uno espera. Se paga en la primera visita del usuario, sí, pero también cada vez que la caché del navegador se vacía, cada vez que publicas una versión nueva si el fichero no está bien versionado, y en cada arranque en memoria aunque el fichero venga de la caché, porque compilar un módulo de casi un megabyte no es gratis ni cuando los bytes están a mano. En un dispositivo modesto ese tramo de compilación es perfectamente perceptible.

📥

Descarga

Cerca de medio megabyte comprimido entre el módulo y su envoltura. Cacheable con agresividad, pero la primera visita lo paga entera.

⚙️

Compilación

El navegador compila el módulo antes de poder ejecutarlo. Se puede solapar con la descarga si se instancia desde el flujo de la respuesta, y ese solape es de las pocas optimizaciones gratis que hay aquí.

🧵

Arranque del worker

Crear la hebra, cargar el módulo dentro y establecer el canal. Es un coste fijo que se paga una vez y que conviene lanzar en paralelo con el resto del arranque.

🔓

Instalación del VFS

En el VFS de manejadores reservados, además, hay que abrir por adelantado todos los ficheros del conjunto. Es la parte asíncrona que compra la velocidad posterior.

Los cuatro tramos no se suman a ciegas: dos de ellos se pueden solapar y conviene hacerlo. La compilación del módulo puede empezar mientras los bytes todavía están llegando, si se instancia desde el flujo de la respuesta en lugar de esperar a tenerlo entero en memoria, y la creación del worker no depende de nada y puede lanzarse en el primer instante del arranque. Lo que no se solapa es la instalación del VFS, que necesita el módulo ya vivo, ni la primera consulta, que necesita el VFS. El arranque en frío tiene, por tanto, un suelo irreducible que conviene medir una vez y anotar.

// El worker arranca en paralelo con todo lo demas, no despues
const worker = new Worker(new URL("./db.worker.js", import.meta.url), {
  type: "module",
});

// Y la interfaz no espera por el: se dibuja y luego se entera
render(estadoInicialSinBase());

La consecuencia de diseño es que la base de datos no puede estar en el camino crítico del primer pintado. Si la primera pantalla de tu aplicación necesita una consulta para dibujarse, has puesto todo lo anterior por delante del usuario. La forma correcta es la contraria: dibujar de inmediato con lo que haya —una vista vacía, un esqueleto, un resumen guardado en un almacén trivial— y dejar que el motor llegue cuando llegue. Eso no es una optimización opcional, es la diferencia entre una aplicación local-first que se siente instantánea y una que tarda más en abrir que la versión servida desde un servidor que querías abandonar.

El coste de aislar el origen

La segunda factura no se mide en kibibytes y sorprende a más gente. Si eliges el VFS con puente, tu sitio necesita emitir las cabeceras de aislamiento del origen, y ese aislamiento no es una etiqueta: cambia las reglas de todo lo que tu página incrusta. A partir de ese momento, cualquier recurso de otro origen —una fuente, una imagen, un vídeo incrustado, un script de analítica, un iframe de un proveedor de pagos— solo carga si ese origen coopera declarando explícitamente que permite ser incrustado, o si se pide en un modo que renuncia a las credenciales.

flowchart TD
A[Necesitas SQLite sobre OPFS] --> B[Tu sitio incrusta terceros que no controlas]
B -->|si| C[El VFS con puente te va a romper cosas]
B -->|no| D[Puedes emitir las cabeceras sin dolor]
C --> E[Usa manejadores reservados con un solo dueno]
D --> F[Elige segun la concurrencia que necesites]
style E fill:#a6e3a1,color:#11111b
style C fill:#f38ba8,color:#11111b

Para una herramienta autónoma que no incrusta nada de fuera, ese coste es cero y no hay conversación. Para una aplicación con pasarela de pago de terceros, mapas incrustados, vídeos alojados fuera o cualquier etiqueta de medición, el coste es una auditoría completa de dependencias y una negociación con proveedores que quizá no cooperen. Esa es, en la práctica, la razón número uno por la que un equipo acaba en el VFS de manejadores reservados: no por rendimiento, aunque sea el más rápido, sino porque no puede aislar el origen sin romper su negocio.

Hay un tercer coste, menos visible, que solo aparece con el tiempo: el coste cognitivo del proyecto. Un equipo que embarca SQLite en el cliente adquiere tres VFS con propiedades distintas, un modelo de bloqueo que no se parece a ninguno que sus miembros conozcan, un modo de escritura anticipada que aquí significa lo contrario de lo que significa fuera y una frontera entre hilos que hay que respetar en cada consulta nueva. Nada de eso es difícil, pero todo eso hay que enseñárselo a cada persona que entre, y aparecerá en cada revisión de código y en cada incidente durante los años que el proyecto viva. Ese coste no se mide en kibibytes y suele superar a los que sí.

📝
La ruta de reserva por IndexedDB existe, y no es un destino

Es técnicamente posible usar IndexedDB como capa de almacenamiento de un motor de base de datos, guardando cada bloque del fichero como un objeto. Funciona, existen implementaciones conocidas y sirve como red de seguridad donde OPFS no esté disponible. Lo que no es, es una alternativa a considerar en igualdad de condiciones: cada lectura de página pasa por una transacción asíncrona y una copia estructurada, de modo que el coste por página se dispara y el resultado es notoriamente lento en cuanto el conjunto de datos crece. Trátalo como lo que es —una degradación aceptable para un navegador viejo— y nunca como el plan principal.

Los casos en los que gana lo simple

Aquí está el contenido útil de la lección, y conviene leerlo sin la incomodidad de haber invertido dos niveles en aprender lo contrario. Hay familias enteras de aplicaciones para las que un motor relacional en el cliente es una respuesta desproporcionada.

La primera es la del estado pequeño y plano: preferencias, el último documento abierto, el tema de la interfaz, un identificador de sesión. Son unos pocos kilobytes, se leen al arrancar y no admiten consultas. El almacén clave-valor síncrono que el navegador trae de serie los resuelve con una línea, y su famoso techo de unos pocos megabytes no es una limitación sino un aviso de que ahí no cabe otra cosa.

La segunda es la del modelo con forma de documento: unos miles de registros que se recuperan por su identificador o por una fecha, sin uniones, sin agregados, sin consultas que combinen tres tablas. IndexedDB fue diseñada exactamente para eso, ya está en el navegador, pesa cero bytes adicionales y con un envoltorio decente resulta perfectamente cómoda. Si tu esquema mental es una colección de objetos y tu consulta más compleja es un filtro por índice y un orden, el motor relacional te está cobrando medio megabyte por resolver un problema que no tienes.

// Si tu consulta mas dura cabe aqui, no necesitas un planificador
const recientes = await db
  .transaction("notas")
  .objectStore("notas")
  .index("porFecha")
  .getAll(IDBKeyRange.lowerBound(desde), 50);

Merece la pena recordar además que IndexedDB no tiene ninguno de los tres costes de este nivel: no pesa nada porque ya está instalada, no exige aislar el origen y funciona desde la hebra principal sin worker. Sus defectos son otros —el coste por transacción, la copia estructurada, la ergonomía áspera sin envoltorio— y el nivel 8 los estudió con detalle. La comparación honesta no es entre una herramienta buena y una mala, sino entre dos conjuntos de defectos distintos.

La tercera es la del buzón de salida: la cola de operaciones pendientes de sincronizar que casi toda aplicación local-first necesita. Es un almacén ordenado por clave, con inserción al final y consumo por el principio, y sus requisitos reales son atomicidad por elemento y supervivencia al cierre. IndexedDB los cubre. Meter una tabla relacional debajo no aporta nada y añade una dependencia al camino crítico de la sincronización.

La cuarta, más sutil, es la del estado que ya converge por su cuenta. Si tu modelo de datos es un documento de una estructura que resuelve conflictos sola —el territorio que los próximos niveles abren—, su representación en disco es un bloque de bytes opaco que se carga entero y se guarda entero. No hay consultas que hacer sobre él. Ponerle un motor relacional debajo es guardar un fichero binario dentro de una columna y llamarlo base de datos.

💡
La prueba del millón de filas, y por qué casi nadie la pasa

Existe un experimento barato que resuelve la discusión antes de que empiece: carga en IndexedDB el volumen de datos que tu aplicación tendrá dentro de dos años y ejecuta la consulta más compleja que la interfaz necesita, con un envoltorio decente y los índices correctos. Si tarda lo suficientemente poco, la decisión está tomada y te has ahorrado medio megabyte y un nivel entero de complejidad operativa. Si no llega, ahora tienes un número que justifica el motor ante cualquiera. La razón por la que casi nadie hace esta prueba es que la decisión suele tomarse el primer día, cuando no hay datos con los que hacerla y el argumento disponible es la intuición.

flowchart TD
A[Que necesita tu aplicacion] --> B[Consultas con uniones agregados o busqueda]
B -->|no| C[Cuantos registros manejas]
C -->|miles| D[IndexedDB con un envoltorio decente]
C -->|unos pocos y planos| E[Almacen clave valor sincrono]
B -->|si| F[El conjunto crece sin techo previsible]
F -->|si| G[SQLite sobre OPFS]
F -->|no| H[Mide antes: quiza IndexedDB llegue]
style G fill:#a6e3a1,color:#11111b
style D fill:#89b4fa,color:#11111b
style E fill:#89b4fa,color:#11111b

El criterio

Un último caso, este a favor y no en contra, porque conviene que el criterio no quede sesgado. Si tu aplicación necesita búsqueda de texto completo sobre un corpus que crece, la balanza se inclina de golpe: mantener un índice invertido a mano sobre IndexedDB es un proyecto entero, mientras que el motor lo trae resuelto y afinado. Ese es probablemente el argumento aislado más fuerte a favor de embarcarlo, y curiosamente casi nunca es el que se esgrime.

Reducido a lo esencial, el motor relacional en el cliente se justifica cuando concurren dos condiciones, y flaquea si falta cualquiera de las dos. La primera es que necesites consultas de verdad: uniones, agregados, ordenaciones por criterios que cambian, búsqueda de texto, un planificador que decida por ti qué índice usar. La segunda es que el volumen de datos sea suficiente para que el índice importe, es decir, que recorrer todo en memoria deje de ser viable. Con ambas, medio megabyte es una ganga. Con una sola, es un impuesto.

Conviene desconfiar de dos argumentos que suenan bien y no sostienen la decisión por sí solos. El primero es la comodidad de escribir SQL: es real, es agradable y no vale medio megabyte si tus consultas son filtros por índice que cualquier envoltorio de IndexedDB expresa igual de bien. El segundo es la portabilidad del formato —poder abrir el fichero con las herramientas de siempre— que también es real, pero se satisface igual con una exportación bien hecha, sin cargar el motor en cada visita del usuario. Un formato de intercambio y un motor de ejecución son cosas distintas, y confundirlas es la forma más común de justificar esta elección con el argumento equivocado.

Hay además dos señales que deberían frenarte aunque ambas condiciones se cumplan. Si tu producto es una página que incrusta terceros y necesitas escrituras desde varias pestañas a la vez, estás pidiendo el VFS con puente con las cabeceras que no puedes emitir, y no hay atajo: replantea la arquitectura antes que la biblioteca. Y si tu primera pantalla no puede permitirse el arranque en frío —una página de aterrizaje, un flujo de registro, cualquier ruta donde el usuario todavía no ha decidido quedarse—, entonces la base no debe cargarse en esa ruta, ni siquiera aunque el resto de la aplicación la necesite.

// La base es una capacidad que llega, no un requisito de arranque
export const baseLista = (async () => {
  await primerPintadoHecho;                 // nunca en el camino critico
  const { abrir } = await import("./db-worker-cliente.js");
  return abrir();                           // el peso viaja aqui, no antes
})();
Elegir el almacén más potente disponible es el error por defecto de nuestra profesión

Cierra el nivel con la observación más incómoda de los dos que llevas: casi nadie llega a SQLite en el navegador por un requisito, se llega por deseo. Es difícil resistirse —hay algo genuinamente hermoso en un motor de veinticinco años, escrito en C para tarjetas de memoria, corriendo dentro de una pestaña sobre un sistema de archivos inventado dos décadas después— y esa belleza funciona como argumento sin serlo. El patrón se repite en toda la historia de la ingeniería de software con nombres distintos: se eligió una base relacional para guardar tres claves de configuración, se eligió un sistema de mensajería distribuido para encolar cien mensajes al día, se eligió un orquestador de contenedores para servir una página estática. Nadie eligió mal por ignorancia; eligieron la herramienta más capaz porque la capacidad se percibe como una virtud gratuita, y solo mucho después descubrieron que su factura no se cobra el día de la decisión sino cada día siguiente, en carga inicial, en superficie de fallo, en gente nueva que tiene que entender por qué hay tres VFS y cuál usa el proyecto. Lo que este nivel te ha dado no es la capacidad de poner SQLite en una pestaña: eso lo da la documentación en una tarde. Te ha dado el mapa de sus costes —el bloqueo exclusivo, la transacción que se revierte, la travesía entre hilos, el medio megabyte, el origen aislado— y con él la posibilidad de elegir a sabiendas. Un ingeniero que conoce a fondo una herramienta y decide no usarla ha hecho un trabajo estrictamente superior al de quien la usa por defecto, aunque el resultado no se vea en ninguna parte y aunque nadie le vaya a felicitar por el código que no escribió. Ese es el examen real de este nivel, y no se aprueba montando la base: se aprueba mirando tu aplicación y siendo capaz de responder, con números y no con gusto, si le hace falta.

⚔️ Justifica tu elección con números
  1. Mide el peso exacto que la distribución que uses añade a tu carga inicial, comprimido y sin comprimir, y compáralo con el resto de tu paquete.
  2. Cronometra los cuatro tramos del arranque en frío por separado en un dispositivo modesto y decide cuáles puedes solapar.
  3. Audita todos los recursos de terceros que tu página carga y determina cuántos sobrevivirían al aislamiento del origen.
  4. Reimplementa la parte más consultada de tu aplicación sobre IndexedDB y mide ambas versiones con tu volumen real de datos. Anota a partir de qué punto el motor gana.
  5. Escribe en tres líneas la justificación de tu elección de almacenamiento, con cifras. Si no aparece ningún número, vuelve al punto uno.