{"schemaVersion":1,"id":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm","title":"APIs de Almacenamiento del Navegador: OPFS, IndexedDB y SQLite-over-WASM para Aplicaciones Offline-First","description":"Análisis de las APIs modernas de almacenamiento del navegador: Origin Private File System, semántica de transacciones de IndexedDB, SQLite compilado a WASM, cuotas de almacenamiento y diseño de arquitectura offline-first.","language":"es","datePublished":"2025-10-23","dateModified":null,"contentFormat":"text/markdown","content":"Web APIs\n\n# APIs de Almacenamiento del Navegador: OPFS, IndexedDB y SQLite-over-WASM para Aplicaciones Offline-First\n\n2025-10-2311 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm>)\n\n## El Panorama de las Storage APIs en 2026\n\nLos navegadores ahora ofrecen cuatro mecanismos de storage significativos para datos de aplicación:\n\n1. **localStorage/sessionStorage**, 5-10MB, sincrónico, solo strings. Todavía útil para datos de config chiquitos. No relevante para esta discusión.\n2. **IndexedDB**, La API \"base de datos\". Object stores, índices, transacciones. Solo async. Soportado en todos lados.\n3. **Cache API**, Diseñada para cachear respuestas HTTP (Service Workers). notable para cachear fetch responses, no para datos arbitrarios de aplicación.\n4. **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.\n\nLa 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?\n\n## IndexedDB: El Diablo Conocido\n\nIndexedDB 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:\n\n```javascript\nfunction openInspectionDB() {\n return new Promise((resolve, reject) => {\n const request = indexedDB.open('InspectionDB', 3);\n\n request.onupgradeneeded = (event) => {\n const db = event.target.result;\n const oldVersion = event.oldVersion;\n\n if (oldVersion < 1) {\n const store = db.createObjectStore('inspections', {\n keyPath: 'id',\n autoIncrement: false\n });\n store.createIndex('by_building', 'buildingId', { unique: false });\n store.createIndex('by_status', 'status', { unique: false });\n store.createIndex('by_date', 'inspectionDate', { unique: false });\n }\n\n if (oldVersion < 2) {\n const store = event.target.transaction.objectStore('inspections');\n store.createIndex('by_building_date', ['buildingId', 'inspectionDate'], {\n unique: false\n });\n }\n\n if (oldVersion < 3) {\n const photoStore = db.createObjectStore('photos', { keyPath: 'id' });\n photoStore.createIndex('by_inspection', 'inspectionId', { unique: false });\n }\n };\n\n request.onsuccess = () => resolve(request.result);\n request.onerror = () => reject(request.error);\n });\n}\n```\n\nEste 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.\n\n### El Modelo de Transacciones Que Te Muerde\n\nLas transacciones de IndexedDB se auto-commitean cuando quedan idle. Esto significa:\n\n```javascript\n// ESTO VA A FALLAR de formas sutiles\nasync function updateInspectionWithPhotos(inspection, photos) {\n const db = await openInspectionDB();\n const tx = db.transaction(['inspections', 'photos'], 'readwrite');\n\n const inspStore = tx.objectStore('inspections');\n inspStore.put(inspection);\n\n // Si esta operación async tarda mucho, la transacción se auto-commitea\n // antes de que pasen las escrituras de fotos\n for (const photo of photos) {\n const compressed = await compressImage(photo.blob); // PELIGRO: gap async\n const photoStore = tx.objectStore('photos');\n photoStore.put({ ...photo, blob: compressed });\n }\n}\n```\n\nEl ` 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:\n\n```javascript\nasync function updateInspectionWithPhotos(inspection, photos) {\n // Hacé todo el trabajo async PRIMERO\n const compressed = await Promise.all(\n photos.map(async (photo) => ({\n ...photo,\n blob: await compressImage(photo.blob)\n }))\n );\n\n const db = await openInspectionDB();\n const tx = db.transaction(['inspections', 'photos'], 'readwrite');\n\n tx.objectStore('inspections').put(inspection);\n for (const photo of compressed) {\n tx.objectStore('photos').put(photo);\n }\n\n return new Promise((resolve, reject) => {\n tx.oncomplete = resolve;\n tx.onerror = () => reject(tx.error);\n });\n}\n```\n\nEste 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).\n\n### Características de Performance de IndexedDB\n\nBenchmarkeé 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):\n\n| Navegador | Insert 1000 Registros | Insert 500 Photo Blobs | Range Query (100 resultados) |\n| --- | --- | --- | --- |\n| Chrome 121 | 180ms | 2,400ms | 12ms |\n| Firefox 124 | 210ms | 3,100ms | 18ms |\n| Safari 17.4 | 340ms | 4,800ms | 24ms |\n\nLa performance de blob storage de Safari es notablemente peor. En iPads, que eran la mitad de nuestra flota de dispositivos, esto importó mucho.\n\n## OPFS: El Filesystem en Tu Navegador\n\nEl 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.\n\n```javascript\nasync function setupOPFS() {\n const root = await navigator.storage.getDirectory();\n\n const dbDir = await root.getDirectoryHandle('databases', { create: true });\n const photosDir = await root.getDirectoryHandle('photos', { create: true });\n\n const configFile = await root.getFileHandle('config.json', { create: true });\n\n const writable = await configFile.createWritable();\n await writable.write(JSON.stringify({ version: 1, lastSync: null }));\n await writable.close();\n\n const file = await configFile.getFile();\n const text = await file.text();\n return JSON.parse(text);\n}\n```\n\nEsa 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:\n\n```javascript\n// worker.js\nasync function writeLargeFile(dirHandle, filename, data) {\n const fileHandle = await dirHandle.getFileHandle(filename, { create: true });\n const accessHandle = await fileHandle.createSyncAccessHandle();\n\n const encoder = new TextEncoder();\n const encoded = encoder.encode(data);\n\n // Escrituras sincrónicas - sin promesas, sin await\n accessHandle.write(encoded, { at: 0 });\n accessHandle.truncate(encoded.byteLength);\n accessHandle.flush();\n accessHandle.close();\n\n return encoded.byteLength;\n}\n```\n\n` 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.\n\n## SQLite-over-WASM: La Opción Nuclear\n\nEl 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.\n\n### Configurarndolo\n\n```javascript\n// worker.js - SQLite tiene que correr en un Worker para sync access de OPFS\nimport sqlite3InitModule from '@official-sqlite/wasm';\n\nlet db = null;\n\nasync function initDatabase() {\n const sqlite3 = await sqlite3InitModule();\n\n if (sqlite3.oo1.OpfsDb) {\n db = new sqlite3.oo1.OpfsDb('/inspections.db');\n } else {\n db = new sqlite3.oo1.DB(':memory:');\n console.warn('OPFS no disponible, usando base en memoria');\n }\n\n db.exec(`\n CREATE TABLE IF NOT EXISTS inspections (\n id TEXT PRIMARY KEY,\n building_id TEXT NOT NULL,\n inspector_id TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'draft',\n inspection_date TEXT NOT NULL,\n data JSON NOT NULL,\n created_at TEXT DEFAULT (datetime('now')),\n updated_at TEXT DEFAULT (datetime('now')),\n sync_status TEXT DEFAULT 'pending'\n );\n\n CREATE INDEX IF NOT EXISTS idx_inspections_building\n ON inspections(building_id);\n\n CREATE INDEX IF NOT EXISTS idx_inspections_sync\n ON inspections(sync_status) WHERE sync_status = 'pending';\n\n CREATE TABLE IF NOT EXISTS photos (\n id TEXT PRIMARY KEY,\n inspection_id TEXT NOT NULL REFERENCES inspections(id),\n blob_path TEXT NOT NULL,\n thumbnail BLOB,\n metadata JSON,\n created_at TEXT DEFAULT (datetime('now'))\n );\n\n CREATE INDEX IF NOT EXISTS idx_photos_inspection\n ON photos(inspection_id);\n `);\n\n return db;\n}\n```\n\nNotá 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.\n\n### Performance de Queries\n\nAcá es donde SQLite brilla comparado con IndexedDB, queries complejas:\n\n```javascript\nfunction getInspectionSummary(buildingId, startDate, endDate) {\n return db.exec({\n sql: `\n SELECT\n i.status,\n COUNT(*) as count,\n COUNT(DISTINCT i.inspector_id) as inspectors,\n AVG(json_array_length(json_extract(i.data, '$.checklist_items'))) as avg_items,\n (\n SELECT COUNT(*)\n FROM photos p\n WHERE p.inspection_id = i.id\n ) as photo_count\n FROM inspections i\n WHERE i.building_id = ?\n AND i.inspection_date BETWEEN ? AND ?\n GROUP BY i.status\n ORDER BY count DESC\n `,\n bind: [buildingId, startDate, endDate],\n returnValue: 'resultRows',\n rowMode: 'object'\n });\n}\n```\n\nIntentá 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.\n\n### Benchmarks: SQLite-over-WASM vs IndexedDB\n\nMismo workload de antes, Chrome 121:\n\n| Operación | IndexedDB | SQLite-WASM (OPFS) | Factor |\n| --- | --- | --- | --- |\n| Insert 1000 registros (una tx) | 180ms | 45ms | 4x más rápido |\n| Range query (100 resultados) | 12ms | 3ms | 4x más rápido |\n| Aggregate query (COUNT/GROUP BY) | 890ms\\* | 8ms | 111x más rápido |\n| Full-text search (1000 docs) | N/A\\*\\* | 14ms | - |\n\n\\* IndexedDB aggregate requiere cursor iteration sobre todos los registros que matchean. \\*\\* IndexedDB no tiene full-text search built-in.\n\nLa 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.\n\n## Cuotas de Almacenamiento: Limitaciones por Navegador\n\nCada navegador limita cuántos datos puede guardar tu origin. La API ` StorageManager ` te dice con qué se está laburando:\n\n```javascript\nasync function checkStorageQuota() {\n if (!navigator.storage?.estimate) {\n return { supported: false };\n }\n\n const estimate = await navigator.storage.estimate();\n const usage = estimate.usage || 0;\n const quota = estimate.quota || 0;\n\n let persistent = false;\n if (navigator.storage.persist) {\n persistent = await navigator.storage.persist();\n }\n\n return {\n supported: true,\n usedMB: Math.round(usage / 1024 / 1024),\n quotaMB: Math.round(quota / 1024 / 1024),\n percentUsed: ((usage / quota) * 100).toFixed(1),\n persistent\n };\n}\n```\n\nQuotas reales que medí:\n\n| Navegador | Quota Default | Con ` persist() ` | Notas |\n| --- | --- | --- | --- |\n| Chrome (desktop) | ~60% del disco | Igual, pero sin eviction | Generoso |\n| Chrome (Android) | ~50% del espacio libre | Requiere user gesture | Puede ser tan bajo como 2GB en dispositivos baratos |\n| Firefox | ~50% del disco, max 2GB por grupo | 10% del disco | Grupo = eTLD+1 |\n| Safari (macOS) | ~1GB | Sin soporte persist() | Evictea después de 7 días sin visita |\n| Safari (iOS) | ~1GB | Igual | El más restrictivo |\n\nLa 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:\n\n1. Pidiendo un shortcut en la pantalla de inicio (que recibe tratamiento de quota más generoso)\n2. Corriendo un sync periódico via Service Worker para \"tocar\" el storage\n3. Manteniendo un mecanismo de backup de exportación al storage local del dispositivo via la File System Access API\n\n## Arquitectura de Producción\n\nDespués de iterar por tres arquitecturas de storage, esto es lo que shippió:\n\n```\n┌─────────────────────────────────────────────┐\n│ Main Thread │\n│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │\n│ │ React │ │ Sync │ │ Photo │ │\n│ │ UI │ │ Manager │ │ Compressor│ │\n│ └────┬─────┘ └────┬─────┘ └─────┬─────┘ │\n│ │ │ │ │\n│ └──────────┬───┘──────────────┘ │\n│ │ postMessage │\n├──────────────────┼────────────────────────────┤\n│ ▼ │\n│ DB Worker Thread │\n│ ┌──────────────────────────────────┐ │\n│ │ SQLite-WASM (OPFS backend) │ │\n│ │ - inspections table │ │\n│ │ - photos metadata table │ │\n│ │ - sync_queue table │ │\n│ │ - fts5 search index │ │\n│ └──────────────────────────────────┘ │\n│ ┌──────────────────────────────────┐ │\n│ │ OPFS Direct │ │\n│ │ - /photos/{id}.webp │ │\n│ │ - /exports/{date}.zip │ │\n│ └──────────────────────────────────┘ │\n└───────────────────────────────────────────────┘\n```\n\nSQLite 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:\n\n```javascript\n// db-rpc.js - Lado del main thread\nlet requestId = 0;\nconst pending = new Map();\nconst worker = new Worker('/db-worker.js', { type: 'module' });\n\nworker.onmessage = ({ data }) => {\n const { id, result, error } = data;\n const { resolve, reject } = pending.get(id);\n pending.delete(id);\n error ? reject(new Error(error)) : resolve(result);\n};\n\nexport function query(sql, params = []) {\n return new Promise((resolve, reject) => {\n const id = ++requestId;\n pending.set(id, { resolve, reject });\n worker.postMessage({ id, type: 'query', sql, params });\n });\n}\n```\n\nEsto 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.\n\nLa 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.\n\n## Preguntas frecuentes\n\n### ¿OPFS está soportado en todos los navegadores principales?\n\nA principios de 2026, OPFS está soportado en Chrome 102+, Firefox 111+, Safari 15.2+, y Edge 102+. La API de sync access handle (usada para SQLite-over-WASM) requiere contexto de Web Worker y está disponible en Chrome 102+, Firefox 111+ y Safari 17.4+.\n\n### ¿SQLite-over-WASM puede manejar bases de datos grandes en el navegador?\n\nSí, con OPFS como backend. Testeamos bases de datos de hasta 2GB en Chrome sin problemas. La restricción principal es la quota de storage del navegador, que varía por navegador y espacio en disco disponible. La performance degrada de forma graciosa, una base de 500MB con índices apropiados sigue entregando queries sub-10ms.\n\n### ¿Debería usar IndexedDB u OPFS para un proyecto nuevo?\n\nDepende de tu patrón de acceso. IndexedDB es mejor para datos key-value estructurados con índices. OPFS es mejor para datos tipo archivo, blobs binarios, o cuando se desea correr SQLite. Para apps offline-first con queries complejas, SQLite-over-WASM con OPFS es la mejor opción en 2026.\n\n## Tips\n\n1. **LuchoTip:** Siempre verificar ` navigator.storage.estimate() ` antes de escribir datasets grandes. La quota de Safari puede ser tan baja como 1GB en algunos casos, y excederla falla silenciosamente en vez de tirar una excepción en ciertos contextos.\n2. **LuchoTip:** Utilizar OPFS via un Web Worker dedicado con ` createSyncAccessHandle() ` para SQLite. La API sincrónica es 5-10x más rápida que la variante async del main thread y no te bloquea la UI.\n3. **LuchoTip:** Wrappeá IndexedDB en una abstracción thin basada en promesas pero NO uses un ORM completo. Librerías como ` idb ` suman ~1.5KB y te salvan del callback hell sin esconderte el modelo de transacciones que se necesita entender.\n\n- opfs\n- indexeddb\n- sqlite\n- wasm\n- offline-first\n- storage-api\n- web-workers\n- browser-storage\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)","headings":[{"level":1,"text":"APIs de Almacenamiento del Navegador: OPFS, IndexedDB y SQLite-over-WASM para Aplicaciones Offline-First","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#title"},{"level":2,"text":"El Panorama de las Storage APIs en 2026","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#el-panorama-de-las-storage-apis-en-2026"},{"level":2,"text":"IndexedDB: El Diablo Conocido","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#indexeddb-el-diablo-conocido"},{"level":3,"text":"El Modelo de Transacciones Que Te Muerde","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#el-modelo-de-transacciones-que-te-muerde"},{"level":3,"text":"Características de Performance de IndexedDB","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#caractersticas-de-performance-de-indexeddb"},{"level":2,"text":"OPFS: El Filesystem en Tu Navegador","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#opfs-el-filesystem-en-tu-navegador"},{"level":2,"text":"SQLite-over-WASM: La Opción Nuclear","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#sqlite-over-wasm-la-opcin-nuclear"},{"level":3,"text":"Configurarndolo","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#configurarndolo"},{"level":3,"text":"Performance de Queries","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#performance-de-queries"},{"level":3,"text":"Benchmarks: SQLite-over-WASM vs IndexedDB","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#benchmarks-sqlite-over-wasm-vs-indexeddb"},{"level":2,"text":"Cuotas de Almacenamiento: Limitaciones por Navegador","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#cuotas-de-almacenamiento-limitaciones-por-navegador"},{"level":2,"text":"Arquitectura de Producción","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#arquitectura-de-produccin"},{"level":2,"text":"Preguntas frecuentes","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#questions"},{"level":3,"text":"¿OPFS está soportado en todos los navegadores principales?","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#question-1"},{"level":3,"text":"¿SQLite-over-WASM puede manejar bases de datos grandes en el navegador?","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#question-2"},{"level":3,"text":"¿Debería usar IndexedDB u OPFS para un proyecto nuevo?","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#article-tips"}],"sections":[{"heading":"APIs de Almacenamiento del Navegador: OPFS, IndexedDB y SQLite-over-WASM para Aplicaciones Offline-First","level":1,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm","content":"Web APIs"},{"heading":"APIs de Almacenamiento del Navegador: OPFS, IndexedDB y SQLite-over-WASM para Aplicaciones Offline-First","level":1,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#title","content":"2025-10-2311 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm>)"},{"heading":"El Panorama de las Storage APIs en 2026","level":2,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#el-panorama-de-las-storage-apis-en-2026","content":"Los navegadores ahora ofrecen cuatro mecanismos de storage significativos para datos de aplicación:\n\n1. **localStorage/sessionStorage**, 5-10MB, sincrónico, solo strings. Todavía útil para datos de config chiquitos. No relevante para esta discusión.\n2. **IndexedDB**, La API \"base de datos\". Object stores, índices, transacciones. Solo async. Soportado en todos lados.\n3. **Cache API**, Diseñada para cachear respuestas HTTP (Service Workers). notable para cachear fetch responses, no para datos arbitrarios de aplicación.\n4. **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.\n\nLa 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?"},{"heading":"IndexedDB: El Diablo Conocido","level":2,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#indexeddb-el-diablo-conocido","content":"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:\n\n```javascript\nfunction openInspectionDB() {\n return new Promise((resolve, reject) => {\n const request = indexedDB.open('InspectionDB', 3);\n\n request.onupgradeneeded = (event) => {\n const db = event.target.result;\n const oldVersion = event.oldVersion;\n\n if (oldVersion < 1) {\n const store = db.createObjectStore('inspections', {\n keyPath: 'id',\n autoIncrement: false\n });\n store.createIndex('by_building', 'buildingId', { unique: false });\n store.createIndex('by_status', 'status', { unique: false });\n store.createIndex('by_date', 'inspectionDate', { unique: false });\n }\n\n if (oldVersion < 2) {\n const store = event.target.transaction.objectStore('inspections');\n store.createIndex('by_building_date', ['buildingId', 'inspectionDate'], {\n unique: false\n });\n }\n\n if (oldVersion < 3) {\n const photoStore = db.createObjectStore('photos', { keyPath: 'id' });\n photoStore.createIndex('by_inspection', 'inspectionId', { unique: false });\n }\n };\n\n request.onsuccess = () => resolve(request.result);\n request.onerror = () => reject(request.error);\n });\n}\n```\n\nEste 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."},{"heading":"El Modelo de Transacciones Que Te Muerde","level":3,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#el-modelo-de-transacciones-que-te-muerde","content":"Las transacciones de IndexedDB se auto-commitean cuando quedan idle. Esto significa:\n\n```javascript\n// ESTO VA A FALLAR de formas sutiles\nasync function updateInspectionWithPhotos(inspection, photos) {\n const db = await openInspectionDB();\n const tx = db.transaction(['inspections', 'photos'], 'readwrite');\n\n const inspStore = tx.objectStore('inspections');\n inspStore.put(inspection);\n\n // Si esta operación async tarda mucho, la transacción se auto-commitea\n // antes de que pasen las escrituras de fotos\n for (const photo of photos) {\n const compressed = await compressImage(photo.blob); // PELIGRO: gap async\n const photoStore = tx.objectStore('photos');\n photoStore.put({ ...photo, blob: compressed });\n }\n}\n```\n\nEl ` 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:\n\n```javascript\nasync function updateInspectionWithPhotos(inspection, photos) {\n // Hacé todo el trabajo async PRIMERO\n const compressed = await Promise.all(\n photos.map(async (photo) => ({\n ...photo,\n blob: await compressImage(photo.blob)\n }))\n );\n\n const db = await openInspectionDB();\n const tx = db.transaction(['inspections', 'photos'], 'readwrite');\n\n tx.objectStore('inspections').put(inspection);\n for (const photo of compressed) {\n tx.objectStore('photos').put(photo);\n }\n\n return new Promise((resolve, reject) => {\n tx.oncomplete = resolve;\n tx.onerror = () => reject(tx.error);\n });\n}\n```\n\nEste 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)."},{"heading":"Características de Performance de IndexedDB","level":3,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#caractersticas-de-performance-de-indexeddb","content":"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):\n\n| Navegador | Insert 1000 Registros | Insert 500 Photo Blobs | Range Query (100 resultados) |\n| --- | --- | --- | --- |\n| Chrome 121 | 180ms | 2,400ms | 12ms |\n| Firefox 124 | 210ms | 3,100ms | 18ms |\n| Safari 17.4 | 340ms | 4,800ms | 24ms |\n\nLa performance de blob storage de Safari es notablemente peor. En iPads, que eran la mitad de nuestra flota de dispositivos, esto importó mucho."},{"heading":"OPFS: El Filesystem en Tu Navegador","level":2,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#opfs-el-filesystem-en-tu-navegador","content":"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.\n\n```javascript\nasync function setupOPFS() {\n const root = await navigator.storage.getDirectory();\n\n const dbDir = await root.getDirectoryHandle('databases', { create: true });\n const photosDir = await root.getDirectoryHandle('photos', { create: true });\n\n const configFile = await root.getFileHandle('config.json', { create: true });\n\n const writable = await configFile.createWritable();\n await writable.write(JSON.stringify({ version: 1, lastSync: null }));\n await writable.close();\n\n const file = await configFile.getFile();\n const text = await file.text();\n return JSON.parse(text);\n}\n```\n\nEsa 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:\n\n```javascript\n// worker.js\nasync function writeLargeFile(dirHandle, filename, data) {\n const fileHandle = await dirHandle.getFileHandle(filename, { create: true });\n const accessHandle = await fileHandle.createSyncAccessHandle();\n\n const encoder = new TextEncoder();\n const encoded = encoder.encode(data);\n\n // Escrituras sincrónicas - sin promesas, sin await\n accessHandle.write(encoded, { at: 0 });\n accessHandle.truncate(encoded.byteLength);\n accessHandle.flush();\n accessHandle.close();\n\n return encoded.byteLength;\n}\n```\n\n` 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."},{"heading":"SQLite-over-WASM: La Opción Nuclear","level":2,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#sqlite-over-wasm-la-opcin-nuclear","content":"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."},{"heading":"Configurarndolo","level":3,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#configurarndolo","content":"```javascript\n// worker.js - SQLite tiene que correr en un Worker para sync access de OPFS\nimport sqlite3InitModule from '@official-sqlite/wasm';\n\nlet db = null;\n\nasync function initDatabase() {\n const sqlite3 = await sqlite3InitModule();\n\n if (sqlite3.oo1.OpfsDb) {\n db = new sqlite3.oo1.OpfsDb('/inspections.db');\n } else {\n db = new sqlite3.oo1.DB(':memory:');\n console.warn('OPFS no disponible, usando base en memoria');\n }\n\n db.exec(`\n CREATE TABLE IF NOT EXISTS inspections (\n id TEXT PRIMARY KEY,\n building_id TEXT NOT NULL,\n inspector_id TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'draft',\n inspection_date TEXT NOT NULL,\n data JSON NOT NULL,\n created_at TEXT DEFAULT (datetime('now')),\n updated_at TEXT DEFAULT (datetime('now')),\n sync_status TEXT DEFAULT 'pending'\n );\n\n CREATE INDEX IF NOT EXISTS idx_inspections_building\n ON inspections(building_id);\n\n CREATE INDEX IF NOT EXISTS idx_inspections_sync\n ON inspections(sync_status) WHERE sync_status = 'pending';\n\n CREATE TABLE IF NOT EXISTS photos (\n id TEXT PRIMARY KEY,\n inspection_id TEXT NOT NULL REFERENCES inspections(id),\n blob_path TEXT NOT NULL,\n thumbnail BLOB,\n metadata JSON,\n created_at TEXT DEFAULT (datetime('now'))\n );\n\n CREATE INDEX IF NOT EXISTS idx_photos_inspection\n ON photos(inspection_id);\n `);\n\n return db;\n}\n```\n\nNotá 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."},{"heading":"Performance de Queries","level":3,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#performance-de-queries","content":"Acá es donde SQLite brilla comparado con IndexedDB, queries complejas:\n\n```javascript\nfunction getInspectionSummary(buildingId, startDate, endDate) {\n return db.exec({\n sql: `\n SELECT\n i.status,\n COUNT(*) as count,\n COUNT(DISTINCT i.inspector_id) as inspectors,\n AVG(json_array_length(json_extract(i.data, '$.checklist_items'))) as avg_items,\n (\n SELECT COUNT(*)\n FROM photos p\n WHERE p.inspection_id = i.id\n ) as photo_count\n FROM inspections i\n WHERE i.building_id = ?\n AND i.inspection_date BETWEEN ? AND ?\n GROUP BY i.status\n ORDER BY count DESC\n `,\n bind: [buildingId, startDate, endDate],\n returnValue: 'resultRows',\n rowMode: 'object'\n });\n}\n```\n\nIntentá 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."},{"heading":"Benchmarks: SQLite-over-WASM vs IndexedDB","level":3,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#benchmarks-sqlite-over-wasm-vs-indexeddb","content":"Mismo workload de antes, Chrome 121:\n\n| Operación | IndexedDB | SQLite-WASM (OPFS) | Factor |\n| --- | --- | --- | --- |\n| Insert 1000 registros (una tx) | 180ms | 45ms | 4x más rápido |\n| Range query (100 resultados) | 12ms | 3ms | 4x más rápido |\n| Aggregate query (COUNT/GROUP BY) | 890ms\\* | 8ms | 111x más rápido |\n| Full-text search (1000 docs) | N/A\\*\\* | 14ms | - |\n\n\\* IndexedDB aggregate requiere cursor iteration sobre todos los registros que matchean. \\*\\* IndexedDB no tiene full-text search built-in.\n\nLa 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."},{"heading":"Cuotas de Almacenamiento: Limitaciones por Navegador","level":2,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#cuotas-de-almacenamiento-limitaciones-por-navegador","content":"Cada navegador limita cuántos datos puede guardar tu origin. La API ` StorageManager ` te dice con qué se está laburando:\n\n```javascript\nasync function checkStorageQuota() {\n if (!navigator.storage?.estimate) {\n return { supported: false };\n }\n\n const estimate = await navigator.storage.estimate();\n const usage = estimate.usage || 0;\n const quota = estimate.quota || 0;\n\n let persistent = false;\n if (navigator.storage.persist) {\n persistent = await navigator.storage.persist();\n }\n\n return {\n supported: true,\n usedMB: Math.round(usage / 1024 / 1024),\n quotaMB: Math.round(quota / 1024 / 1024),\n percentUsed: ((usage / quota) * 100).toFixed(1),\n persistent\n };\n}\n```\n\nQuotas reales que medí:\n\n| Navegador | Quota Default | Con ` persist() ` | Notas |\n| --- | --- | --- | --- |\n| Chrome (desktop) | ~60% del disco | Igual, pero sin eviction | Generoso |\n| Chrome (Android) | ~50% del espacio libre | Requiere user gesture | Puede ser tan bajo como 2GB en dispositivos baratos |\n| Firefox | ~50% del disco, max 2GB por grupo | 10% del disco | Grupo = eTLD+1 |\n| Safari (macOS) | ~1GB | Sin soporte persist() | Evictea después de 7 días sin visita |\n| Safari (iOS) | ~1GB | Igual | El más restrictivo |\n\nLa 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:\n\n1. Pidiendo un shortcut en la pantalla de inicio (que recibe tratamiento de quota más generoso)\n2. Corriendo un sync periódico via Service Worker para \"tocar\" el storage\n3. Manteniendo un mecanismo de backup de exportación al storage local del dispositivo via la File System Access API"},{"heading":"Arquitectura de Producción","level":2,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#arquitectura-de-produccin","content":"Después de iterar por tres arquitecturas de storage, esto es lo que shippió:\n\n```\n┌─────────────────────────────────────────────┐\n│ Main Thread │\n│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │\n│ │ React │ │ Sync │ │ Photo │ │\n│ │ UI │ │ Manager │ │ Compressor│ │\n│ └────┬─────┘ └────┬─────┘ └─────┬─────┘ │\n│ │ │ │ │\n│ └──────────┬───┘──────────────┘ │\n│ │ postMessage │\n├──────────────────┼────────────────────────────┤\n│ ▼ │\n│ DB Worker Thread │\n│ ┌──────────────────────────────────┐ │\n│ │ SQLite-WASM (OPFS backend) │ │\n│ │ - inspections table │ │\n│ │ - photos metadata table │ │\n│ │ - sync_queue table │ │\n│ │ - fts5 search index │ │\n│ └──────────────────────────────────┘ │\n│ ┌──────────────────────────────────┐ │\n│ │ OPFS Direct │ │\n│ │ - /photos/{id}.webp │ │\n│ │ - /exports/{date}.zip │ │\n│ └──────────────────────────────────┘ │\n└───────────────────────────────────────────────┘\n```\n\nSQLite 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:\n\n```javascript\n// db-rpc.js - Lado del main thread\nlet requestId = 0;\nconst pending = new Map();\nconst worker = new Worker('/db-worker.js', { type: 'module' });\n\nworker.onmessage = ({ data }) => {\n const { id, result, error } = data;\n const { resolve, reject } = pending.get(id);\n pending.delete(id);\n error ? reject(new Error(error)) : resolve(result);\n};\n\nexport function query(sql, params = []) {\n return new Promise((resolve, reject) => {\n const id = ++requestId;\n pending.set(id, { resolve, reject });\n worker.postMessage({ id, type: 'query', sql, params });\n });\n}\n```\n\nEsto 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.\n\nLa 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."},{"heading":"Preguntas frecuentes","level":2,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#questions","content":""},{"heading":"¿OPFS está soportado en todos los navegadores principales?","level":3,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#question-1","content":"A principios de 2026, OPFS está soportado en Chrome 102+, Firefox 111+, Safari 15.2+, y Edge 102+. La API de sync access handle (usada para SQLite-over-WASM) requiere contexto de Web Worker y está disponible en Chrome 102+, Firefox 111+ y Safari 17.4+."},{"heading":"¿SQLite-over-WASM puede manejar bases de datos grandes en el navegador?","level":3,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#question-2","content":"Sí, con OPFS como backend. Testeamos bases de datos de hasta 2GB en Chrome sin problemas. La restricción principal es la quota de storage del navegador, que varía por navegador y espacio en disco disponible. La performance degrada de forma graciosa, una base de 500MB con índices apropiados sigue entregando queries sub-10ms."},{"heading":"¿Debería usar IndexedDB u OPFS para un proyecto nuevo?","level":3,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#question-3","content":"Depende de tu patrón de acceso. IndexedDB es mejor para datos key-value estructurados con índices. OPFS es mejor para datos tipo archivo, blobs binarios, o cuando se desea correr SQLite. Para apps offline-first con queries complejas, SQLite-over-WASM con OPFS es la mejor opción en 2026."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#article-tips","content":"1. **LuchoTip:** Siempre verificar ` navigator.storage.estimate() ` antes de escribir datasets grandes. La quota de Safari puede ser tan baja como 1GB en algunos casos, y excederla falla silenciosamente en vez de tirar una excepción en ciertos contextos.\n2. **LuchoTip:** Utilizar OPFS via un Web Worker dedicado con ` createSyncAccessHandle() ` para SQLite. La API sincrónica es 5-10x más rápida que la variante async del main thread y no te bloquea la UI.\n3. **LuchoTip:** Wrappeá IndexedDB en una abstracción thin basada en promesas pero NO uses un ORM completo. Librerías como ` idb ` suman ~1.5KB y te salvan del callback hell sin esconderte el modelo de transacciones que se necesita entender.\n\n- opfs\n- indexeddb\n- sqlite\n- wasm\n- offline-first\n- storage-api\n- web-workers\n- browser-storage\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)"}],"links":[{"text":"English","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm"},{"text":"Volver al Blog","url":"https://lucioduran.com/es/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm"},"headline":"APIs de Almacenamiento del Navegador: OPFS, IndexedDB y SQLite-over-WASM para Aplicaciones Offline-First","description":"Análisis de las APIs modernas de almacenamiento del navegador: Origin Private File System, semántica de transacciones de IndexedDB, SQLite compilado a WASM, cuotas de almacenamiento y diseño de arquitectura offline-first.","datePublished":"2025-10-23","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://statics.lucioduran.com/images/lucioduran-social-card.jpg","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm","inLanguage":"es","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Web APIs","keywords":"opfs, indexeddb, sqlite, wasm, offline-first, storage-api, web-workers, browser-storage","wordCount":1919,"timeRequired":"PT11M","workTranslation":[{"@type":"BlogPosting","inLanguage":"en","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Inicio","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/es/blog"},{"@type":"ListItem","position":3,"name":"APIs de Almacenamiento del Navegador: OPFS, IndexedDB y SQLite-over-WASM para Aplicaciones Offline-First","item":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"¿OPFS está soportado en todos los navegadores principales?","acceptedAnswer":{"@type":"Answer","text":"A principios de 2026, OPFS está soportado en Chrome 102+, Firefox 111+, Safari 15.2+, y Edge 102+. La API de sync access handle (usada para SQLite-over-WASM) requiere contexto de Web Worker y está disponible en Chrome 102+, Firefox 111+ y Safari 17.4+."}},{"@type":"Question","name":"¿SQLite-over-WASM puede manejar bases de datos grandes en el navegador?","acceptedAnswer":{"@type":"Answer","text":"Sí, con OPFS como backend. Testeamos bases de datos de hasta 2GB en Chrome sin problemas. La restricción principal es la quota de storage del navegador, que varía por navegador y espacio en disco disponible. La performance degrada de forma graciosa, una base de 500MB con índices apropiados sigue entregando queries sub-10ms."}},{"@type":"Question","name":"¿Debería usar IndexedDB u OPFS para un proyecto nuevo?","acceptedAnswer":{"@type":"Answer","text":"Depende de tu patrón de acceso. IndexedDB es mejor para datos key-value estructurados con índices. OPFS es mejor para datos tipo archivo, blobs binarios, o cuando se desea correr SQLite. Para apps offline-first con queries complejas, SQLite-over-WASM con OPFS es la mejor opción en 2026."}}]}]}],"contentHash":"2cd170ec89f2401881b2d93849978495a269efa7a9401d053129dcbcbc396df1","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":79994,"contentBytes":17681}}
