El Panorama de las Storage APIs en 2026

Los navegadores ahora ofrecen cuatro mecanismos de storage significativos para datos de aplicación:

  1. localStorage/sessionStorage, 5-10MB, sincrónico, solo strings. Todavía útil para datos de config chiquitos. No relevante para esta discusión.
  2. IndexedDB, La API "base de datos". Object stores, índices, transacciones. Solo async. Soportado en todos lados.
  3. Cache API, Diseñada para cachear respuestas HTTP (Service Workers). notable para cachear fetch responses, no para datos arbitrarios de aplicación.
  4. OPFS (Origin Private File System), Una API de filesystem real con scope a tu origin. File handles, streams de lectura/escritura, y críticamente, una API sincrónica en contextos de Worker.

La pregunta arquitectónica interesante es: ¿usás IndexedDB como tu base de datos, o usás OPFS como filesystem y corrés una base de datos real (SQLite) encima?

IndexedDB: El Diablo Conocido

IndexedDB existe desde 2011. Es maduro, ampliamente soportado, y profundamente frustrante. Dejame mostrarte un patrón real de la app de inspecciones, guardando registros de inspección con índices compuestos:

function openInspectionDB() {
 return new Promise((resolve, reject) => {
 const request = indexedDB.open('InspectionDB', 3);

 request.onupgradeneeded = (event) => {
 const db = event.target.result;
 const oldVersion = event.oldVersion;

 if (oldVersion < 1) {
 const store = db.createObjectStore('inspections', {
 keyPath: 'id',
 autoIncrement: false
 });
 store.createIndex('by_building', 'buildingId', { unique: false });
 store.createIndex('by_status', 'status', { unique: false });
 store.createIndex('by_date', 'inspectionDate', { unique: false });
 }

 if (oldVersion < 2) {
 const store = event.target.transaction.objectStore('inspections');
 store.createIndex('by_building_date', ['buildingId', 'inspectionDate'], {
 unique: false
 });
 }

 if (oldVersion < 3) {
 const photoStore = db.createObjectStore('photos', { keyPath: 'id' });
 photoStore.createIndex('by_inspection', 'inspectionId', { unique: false });
 }
 };

 request.onsuccess = () => resolve(request.result);
 request.onerror = () => reject(request.error);
 });
}

Este patrón, migraciones versionadas en onupgradeneeded, es el equivalente IndexedDB de migraciones de base de datos. Funciona, pero observar las piruetas: no es posible agregar un índice a un store existente fuera de un version upgrade, y se necesita manejar cada transición de versión.

El Modelo de Transacciones Que Te Muerde

Las transacciones de IndexedDB se auto-commitean cuando quedan idle. Esto significa:

// ESTO VA A FALLAR de formas sutiles
async function updateInspectionWithPhotos(inspection, photos) {
 const db = await openInspectionDB();
 const tx = db.transaction(['inspections', 'photos'], 'readwrite');

 const inspStore = tx.objectStore('inspections');
 inspStore.put(inspection);

 // Si esta operación async tarda mucho, la transacción se auto-commitea
 // antes de que pasen las escrituras de fotos
 for (const photo of photos) {
 const compressed = await compressImage(photo.blob); // PELIGRO: gap async
 const photoStore = tx.objectStore('photos');
 photoStore.put({ ...photo, blob: compressed });
 }
}

El await compressImage() crea un gap de microtask. Si la compresión tarda más de ~unos cientos de milisegundos, la transacción se commitea o aborta antes de que las escrituras de fotos se ejecuten. El fix es hacer todo el trabajo async antes de abrir la transacción:

async function updateInspectionWithPhotos(inspection, photos) {
 // Hacé todo el trabajo async PRIMERO
 const compressed = await Promise.all(
 photos.map(async (photo) => ({
 ...photo,
 blob: await compressImage(photo.blob)
 }))
 );

 const db = await openInspectionDB();
 const tx = db.transaction(['inspections', 'photos'], 'readwrite');

 tx.objectStore('inspections').put(inspection);
 for (const photo of compressed) {
 tx.objectStore('photos').put(photo);
 }

 return new Promise((resolve, reject) => {
 tx.oncomplete = resolve;
 tx.onerror = () => reject(tx.error);
 });
}

Este es el tipo de cosas que funciona en desarrollo (imágenes chicas, compresión rápida) y se rompe en producción (fotos de 14MP en un procesador de tablet).

Características de Performance de IndexedDB

Benchmarkeé IndexedDB entre navegadores para nuestro workload típico, inserción batch de 1000 registros de inspección (~2KB c/u) y 500 fotos (~200KB c/u):

Navegador Insert 1000 Registros Insert 500 Photo Blobs Range Query (100 resultados)
Chrome 121 180ms 2,400ms 12ms
Firefox 124 210ms 3,100ms 18ms
Safari 17.4 340ms 4,800ms 24ms

La performance de blob storage de Safari es notablemente peor. En iPads, que eran la mitad de nuestra flota de dispositivos, esto importó mucho.

OPFS: El Filesystem en Tu Navegador

El Origin Private File System te da un filesystem jerárquico real, con scope a tu origin. Sin prompts al usuario, sin diálogos de file picker, es enteramente programático.

async function setupOPFS() {
 const root = await navigator.storage.getDirectory();

 const dbDir = await root.getDirectoryHandle('databases', { create: true });
 const photosDir = await root.getDirectoryHandle('photos', { create: true });

 const configFile = await root.getFileHandle('config.json', { create: true });

 const writable = await configFile.createWritable();
 await writable.write(JSON.stringify({ version: 1, lastSync: null }));
 await writable.close();

 const file = await configFile.getFile();
 const text = await file.text();
 return JSON.parse(text);
}

Esa es la API async, que corre en el main thread. Está bien para operaciones chicas. Pero el poder real está en el sync access handle, disponible solo en Web Workers:

// worker.js
async function writeLargeFile(dirHandle, filename, data) {
 const fileHandle = await dirHandle.getFileHandle(filename, { create: true });
 const accessHandle = await fileHandle.createSyncAccessHandle();

 const encoder = new TextEncoder();
 const encoded = encoder.encode(data);

 // Escrituras sincrónicas - sin promesas, sin await
 accessHandle.write(encoded, { at: 0 });
 accessHandle.truncate(encoded.byteLength);
 accessHandle.flush();
 accessHandle.close();

 return encoded.byteLength;
}

createSyncAccessHandle() te da read(), write(), truncate(), flush(), y close(), todo sincrónico. Esto es lo que hace a SQLite-over-WASM práctico, porque la capa VFS (Virtual File System) de SQLite espera I/O sincrónico.

SQLite-over-WASM: La Opción Nuclear

El equipo de SQLite soporta oficialmente un build WASM desde fines de 2022, y maduró significativamente. Combinado con OPFS, teners una base de datos SQL real en el navegador con transacciones ACID, queries complejas, full-text search, y window functions.

Configurarndolo

// worker.js - SQLite tiene que correr en un Worker para sync access de OPFS
import sqlite3InitModule from '@official-sqlite/wasm';

let db = null;

async function initDatabase() {
 const sqlite3 = await sqlite3InitModule();

 if (sqlite3.oo1.OpfsDb) {
 db = new sqlite3.oo1.OpfsDb('/inspections.db');
 } else {
 db = new sqlite3.oo1.DB(':memory:');
 console.warn('OPFS no disponible, usando base en memoria');
 }

 db.exec(`
 CREATE TABLE IF NOT EXISTS inspections (
 id TEXT PRIMARY KEY,
 building_id TEXT NOT NULL,
 inspector_id TEXT NOT NULL,
 status TEXT NOT NULL DEFAULT 'draft',
 inspection_date TEXT NOT NULL,
 data JSON NOT NULL,
 created_at TEXT DEFAULT (datetime('now')),
 updated_at TEXT DEFAULT (datetime('now')),
 sync_status TEXT DEFAULT 'pending'
 );

 CREATE INDEX IF NOT EXISTS idx_inspections_building
 ON inspections(building_id);

 CREATE INDEX IF NOT EXISTS idx_inspections_sync
 ON inspections(sync_status) WHERE sync_status = 'pending';

 CREATE TABLE IF NOT EXISTS photos (
 id TEXT PRIMARY KEY,
 inspection_id TEXT NOT NULL REFERENCES inspections(id),
 blob_path TEXT NOT NULL,
 thumbnail BLOB,
 metadata JSON,
 created_at TEXT DEFAULT (datetime('now'))
 );

 CREATE INDEX IF NOT EXISTS idx_photos_inspection
 ON photos(inspection_id);
 `);

 return db;
}

Notá que las fotos se guardan como paths de archivos, no como blobs en SQLite. Los datos de imagen reales viven en archivos OPFS, y SQLite guarda el path. Esta es una decisión arquitectónica crítica, SQLite maneja blobs bien, pero tu archivo de base de datos se infla rápido y la performance degrada. Mantener los metadatos en SQLite, mantener los datos binarios en archivos OPFS.

Performance de Queries

Acá es donde SQLite brilla comparado con IndexedDB, queries complejas:

function getInspectionSummary(buildingId, startDate, endDate) {
 return db.exec({
 sql: `
 SELECT
 i.status,
 COUNT(*) as count,
 COUNT(DISTINCT i.inspector_id) as inspectors,
 AVG(json_array_length(json_extract(i.data, '$.checklist_items'))) as avg_items,
 (
 SELECT COUNT(*)
 FROM photos p
 WHERE p.inspection_id = i.id
 ) as photo_count
 FROM inspections i
 WHERE i.building_id = ?
 AND i.inspection_date BETWEEN ? AND ?
 GROUP BY i.status
 ORDER BY count DESC
 `,
 bind: [buildingId, startDate, endDate],
 returnValue: 'resultRows',
 rowMode: 'object'
 });
}

Intentá escribir esa query con IndexedDB. Tendrías que abrir un cursor, iterar sobre cada registro que matchee, agregar manualmente, y manejar el join a photos usted mismo. Con SQLite, es solo SQL.

Benchmarks: SQLite-over-WASM vs IndexedDB

Mismo workload de antes, Chrome 121:

Operación IndexedDB SQLite-WASM (OPFS) Factor
Insert 1000 registros (una tx) 180ms 45ms 4x más rápido
Range query (100 resultados) 12ms 3ms 4x más rápido
Aggregate query (COUNT/GROUP BY) 890ms* 8ms 111x más rápido
Full-text search (1000 docs) N/A** 14ms -

* IndexedDB aggregate requiere cursor iteration sobre todos los registros que matchean. ** IndexedDB no tiene full-text search built-in.

La diferencia en aggregate queries es brutal. IndexedDB no tiene primitivas de agregación, teners que leer cada registro y computar en JavaScript. SQLite lo hace en código WASM compilado con B-tree traversal optimizado.

Cuotas de Almacenamiento: Limitaciones por Navegador

Cada navegador limita cuántos datos puede guardar tu origin. La API StorageManager te dice con qué se está laburando:

async function checkStorageQuota() {
 if (!navigator.storage?.estimate) {
 return { supported: false };
 }

 const estimate = await navigator.storage.estimate();
 const usage = estimate.usage || 0;
 const quota = estimate.quota || 0;

 let persistent = false;
 if (navigator.storage.persist) {
 persistent = await navigator.storage.persist();
 }

 return {
 supported: true,
 usedMB: Math.round(usage / 1024 / 1024),
 quotaMB: Math.round(quota / 1024 / 1024),
 percentUsed: ((usage / quota) * 100).toFixed(1),
 persistent
 };
}

Quotas reales que medí:

Navegador Quota Default Con persist() Notas
Chrome (desktop) ~60% del disco Igual, pero sin eviction Generoso
Chrome (Android) ~50% del espacio libre Requiere user gesture Puede ser tan bajo como 2GB en dispositivos baratos
Firefox ~50% del disco, max 2GB por grupo 10% del disco Grupo = eTLD+1
Safari (macOS) ~1GB Sin soporte persist() Evictea después de 7 días sin visita
Safari (iOS) ~1GB Igual El más restrictivo

La política de eviction de 7 días de Safari es el gotcha operacional más grande. Si un inspector no abre la app por una semana, Safari puede borrar toda la base de datos. Lo resolvimos con:

  1. Pidiendo un shortcut en la pantalla de inicio (que recibe tratamiento de quota más generoso)
  2. Corriendo un sync periódico via Service Worker para "tocar" el storage
  3. Manteniendo un mecanismo de backup de exportación al storage local del dispositivo via la File System Access API

Arquitectura de Producción

Después de iterar por tres arquitecturas de storage, esto es lo que shippió:

┌─────────────────────────────────────────────┐
│ Main Thread │
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ React │ │ Sync │ │ Photo │ │
│ │ UI │ │ Manager │ │ Compressor│ │
│ └────┬─────┘ └────┬─────┘ └─────┬─────┘ │
│ │ │ │ │
│ └──────────┬───┘──────────────┘ │
│ │ postMessage │
├──────────────────┼────────────────────────────┤
│ ▼ │
│ DB Worker Thread │
│ ┌──────────────────────────────────┐ │
│ │ SQLite-WASM (OPFS backend) │ │
│ │ - inspections table │ │
│ │ - photos metadata table │ │
│ │ - sync_queue table │ │
│ │ - fts5 search index │ │
│ └──────────────────────────────────┘ │
│ ┌──────────────────────────────────┐ │
│ │ OPFS Direct │ │
│ │ - /photos/{id}.webp │ │
│ │ - /exports/{date}.zip │ │
│ └──────────────────────────────────┘ │
└───────────────────────────────────────────────┘

SQLite maneja todos los datos estructurados. OPFS maneja todos los archivos binarios. La comunicación entre el main thread y el worker usa un patrón simple de RPC-over-postMessage:

// db-rpc.js - Lado del main thread
let requestId = 0;
const pending = new Map();
const worker = new Worker('/db-worker.js', { type: 'module' });

worker.onmessage = ({ data }) => {
 const { id, result, error } = data;
 const { resolve, reject } = pending.get(id);
 pending.delete(id);
 error ? reject(new Error(error)) : resolve(result);
};

export function query(sql, params = []) {
 return new Promise((resolve, reject) => {
 const id = ++requestId;
 pending.set(id, { resolve, reject });
 worker.postMessage({ id, type: 'query', sql, params });
 });
}

Esto viene corriendo en producción hace tres meses en ~200 tablets. Incidentes de pérdida total de datos: cero. La arquitectura offline-first, una vez que armamos bien la capa de storage, ha sido sólida como una roca.

La lección no es que una sola storage API sea "la respuesta". La lección es que cada API tiene un punto óptimo específico: IndexedDB para patrones key-value simples, OPFS para datos tipo archivo y como backend de SQLite, y SQLite-over-WASM para cualquier cosa que necesite queries reales. Usalas juntas. Asegurate nomás de entender los límites de quota antes de que tus usuarios los peguen en el campo.