{"schemaVersion":1,"id":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm","title":"Browser Storage APIs: OPFS, IndexedDB, and SQLite-over-WASM for Offline-First Applications","description":"An analysis of modern browser storage APIs, Origin Private File System, IndexedDB transaction semantics, SQLite compiled to WASM, storage quotas, and offline-first architecture design.","language":"en","datePublished":"2025-10-23","dateModified":null,"contentFormat":"text/markdown","content":"Web APIs\n\n# Browser Storage APIs: OPFS, IndexedDB, and SQLite-over-WASM for Offline-First Applications\n\n2025-10-2310 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm>)\n\n## The Storage API Landscape in 2026\n\nBrowsers now offer four meaningful storage mechanisms for application data:\n\n1. **localStorage/sessionStorage**, 5-10MB, synchronous, string-only. Still useful for small config data. Not relevant for this discussion.\n2. **IndexedDB**, The \"database\" API. Object stores, indexes, transactions. Async only. Supported everywhere.\n3. **Cache API**, Designed for HTTP response caching (Service Workers). Great for caching fetch responses, not for arbitrary application data.\n4. **OPFS (Origin Private File System)**, A real filesystem API scoped to your origin. File handles, read/write streams, and critically, a synchronous access API in Worker contexts.\n\nThe interesting architectural question is: do you use IndexedDB as your database, or do you use OPFS as a filesystem and run an actual database (SQLite) on top of it?\n\n## IndexedDB: The Devil You Know\n\nIndexedDB has been around since 2011. It's mature, widely supported, and thoroughly frustrating. Let me show you a real pattern from the inspection app, storing inspection records with compound indexes:\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\nThis pattern, versioned migrations in ` onupgradeneeded `, is the IndexedDB equivalent of database migrations. It works, but notice the gymnastics: you can't add an index to an existing store outside of a version upgrade, and you need to handle every version transition.\n\n### The Transaction Model That Bites You\n\nIndexedDB transactions auto-commit when they go idle. This means:\n\n```javascript\n// THIS WILL FAIL in subtle ways\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 // If this async operation takes too long, the transaction auto-commits\n // before the photo writes happen\n for (const photo of photos) {\n const compressed = await compressImage(photo.blob); // DANGER: async gap\n const photoStore = tx.objectStore('photos');\n photoStore.put({ ...photo, blob: compressed });\n }\n}\n```\n\nThe ` await compressImage() ` creates a microtask gap. If the compression takes more than ~a few hundred milliseconds, the transaction commits or aborts before the photo writes execute. The fix is to do all async work before opening the transaction:\n\n```javascript\nasync function updateInspectionWithPhotos(inspection, photos) {\n // Do all async work FIRST\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\nThis is the kind of thing that works in development (small images, fast compression) and breaks in production (14MP camera photos on a tablet processor).\n\n### IndexedDB Performance Characteristics\n\nI benchmarked IndexedDB across browsers for our typical workload, batch-inserting 1000 inspection records (~2KB each) and 500 photos (~200KB each):\n\n| Browser | 1000 Records Insert | 500 Photo Blobs Insert | Range Query (100 results) |\n| --- | --- | --- | --- |\n| Chrome 121 | 180ms | 2,400ms | 12ms |\n| Firefox 124 | 210ms | 3,100ms | 18ms |\n| Safari 17.4 | 340ms | 4,800ms | 24ms |\n\nSafari's blob storage performance is notably worse. On iPads, which were half our device fleet, this mattered a lot.\n\n## OPFS: The Filesystem in Your Browser\n\nThe Origin Private File System gives you a real hierarchical filesystem, scoped to your origin. No user prompts, no file picker dialogs, it's entirely programmatic.\n\n```javascript\nasync function setupOPFS() {\n const root = await navigator.storage.getDirectory();\n\n // Create directory structure\n const dbDir = await root.getDirectoryHandle('databases', { create: true });\n const photosDir = await root.getDirectoryHandle('photos', { create: true });\n\n // Create a file\n const configFile = await root.getFileHandle('config.json', { create: true });\n\n // Write to it\n const writable = await configFile.createWritable();\n await writable.write(JSON.stringify({ version: 1, lastSync: null }));\n await writable.close();\n\n // Read it back\n const file = await configFile.getFile();\n const text = await file.text();\n return JSON.parse(text);\n}\n```\n\nThat's the async API, which runs on the main thread. It's fine for small operations. But the real power is the synchronous access handle, available only in 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 // Synchronous writes - no promises, no 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() ` gives you ` read() `, ` write() `, ` truncate() `, ` flush() `, and ` close() `, all synchronous. This is what makes SQLite-over-WASM practical, because SQLite's VFS (Virtual File System) layer expects synchronous I/O.\n\n## SQLite-over-WASM: The Nuclear Option\n\nThe SQLite team officially supports a WASM build since late 2022, and it's matured significantly. Combined with OPFS, you get a real SQL database in the browser with ACID transactions, complex queries, full-text search, and window functions.\n\n### Setting It Up\n\n```javascript\n// worker.js - SQLite must run in a Worker for OPFS sync access\nimport sqlite3InitModule from '@official-sqlite/wasm';\n\nlet db = null;\n\nasync function initDatabase() {\n const sqlite3 = await sqlite3InitModule();\n\n // Use OPFS as the VFS backend\n if (sqlite3.oo1.OpfsDb) {\n db = new sqlite3.oo1.OpfsDb('/inspections.db');\n } else {\n // Fallback to in-memory if OPFS unavailable\n db = new sqlite3.oo1.DB(':memory:');\n console.warn('OPFS not available, using in-memory database');\n }\n\n // Run migrations\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\nNotice that photos are stored as file paths, not blobs in SQLite. The actual image data lives in OPFS files, and SQLite stores the path. This is a critical architectural decision, SQLite handles blobs fine, but your database file balloons quickly and performance degrades. Keep metadata in SQLite, keep binary data in OPFS files.\n\n### Query Performance\n\nHere's where SQLite shines compared to IndexedDB, complex queries:\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\nTry writing that query with IndexedDB. You'd need to open a cursor, iterate over every matching record, manually aggregate, and handle the join to photos yourself. With SQLite, it's just SQL.\n\n### Benchmarks: SQLite-over-WASM vs IndexedDB\n\nSame workload as before, Chrome 121:\n\n| Operation | IndexedDB | SQLite-WASM (OPFS) | Factor |\n| --- | --- | --- | --- |\n| Insert 1000 records (one tx) | 180ms | 45ms | 4x faster |\n| Range query (100 results) | 12ms | 3ms | 4x faster |\n| Aggregate query (COUNT/GROUP BY) | 890ms\\* | 8ms | 111x faster |\n| Full-text search (1000 docs) | N/A\\*\\* | 14ms | - |\n\n\\* IndexedDB aggregate requires cursor iteration over all matching records. \\*\\* IndexedDB has no built-in full-text search.\n\nThe aggregate query difference is staggering. IndexedDB doesn't have aggregation primitives, you have to read every record and compute in JavaScript. SQLite does it in compiled WASM code with optimized B-tree traversal.\n\n## Storage Quotas: Browser-Specific Limitations\n\nEvery browser limits how much data your origin can store. The ` StorageManager ` API tells you what you're working with:\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 // Request persistent storage to avoid eviction\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\nReal-world quotas I measured:\n\n| Browser | Default Quota | With ` persist() ` | Notes |\n| --- | --- | --- | --- |\n| Chrome (desktop) | ~60% of disk | Same, but no eviction | Generous |\n| Chrome (Android) | ~50% of free space | Requires user gesture | Can be as low as 2GB on budget devices |\n| Firefox | ~50% of disk, max 2GB per group | 10% of disk | Group = eTLD+1 |\n| Safari (macOS) | ~1GB | No persist() support | Evicts after 7 days without visit |\n| Safari (iOS) | ~1GB | Same | Most restrictive |\n\nSafari's 7-day eviction policy is the biggest operational gotcha. If an inspector doesn't open the app for a week, Safari might delete the entire database. We solved this by:\n\n1. Requesting a home screen shortcut (which gets more generous quota treatment)\n2. Running a periodic sync via a Service Worker to \"touch\" the storage\n3. Keeping a backup export mechanism to device-local storage via the File System Access API\n\n## Production Architecture\n\nAfter iterating through three storage architectures, here's what shipped:\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 handles all structured data. OPFS handles all binary files. Communication between the main thread and the worker uses a simple RPC-over-postMessage pattern:\n\n```javascript\n// db-rpc.js - Main thread side\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\nexport function execute(sql, params = []) {\n return new Promise((resolve, reject) => {\n const id = ++requestId;\n pending.set(id, { resolve, reject });\n worker.postMessage({ id, type: 'execute', sql, params });\n });\n}\n```\n\nThis has been running in production for three months across ~200 tablets. Total data loss incidents: zero. The offline-first architecture, once we got the storage layer right, has been rock solid.\n\nThe lesson isn't that any one storage API is \"the answer.\" The lesson is that each API has a specific sweet spot: IndexedDB for simple key-value patterns, OPFS for file-like data and as a SQLite backend, and SQLite-over-WASM for anything that needs real queries. Use them together. Just make sure you understand the quota limits before your users hit them in the field.\n\n## Frequently asked questions\n\n### Is OPFS supported in all major browsers?\n\nAs of early 2026, OPFS is supported in Chrome 102+, Firefox 111+, Safari 15.2+, and Edge 102+. The synchronous access handle API (used for SQLite-over-WASM) requires a Web Worker context and is available in Chrome 102+, Firefox 111+, and Safari 17.4+.\n\n### Can SQLite-over-WASM handle large databases in the browser?\n\nYes, with OPFS as the backend. We've tested databases up to 2GB in Chrome without issues. The main constraint is the browser's storage quota, which varies by browser and available disk space. Performance degrades gracefully, a 500MB database with proper indexes still delivers sub-10ms queries.\n\n### Should I use IndexedDB or OPFS for a new project?\n\nIt depends on your access pattern. IndexedDB is better for structured key-value data with indexes. OPFS is better for file-like data, binary blobs, or when you want to run SQLite. For offline-first apps with complex queries, SQLite-over-WASM with OPFS is the best option in 2026.\n\n## Tips\n\n1. **LuchoTip:** Always check ` navigator.storage.estimate() ` before writing large datasets. Safari's quota is as low as 1GB in some cases, and exceeding it silently fails instead of throwing in certain contexts.\n2. **LuchoTip:** Use OPFS via a dedicated Web Worker with ` createSyncAccessHandle() ` for SQLite. The synchronous API is 5-10x faster than the async main-thread variant and won't block your UI.\n3. **LuchoTip:** Wrap IndexedDB in a thin promise-based abstraction but do NOT use a full ORM. Libraries like ` idb ` add ~1.5KB and save you from callback hell without hiding the transaction model you need to understand.\n\n- opfs\n- indexeddb\n- sqlite\n- wasm\n- offline-first\n- storage-api\n- web-workers\n- browser-storage\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Browser Storage APIs: OPFS, IndexedDB, and SQLite-over-WASM for Offline-First Applications","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#title"},{"level":2,"text":"The Storage API Landscape in 2026","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#the-storage-api-landscape-in-2026"},{"level":2,"text":"IndexedDB: The Devil You Know","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#indexeddb-the-devil-you-know"},{"level":3,"text":"The Transaction Model That Bites You","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#the-transaction-model-that-bites-you"},{"level":3,"text":"IndexedDB Performance Characteristics","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#indexeddb-performance-characteristics"},{"level":2,"text":"OPFS: The Filesystem in Your Browser","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#opfs-the-filesystem-in-your-browser"},{"level":2,"text":"SQLite-over-WASM: The Nuclear Option","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#sqlite-over-wasm-the-nuclear-option"},{"level":3,"text":"Setting It Up","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#setting-it-up"},{"level":3,"text":"Query Performance","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#query-performance"},{"level":3,"text":"Benchmarks: SQLite-over-WASM vs IndexedDB","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#benchmarks-sqlite-over-wasm-vs-indexeddb"},{"level":2,"text":"Storage Quotas: Browser-Specific Limitations","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#storage-quotas-browser-specific-limitations"},{"level":2,"text":"Production Architecture","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#production-architecture"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#questions"},{"level":3,"text":"Is OPFS supported in all major browsers?","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#question-1"},{"level":3,"text":"Can SQLite-over-WASM handle large databases in the browser?","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#question-2"},{"level":3,"text":"Should I use IndexedDB or OPFS for a new project?","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#article-tips"}],"sections":[{"heading":"Browser Storage APIs: OPFS, IndexedDB, and SQLite-over-WASM for Offline-First Applications","level":1,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm","content":"Web APIs"},{"heading":"Browser Storage APIs: OPFS, IndexedDB, and SQLite-over-WASM for Offline-First Applications","level":1,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#title","content":"2025-10-2310 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm>)"},{"heading":"The Storage API Landscape in 2026","level":2,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#the-storage-api-landscape-in-2026","content":"Browsers now offer four meaningful storage mechanisms for application data:\n\n1. **localStorage/sessionStorage**, 5-10MB, synchronous, string-only. Still useful for small config data. Not relevant for this discussion.\n2. **IndexedDB**, The \"database\" API. Object stores, indexes, transactions. Async only. Supported everywhere.\n3. **Cache API**, Designed for HTTP response caching (Service Workers). Great for caching fetch responses, not for arbitrary application data.\n4. **OPFS (Origin Private File System)**, A real filesystem API scoped to your origin. File handles, read/write streams, and critically, a synchronous access API in Worker contexts.\n\nThe interesting architectural question is: do you use IndexedDB as your database, or do you use OPFS as a filesystem and run an actual database (SQLite) on top of it?"},{"heading":"IndexedDB: The Devil You Know","level":2,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#indexeddb-the-devil-you-know","content":"IndexedDB has been around since 2011. It's mature, widely supported, and thoroughly frustrating. Let me show you a real pattern from the inspection app, storing inspection records with compound indexes:\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\nThis pattern, versioned migrations in ` onupgradeneeded `, is the IndexedDB equivalent of database migrations. It works, but notice the gymnastics: you can't add an index to an existing store outside of a version upgrade, and you need to handle every version transition."},{"heading":"The Transaction Model That Bites You","level":3,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#the-transaction-model-that-bites-you","content":"IndexedDB transactions auto-commit when they go idle. This means:\n\n```javascript\n// THIS WILL FAIL in subtle ways\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 // If this async operation takes too long, the transaction auto-commits\n // before the photo writes happen\n for (const photo of photos) {\n const compressed = await compressImage(photo.blob); // DANGER: async gap\n const photoStore = tx.objectStore('photos');\n photoStore.put({ ...photo, blob: compressed });\n }\n}\n```\n\nThe ` await compressImage() ` creates a microtask gap. If the compression takes more than ~a few hundred milliseconds, the transaction commits or aborts before the photo writes execute. The fix is to do all async work before opening the transaction:\n\n```javascript\nasync function updateInspectionWithPhotos(inspection, photos) {\n // Do all async work FIRST\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\nThis is the kind of thing that works in development (small images, fast compression) and breaks in production (14MP camera photos on a tablet processor)."},{"heading":"IndexedDB Performance Characteristics","level":3,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#indexeddb-performance-characteristics","content":"I benchmarked IndexedDB across browsers for our typical workload, batch-inserting 1000 inspection records (~2KB each) and 500 photos (~200KB each):\n\n| Browser | 1000 Records Insert | 500 Photo Blobs Insert | Range Query (100 results) |\n| --- | --- | --- | --- |\n| Chrome 121 | 180ms | 2,400ms | 12ms |\n| Firefox 124 | 210ms | 3,100ms | 18ms |\n| Safari 17.4 | 340ms | 4,800ms | 24ms |\n\nSafari's blob storage performance is notably worse. On iPads, which were half our device fleet, this mattered a lot."},{"heading":"OPFS: The Filesystem in Your Browser","level":2,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#opfs-the-filesystem-in-your-browser","content":"The Origin Private File System gives you a real hierarchical filesystem, scoped to your origin. No user prompts, no file picker dialogs, it's entirely programmatic.\n\n```javascript\nasync function setupOPFS() {\n const root = await navigator.storage.getDirectory();\n\n // Create directory structure\n const dbDir = await root.getDirectoryHandle('databases', { create: true });\n const photosDir = await root.getDirectoryHandle('photos', { create: true });\n\n // Create a file\n const configFile = await root.getFileHandle('config.json', { create: true });\n\n // Write to it\n const writable = await configFile.createWritable();\n await writable.write(JSON.stringify({ version: 1, lastSync: null }));\n await writable.close();\n\n // Read it back\n const file = await configFile.getFile();\n const text = await file.text();\n return JSON.parse(text);\n}\n```\n\nThat's the async API, which runs on the main thread. It's fine for small operations. But the real power is the synchronous access handle, available only in 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 // Synchronous writes - no promises, no 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() ` gives you ` read() `, ` write() `, ` truncate() `, ` flush() `, and ` close() `, all synchronous. This is what makes SQLite-over-WASM practical, because SQLite's VFS (Virtual File System) layer expects synchronous I/O."},{"heading":"SQLite-over-WASM: The Nuclear Option","level":2,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#sqlite-over-wasm-the-nuclear-option","content":"The SQLite team officially supports a WASM build since late 2022, and it's matured significantly. Combined with OPFS, you get a real SQL database in the browser with ACID transactions, complex queries, full-text search, and window functions."},{"heading":"Setting It Up","level":3,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#setting-it-up","content":"```javascript\n// worker.js - SQLite must run in a Worker for OPFS sync access\nimport sqlite3InitModule from '@official-sqlite/wasm';\n\nlet db = null;\n\nasync function initDatabase() {\n const sqlite3 = await sqlite3InitModule();\n\n // Use OPFS as the VFS backend\n if (sqlite3.oo1.OpfsDb) {\n db = new sqlite3.oo1.OpfsDb('/inspections.db');\n } else {\n // Fallback to in-memory if OPFS unavailable\n db = new sqlite3.oo1.DB(':memory:');\n console.warn('OPFS not available, using in-memory database');\n }\n\n // Run migrations\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\nNotice that photos are stored as file paths, not blobs in SQLite. The actual image data lives in OPFS files, and SQLite stores the path. This is a critical architectural decision, SQLite handles blobs fine, but your database file balloons quickly and performance degrades. Keep metadata in SQLite, keep binary data in OPFS files."},{"heading":"Query Performance","level":3,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#query-performance","content":"Here's where SQLite shines compared to IndexedDB, complex queries:\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\nTry writing that query with IndexedDB. You'd need to open a cursor, iterate over every matching record, manually aggregate, and handle the join to photos yourself. With SQLite, it's just SQL."},{"heading":"Benchmarks: SQLite-over-WASM vs IndexedDB","level":3,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#benchmarks-sqlite-over-wasm-vs-indexeddb","content":"Same workload as before, Chrome 121:\n\n| Operation | IndexedDB | SQLite-WASM (OPFS) | Factor |\n| --- | --- | --- | --- |\n| Insert 1000 records (one tx) | 180ms | 45ms | 4x faster |\n| Range query (100 results) | 12ms | 3ms | 4x faster |\n| Aggregate query (COUNT/GROUP BY) | 890ms\\* | 8ms | 111x faster |\n| Full-text search (1000 docs) | N/A\\*\\* | 14ms | - |\n\n\\* IndexedDB aggregate requires cursor iteration over all matching records. \\*\\* IndexedDB has no built-in full-text search.\n\nThe aggregate query difference is staggering. IndexedDB doesn't have aggregation primitives, you have to read every record and compute in JavaScript. SQLite does it in compiled WASM code with optimized B-tree traversal."},{"heading":"Storage Quotas: Browser-Specific Limitations","level":2,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#storage-quotas-browser-specific-limitations","content":"Every browser limits how much data your origin can store. The ` StorageManager ` API tells you what you're working with:\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 // Request persistent storage to avoid eviction\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\nReal-world quotas I measured:\n\n| Browser | Default Quota | With ` persist() ` | Notes |\n| --- | --- | --- | --- |\n| Chrome (desktop) | ~60% of disk | Same, but no eviction | Generous |\n| Chrome (Android) | ~50% of free space | Requires user gesture | Can be as low as 2GB on budget devices |\n| Firefox | ~50% of disk, max 2GB per group | 10% of disk | Group = eTLD+1 |\n| Safari (macOS) | ~1GB | No persist() support | Evicts after 7 days without visit |\n| Safari (iOS) | ~1GB | Same | Most restrictive |\n\nSafari's 7-day eviction policy is the biggest operational gotcha. If an inspector doesn't open the app for a week, Safari might delete the entire database. We solved this by:\n\n1. Requesting a home screen shortcut (which gets more generous quota treatment)\n2. Running a periodic sync via a Service Worker to \"touch\" the storage\n3. Keeping a backup export mechanism to device-local storage via the File System Access API"},{"heading":"Production Architecture","level":2,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#production-architecture","content":"After iterating through three storage architectures, here's what shipped:\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 handles all structured data. OPFS handles all binary files. Communication between the main thread and the worker uses a simple RPC-over-postMessage pattern:\n\n```javascript\n// db-rpc.js - Main thread side\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\nexport function execute(sql, params = []) {\n return new Promise((resolve, reject) => {\n const id = ++requestId;\n pending.set(id, { resolve, reject });\n worker.postMessage({ id, type: 'execute', sql, params });\n });\n}\n```\n\nThis has been running in production for three months across ~200 tablets. Total data loss incidents: zero. The offline-first architecture, once we got the storage layer right, has been rock solid.\n\nThe lesson isn't that any one storage API is \"the answer.\" The lesson is that each API has a specific sweet spot: IndexedDB for simple key-value patterns, OPFS for file-like data and as a SQLite backend, and SQLite-over-WASM for anything that needs real queries. Use them together. Just make sure you understand the quota limits before your users hit them in the field."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#questions","content":""},{"heading":"Is OPFS supported in all major browsers?","level":3,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#question-1","content":"As of early 2026, OPFS is supported in Chrome 102+, Firefox 111+, Safari 15.2+, and Edge 102+. The synchronous access handle API (used for SQLite-over-WASM) requires a Web Worker context and is available in Chrome 102+, Firefox 111+, and Safari 17.4+."},{"heading":"Can SQLite-over-WASM handle large databases in the browser?","level":3,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#question-2","content":"Yes, with OPFS as the backend. We've tested databases up to 2GB in Chrome without issues. The main constraint is the browser's storage quota, which varies by browser and available disk space. Performance degrades gracefully, a 500MB database with proper indexes still delivers sub-10ms queries."},{"heading":"Should I use IndexedDB or OPFS for a new project?","level":3,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#question-3","content":"It depends on your access pattern. IndexedDB is better for structured key-value data with indexes. OPFS is better for file-like data, binary blobs, or when you want to run SQLite. For offline-first apps with complex queries, SQLite-over-WASM with OPFS is the best option in 2026."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#article-tips","content":"1. **LuchoTip:** Always check ` navigator.storage.estimate() ` before writing large datasets. Safari's quota is as low as 1GB in some cases, and exceeding it silently fails instead of throwing in certain contexts.\n2. **LuchoTip:** Use OPFS via a dedicated Web Worker with ` createSyncAccessHandle() ` for SQLite. The synchronous API is 5-10x faster than the async main-thread variant and won't block your UI.\n3. **LuchoTip:** Wrap IndexedDB in a thin promise-based abstraction but do NOT use a full ORM. Libraries like ` idb ` add ~1.5KB and save you from callback hell without hiding the transaction model you need to understand.\n\n- opfs\n- indexeddb\n- sqlite\n- wasm\n- offline-first\n- storage-api\n- web-workers\n- browser-storage\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm"},"headline":"Browser Storage APIs: OPFS, IndexedDB, and SQLite-over-WASM for Offline-First Applications","description":"An analysis of modern browser storage APIs, Origin Private File System, IndexedDB transaction semantics, SQLite compiled to WASM, storage quotas, and offline-first architecture design.","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/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Web APIs","keywords":"opfs, indexeddb, sqlite, wasm, offline-first, storage-api, web-workers, browser-storage","wordCount":1884,"timeRequired":"PT10M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Browser Storage APIs: OPFS, IndexedDB, and SQLite-over-WASM for Offline-First Applications","item":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is OPFS supported in all major browsers?","acceptedAnswer":{"@type":"Answer","text":"As of early 2026, OPFS is supported in Chrome 102+, Firefox 111+, Safari 15.2+, and Edge 102+. The synchronous access handle API (used for SQLite-over-WASM) requires a Web Worker context and is available in Chrome 102+, Firefox 111+, and Safari 17.4+."}},{"@type":"Question","name":"Can SQLite-over-WASM handle large databases in the browser?","acceptedAnswer":{"@type":"Answer","text":"Yes, with OPFS as the backend. We've tested databases up to 2GB in Chrome without issues. The main constraint is the browser's storage quota, which varies by browser and available disk space. Performance degrades gracefully, a 500MB database with proper indexes still delivers sub-10ms queries."}},{"@type":"Question","name":"Should I use IndexedDB or OPFS for a new project?","acceptedAnswer":{"@type":"Answer","text":"It depends on your access pattern. IndexedDB is better for structured key-value data with indexes. OPFS is better for file-like data, binary blobs, or when you want to run SQLite. For offline-first apps with complex queries, SQLite-over-WASM with OPFS is the best option in 2026."}}]}]}],"contentHash":"134041559e15ad06cfb4933feb7b7fa5ee8be15d7675a5b6c36fbd7351a90787","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":78500,"contentBytes":17330}}
