El catálogo: las cuatro familias de bases locales
Las bases de datos del cliente se agrupan en cuatro familias con físicas distintas, y saber a cuál pertenece una candidata explica de antemano casi todo lo que podrá y no podrá hacer por ti.
Cuando alguien pregunta qué base de datos usar en el cliente, la respuesta que circula es una lista de nombres ordenada por popularidad, y esa lista es inservible porque pone en la misma columna objetos que no son comparables. Un envoltorio de doscientas líneas sobre la API del navegador y un motor relacional completo compilado a WebAssembly no compiten por el mismo puesto: resuelven problemas distintos, cobran precios distintos y fallan de maneras distintas. Antes de comparar nombres hay que reconocer familias, porque la familia a la que pertenece una herramienta predice su techo, su peso y su modo de romperse mucho mejor que cualquier tabla comparativa de funcionalidades. Este nivel construye ese catálogo, y empieza por la única pregunta que de verdad las separa: dónde vive el motor que ejecuta tus consultas.
- Reconocer las cuatro familias de almacenamiento local y el criterio estructural que las separa.
- Identificar en cada familia dónde reside el motor de consultas y quién paga su coste.
- Entender por qué la pertenencia a una familia predice el techo de una herramienta mejor que su lista de funciones.
- Situar las herramientas concretas que ya conoces dentro del catálogo, incluidos los casos fronterizos.
El criterio que agrupa
La pregunta que ordena el territorio no es cuánto pesa una librería ni qué sintaxis expone, sino esta: cuando pides datos filtrados y ordenados, quién ejecuta esa operación y sobre qué representación. Hay exactamente cuatro respuestas posibles en un navegador, y de cada una se deduce en línea recta un conjunto de consecuencias que ninguna decisión de diseño posterior puede revertir.
Conviene además situar el catálogo en su historia, porque explica por qué tiene esta forma y no otra. Durante mucho tiempo el cliente solo tuvo almacenamiento síncrono de cadenas y un motor de base de datos orientado a objetos con una API anterior a las promesas; todo lo que se construía encima era, necesariamente, un envoltorio. La aparición de un sistema de ficheros de origen privado con acceso de baja latencia cambió el suelo: por primera vez fue posible que un motor de bases de datos compilado a WebAssembly leyera y escribiera páginas con un coste comparable al que tiene fuera del navegador. Las familias segunda y cuarta son hijas de ese cambio, y por eso son las que más se están moviendo hoy. Las familias primera y tercera son anteriores, están estabilizadas y siguen siendo la respuesta correcta para más casos de los que la conversación pública sugiere.
flowchart TB R[Bases locales en el cliente] --> F1[Envoltorios sobre IndexedDB] R --> F2[Motores SQL compilados a WebAssembly] R --> F3[Almacenes reactivos en memoria con persistencia] R --> F4[Almacenes dentro de un motor de sincronizacion] F1 --> M1[el motor es el del navegador y tu solo lo diriges] F2 --> M2[el motor viaja contigo dentro del paquete] F3 --> M3[el motor es la memoria y el disco es un respaldo] F4 --> M4[el motor lo elige quien disena la replicacion] style R fill:#cba6f7,color:#11111b style M2 fill:#89b4fa,color:#11111b style M4 fill:#a6e3a1,color:#11111b
Hay una manera práctica de aplicar el criterio sin leerse la arquitectura de nadie: mirar qué forma tiene la primera llamada que te obliga a hacer. Si te pide un nombre de almacén y un rango, el motor es del navegador. Si te pide una cadena de texto con una consulta, el motor viaja contigo. Si te devuelve un valor sin esperar, el estado ya estaba en memoria. Y si lo primero que te pide es una dirección de servidor o una definición de esquema compartido, lo que has adoptado es un motor de replicación con almacén incluido. Esa prueba de treinta segundos clasifica correctamente casi cualquier candidata y no requiere leer una sola línea de documentación.
Obsérvese que el diagrama no habla de rendimiento ni de ergonomía. Habla de propiedad del código: de quién es el código que decide cómo se recorre un índice. Esa titularidad determina el tamaño de lo que descargas, el modelo de datos que estás obligado a aceptar, la clase de consulta que puedes formular sin pagar un recorrido en memoria y, muy en particular, qué ocurre cuando el volumen crece un orden de magnitud por encima de lo que probaste.
Las cuatro familias
Envoltorios sobre IndexedDB
No traen motor: dirigen el que el navegador ya te da. Añaden promesas, tipos, un lenguaje de consulta encadenable y, en el mejor caso, observación de cambios. Todo lo que ejecutan acaba siendo peticiones de IndexedDB sobre almacenes de objetos y rangos de clave, de modo que heredan íntegras sus virtudes y sus limitaciones. Son la familia de peso más bajo y la de techo más previsible.
Motores SQL en WebAssembly
Traen el motor entero dentro del paquete. SQLite compilado a WebAssembly es el caso canónico, y su propio proyecto documenta los caminos de persistencia disponibles en el navegador; PGlite lleva la misma idea a Postgres, empaquetado como cliente de TypeScript y anunciado por su documentación por debajo de tres megabytes comprimido. Ganas SQL real con uniones y planificador, y pagas descarga, arranque y una travesía de datos.
Almacenes reactivos con persistencia
El estado vive en memoria con una estructura propia, la reactividad es de primera clase y el disco actúa como respaldo. TinyBase organiza sus datos en tablas de filas y celdas junto a valores sueltos, y documenta encima de eso oyentes, transacciones, esquemas, consultas, índices, relaciones, métricas y puntos de control. La lectura es inmediata porque nunca sale de la memoria; el límite es exactamente ese.
Almacenes dentro de un motor de sincronización
Aquí no eliges base: eliges replicación, y la base viene dentro. Zero describe su diseño como un almacén cliente local y normalizado en el que lecturas y escrituras aterrizan primero, sincronizado de forma continua con el servidor por debajo, y donde lo que se sincroniza se decide escribiendo consultas normales en el código en vez de replicar tablas enteras. El almacén deja de ser una decisión tuya.
Hay además un rasgo que se aprecia leyendo las cuatro descripciones juntas y que conviene nombrar: la cantidad de decisiones que cada familia toma por ti crece de la primera a la cuarta, y con ella crece también lo que aportas cuando aciertas y lo que pierdes cuando te equivocas. Un envoltorio decide casi nada y por eso casi nunca estorba; un motor de sincronización decide casi todo y por eso, cuando encaja, ahorra meses, y cuando no encaja, no hay forma elegante de salir. Elegir en este nivel es en buena medida elegir cuánta autonomía estás dispuesto a ceder a cambio de cuánto trabajo te ahorras.
Las fronteras entre estas cuatro casillas no son impermeables, y los casos que las cruzan son instructivos precisamente por eso. Hay almacenes reactivos que ofrecen persistir sobre un motor SQL en WebAssembly, con lo que pertenecen a la tercera familia por su modelo de programación y toman prestado el sustrato de la segunda. Hay motores en WebAssembly que persisten dentro de IndexedDB, es decir, que usan a la primera familia como si fuera un disco de bloques. Y hay motores de sincronización cuyo almacén cliente es, por dentro, uno de los de las otras tres. Nada de esto invalida la clasificación: lo que hace es recordar que la pregunta correcta se formula por capas, y que un producto concreto puede responder distinto en cada una. Cuando evalúes una herramienta, pregunta separadamente qué modelo de programación te impone, qué motor ejecuta las consultas y sobre qué sustrato persiste, porque son tres decisiones distintas que a menudo vienen empaquetadas juntas.
Las tres primeras familias responden a la pregunta dónde guardo y cómo consulto. La cuarta responde a cómo mantengo dos copias de acuerdo, y trae un almacén como efecto colateral de esa respuesta. Ponerlas en la misma lista es un error de categoría con consecuencias muy caras: si acabas necesitando sincronizar, el almacén que elegiste antes suele ser irrelevante o directamente un estorbo, porque el motor de réplica impone su propio modelo de identidad, de versionado y de aplicación de cambios. Por eso el orden correcto de las preguntas es primero si vas a sincronizar y después con qué guardas.
Dónde vive el estado de verdad
Dentro de cada familia hay una variación que conviene no perder de vista, y es dónde reside la copia que se considera autorizada en tiempo de ejecución. En un envoltorio, el estado vive en disco y cada lectura lo va a buscar; la memoria solo contiene el resultado de la última consulta. En un motor en WebAssembly, el estado vive en el montón lineal del módulo mientras la base está abierta y baja a disco al confirmar, lo cual explica que su rendimiento dependa tanto del sistema de ficheros virtual que tenga debajo. En un almacén reactivo, el estado vive en memoria y el disco solo lo espeja. Y en un motor de sincronización, el estado vive en dos sitios a la vez por definición, que es justamente el problema que ese motor existe para resolver.
La pregunta de dónde vive la verdad tiene además una segunda parte que casi nunca se formula y que decide el comportamiento en el peor momento: qué pasa cuando la pestaña se cierra de golpe. Si el estado vive en disco y cada escritura confirmó una transacción, no pasa nada. Si vive en memoria con persistencia periódica, se pierde lo que hubiera entre el último volcado y el cierre, y el tamaño de esa ventana es una decisión de diseño tuya aunque nadie te la haya presentado como tal. Si vive en el montón de un módulo con un fichero abierto debajo, lo que ocurre depende de si el motor había confirmado sus páginas y de qué garantías ofrecía el sistema de ficheros virtual. Preguntar por esa ventana de pérdida, y responderla con un número, es una de las pocas cosas de este nivel que se puede hacer en cinco minutos y que evita un incidente entero.
Esta distinción no es teórica: se manifiesta en la primera línea de código que escribes con cada una.
// Familia 1: el motor es del navegador, tu expresas rangos sobre indices.
const recientes = await db.docs
.where("actualizado")
.above(desde)
.limit(50)
.toArray();
// Familia 2: el motor es tuyo, expresas intencion y el planifica.
const { rows } = await pg.query(`
SELECT d.id, d.titulo, count(c.id) AS comentarios
FROM docs d LEFT JOIN comentarios c ON c.doc_id = d.id
WHERE d.actualizado > $1
GROUP BY d.id, d.titulo
ORDER BY comentarios DESC
LIMIT 50
`, [desde]);
// Familia 3: el estado ya esta en memoria, leer es mirar.
const fila = store.getRow("docs", "d17");
// Familia 4: escribes local y alguien mas se ocupa de propagarlo.
const docs = z.query.docs.where("autorId", usuarioActual).limit(50);
Nótese que esta variable es ortogonal a la familia y no queda determinada por ella: dos herramientas de la misma casilla pueden colocar la verdad en sitios distintos, y ese detalle cambia por completo su comportamiento bajo presión de memoria, bajo cierre abrupto y bajo concurrencia. Por eso la clasificación en cuatro familias es un primer tamiz y no una respuesta: reduce el espacio de candidatas a un puñado, y a partir de ahí las preguntas se vuelven específicas.
De ahí se sigue una consecuencia que conviene tener presente al depurar. Cuando el estado vive en disco, un fallo de escritura es inmediato y localizable: la transacción aborta y tú te enteras. Cuando vive en memoria con un respaldo, la escritura en memoria puede triunfar y la persistencia fallar después, con lo que el usuario ve su cambio aplicado y la próxima sesión no lo encuentra; ese modo de fallo es silencioso y muchísimo más difícil de reproducir. Y cuando vive dentro del montón de un módulo compilado, un fallo puede manifestarse como corrupción del fichero subyacente en lugar de como una excepción, porque el motor asumía un contrato con el sistema de ficheros que el navegador no cumplió. No es que unas familias sean más fiables que otras: es que fallan en sitios distintos, y saber dónde mirar ahorra días.
Antes de leer una sola línea de su documentación de funciones, exige a cualquier herramienta que responda cuatro cosas. Dónde ejecuta las consultas y sobre qué representación. Dónde reside la copia autorizada mientras la aplicación está en marcha. Qué ocurre exactamente cuando dos contextos del mismo origen escriben a la vez. Y cómo se migra el esquema cuando un dispositivo reaparece con datos de hace un año. Si la documentación no responde a las cuatro con claridad, eso ya es información: significa que esas respuestas las vas a descubrir tú, en producción, y que el coste de descubrirlas no está en ninguna comparativa.
La segunda familia tiene además una historia interna que ayuda a leer su presente. El primer uso conocido de SQLite dentro de un navegador se remonta a sql.js, de 2012, según la propia documentación del subproyecto WebAssembly de SQLite; absurd-sql demostró después que se podía guardar una base de SQLite dentro de IndexedDB, y wa-sqlite fue el primer sitio donde se implementó el almacenamiento sobre OPFS. Esa cronología explica por qué hoy conviven varias construcciones con la misma etiqueta de SQLite en el navegador: no son la misma cosa, y lo que las diferencia es precisamente el sistema de ficheros virtual que llevan debajo, que es el nivel donde de verdad se decide su rendimiento.
Dos paquetes pueden anunciar SQLite en el navegador y comportarse de forma radicalmente distinta según persistan en memoria, sobre IndexedDB o sobre OPFS, y según necesiten o no ejecutarse dentro de un worker. Antes de comparar cifras de nadie, averigua qué sistema de ficheros virtual usa la construcción concreta que estás evaluando y bajo qué condiciones opera. Un número medido sobre una base en memoria no describe la misma máquina que uno medido sobre disco, y confundirlos es la vía más rápida a una elección que se derrumba en producción.
Esa cronología deja también una lección de método que va más allá de SQLite. Cuando un ecosistema resuelve un problema difícil, lo habitual no es que aparezca la solución definitiva de golpe, sino que se sucedan varias construcciones que exploran caminos distintos sobre el mismo sustrato, y que durante años convivan compartiendo nombre y divergiendo en lo esencial. Reconocer en qué punto de esa sucesión está lo que estás evaluando —si es la exploración inicial, la consolidación o el mantenimiento— informa la decisión tanto como cualquier medición, porque predice cuánto va a cambiar bajo tus pies.
Lo que ninguna familia te regala
Hay tres cosas que no aparecen en ningún catálogo y que vas a tener que resolver decidas lo que decidas. Un catálogo honesto de bases locales tiene que incluir estas tres columnas vacías, porque son exactamente donde se van las semanas de trabajo que nadie presupuestó.
Migración de esquema a ciegas
Tus datos viven en dispositivos que no controlas y que pueden reaparecer tras meses apagados, con una versión antigua del esquema y sin nadie a quien preguntar. Las herramientas te dan un mecanismo de versionado; lo que no te dan es la disciplina de escribir cada migración cuando toca ni la garantía de que las migraciones encadenadas de once versiones sigan funcionando.
Desalojo del origen
El navegador puede borrar todo lo que has guardado, y lo hará bajo presión de espacio si no has hecho nada para evitarlo. Ninguna familia te inmuniza: la persistencia es una propiedad del origen, no del almacén. La consecuencia de diseño es que hay que separar desde el primer día lo prescindible de lo irreemplazable, y esa separación no la hace ninguna librería por ti.
Concurrencia entre contextos
Dos pestañas del mismo origen escriben en la misma base, y hay un worker que también escribe. Cada familia aborda esto de una manera —transacciones nativas, bloqueo exclusivo del fichero, difusión de cambios, elección de líder— y ninguna lo hace desaparecer. La pregunta que hay que hacerle a toda candidata es qué ocurre exactamente cuando dos contextos escriben a la vez.
La primera columna merece una advertencia adicional, porque su dificultad no es proporcional a lo que parece. Migrar de la versión uno a la dos es trivial; el problema aparece cuando llevas once versiones y alguien reaparece con la tres, porque entonces lo que se ejecuta es una cadena de ocho transformaciones que nadie ha probado nunca en ese orden concreto y sobre datos reales de aquella época. Ninguna de las cuatro familias resuelve eso por ti, y las estrategias que funcionan son las mismas en todas: conservar migraciones de prueba con datos de cada versión antigua, no borrar nunca una migración por muy vieja que parezca, y diseñar los cambios de esquema de forma aditiva siempre que sea posible, porque añadir un campo opcional no rompe a nadie y renombrarlo rompe a todo el mundo.
Merece la pena insistir en la tercera, porque es la que más varía entre familias y la que peor se documenta. Un envoltorio hereda el modelo transaccional del navegador, que ya resuelve la concurrencia entre contextos a nivel de transacción. Un motor en WebAssembly sobre un fichero puede necesitar acceso exclusivo, lo que obliga a que la base viva en un único contexto y a que los demás hablen con él por mensajes. Un almacén reactivo tiene por definición una copia en memoria por contexto y necesita un canal explícito para mantenerlas de acuerdo. Y un motor de sincronización trata este caso como un subconjunto trivial de su problema general, porque coordinar réplicas es exactamente para lo que existe. Son cuatro respuestas radicalmente distintas a la misma situación, y descubrir cuál te ha tocado el día que un usuario abre dos pestañas es el peor momento posible.
La pregunta con la que casi todo el mundo aborda este nivel —cuál es la mejor base de datos para el navegador— está mal formulada de una manera que merece diseccionarse, porque el error no está en la respuesta sino en presuponer que existe un eje único sobre el que ordenarlas. Lo que en realidad estás eligiendo al escoger una familia no es un conjunto de prestaciones sino un reparto de trabajo: qué parte del problema resuelve el código que descargas y qué parte queda para ti. Si eliges un envoltorio, el navegador ejecuta y tú te quedas con el diseño del esquema y con cada consulta que no se pueda expresar como un rango sobre un índice, lo cual significa que la complejidad relacional de tu dominio se convertirá en código imperativo tuyo, línea a línea. Si eliges un motor en WebAssembly, compras un planificador de consultas maduro que sabe hacer uniones y pagas su precio en descarga inicial, en tiempo de arranque, en memoria y en la travesía de los datos entre el montón del módulo y el mundo de JavaScript, además de heredar toda la superficie operativa de administrar una base de datos de verdad dentro de una pestaña. Si eliges un almacén reactivo, la reactividad y la latencia dejan de ser tu problema y a cambio aceptas que el conjunto de trabajo entero tiene que caber en memoria, con lo que tu techo de volumen queda fijado el primer día y no lo mueve ninguna optimización posterior. Y si eliges un motor de sincronización, subcontratas el problema más difícil de todo este track —mantener dos copias de acuerdo sin un árbitro— a cambio de aceptar el modelo de datos, el formato en disco y el ritmo de evolución de un tercero, en la capa donde viven tus datos y no en la capa donde vive tu interfaz. Ninguna de estas cuatro elecciones es más avanzada que las otras: son cuatro repartos distintos de la misma cantidad total de dificultad. Por eso la única forma de elegir bien es saber de antemano qué parte estás dispuesto a quedarte, y ese conocimiento no sale de leer descripciones de librerías sino de haber caracterizado tu problema con números, que es lo que hacen las cuatro lecciones siguientes.
- Coge las cinco herramientas de almacenamiento local que más veces has visto citadas y clasifícalas en las cuatro familias, sin mirar ninguna comparativa.
- Para cada una, responde en una línea dónde vive el motor de consultas y dónde vive el estado autorizado en tiempo de ejecución.
- Señala cuáles de tus cinco son en realidad casos fronterizos y explica por qué la frontera está donde está.
- Añade a tu catálogo las tres columnas que ninguna familia rellena: migración de esquema, desalojo y concurrencia entre pestañas.
- Marca cuál de las cuatro familias resolvería tu proyecto actual y, sobre todo, qué parte del problema te quedaría a ti en ese caso.