<!-- Canonical: https://lucioduran.com/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm
Language: en
Content-SHA256: 134041559e15ad06cfb4933feb7b7fa5ee8be15d7675a5b6c36fbd7351a90787
-->

Web APIs

# Browser Storage APIs: OPFS, IndexedDB, and SQLite-over-WASM for Offline-First Applications

2025-10-2310 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/browser-storage-apis-opfs-indexeddb-sqlite-wasm>)

## The Storage API Landscape in 2026

Browsers now offer four meaningful storage mechanisms for application data:

1. **localStorage/sessionStorage**, 5-10MB, synchronous, string-only. Still useful for small config data. Not relevant for this discussion.
2. **IndexedDB**, The "database" API. Object stores, indexes, transactions. Async only. Supported everywhere.
3. **Cache API**, Designed for HTTP response caching (Service Workers). Great for caching fetch responses, not for arbitrary application data.
4. **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.

The 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?

## IndexedDB: The Devil You Know

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:

```javascript
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);
 });
}
```

This 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.

### The Transaction Model That Bites You

IndexedDB transactions auto-commit when they go idle. This means:

```javascript
// THIS WILL FAIL in subtle ways
async function updateInspectionWithPhotos(inspection, photos) {
 const db = await openInspectionDB();
 const tx = db.transaction(['inspections', 'photos'], 'readwrite');

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

 // If this async operation takes too long, the transaction auto-commits
 // before the photo writes happen
 for (const photo of photos) {
 const compressed = await compressImage(photo.blob); // DANGER: async gap
 const photoStore = tx.objectStore('photos');
 photoStore.put({ ...photo, blob: compressed });
 }
}
```

The ` 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:

```javascript
async function updateInspectionWithPhotos(inspection, photos) {
 // Do all async work FIRST
 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);
 });
}
```

This is the kind of thing that works in development (small images, fast compression) and breaks in production (14MP camera photos on a tablet processor).

### IndexedDB Performance Characteristics

I benchmarked IndexedDB across browsers for our typical workload, batch-inserting 1000 inspection records (~2KB each) and 500 photos (~200KB each):

| Browser | 1000 Records Insert | 500 Photo Blobs Insert | Range Query (100 results) |
| --- | --- | --- | --- |
| Chrome 121 | 180ms | 2,400ms | 12ms |
| Firefox 124 | 210ms | 3,100ms | 18ms |
| Safari 17.4 | 340ms | 4,800ms | 24ms |

Safari's blob storage performance is notably worse. On iPads, which were half our device fleet, this mattered a lot.

## OPFS: The Filesystem in Your Browser

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.

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

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

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

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

 // Read it back
 const file = await configFile.getFile();
 const text = await file.text();
 return JSON.parse(text);
}
```

That'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:

```javascript
// 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);

 // Synchronous writes - no promises, no await
 accessHandle.write(encoded, { at: 0 });
 accessHandle.truncate(encoded.byteLength);
 accessHandle.flush();
 accessHandle.close();

 return encoded.byteLength;
}
```

` 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.

## SQLite-over-WASM: The Nuclear Option

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.

### Setting It Up

```javascript
// worker.js - SQLite must run in a Worker for OPFS sync access
import sqlite3InitModule from '@official-sqlite/wasm';

let db = null;

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

 // Use OPFS as the VFS backend
 if (sqlite3.oo1.OpfsDb) {
 db = new sqlite3.oo1.OpfsDb('/inspections.db');
 } else {
 // Fallback to in-memory if OPFS unavailable
 db = new sqlite3.oo1.DB(':memory:');
 console.warn('OPFS not available, using in-memory database');
 }

 // Run migrations
 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;
}
```

Notice 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.

### Query Performance

Here's where SQLite shines compared to IndexedDB, complex queries:

```javascript
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'
 });
}
```

Try 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.

### Benchmarks: SQLite-over-WASM vs IndexedDB

Same workload as before, Chrome 121:

| Operation | IndexedDB | SQLite-WASM (OPFS) | Factor |
| --- | --- | --- | --- |
| Insert 1000 records (one tx) | 180ms | 45ms | 4x faster |
| Range query (100 results) | 12ms | 3ms | 4x faster |
| Aggregate query (COUNT/GROUP BY) | 890ms\* | 8ms | 111x faster |
| Full-text search (1000 docs) | N/A\*\* | 14ms | - |

\* IndexedDB aggregate requires cursor iteration over all matching records. \*\* IndexedDB has no built-in full-text search.

The 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.

## Storage Quotas: Browser-Specific Limitations

Every browser limits how much data your origin can store. The ` StorageManager ` API tells you what you're working with:

```javascript
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;

 // Request persistent storage to avoid eviction
 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
 };
}
```

Real-world quotas I measured:

| Browser | Default Quota | With ` persist() ` | Notes |
| --- | --- | --- | --- |
| Chrome (desktop) | ~60% of disk | Same, but no eviction | Generous |
| Chrome (Android) | ~50% of free space | Requires user gesture | Can be as low as 2GB on budget devices |
| Firefox | ~50% of disk, max 2GB per group | 10% of disk | Group = eTLD+1 |
| Safari (macOS) | ~1GB | No persist() support | Evicts after 7 days without visit |
| Safari (iOS) | ~1GB | Same | Most restrictive |

Safari'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:

1. Requesting a home screen shortcut (which gets more generous quota treatment)
2. Running a periodic sync via a Service Worker to "touch" the storage
3. Keeping a backup export mechanism to device-local storage via the File System Access API

## Production Architecture

After iterating through three storage architectures, here's what shipped:

```
┌─────────────────────────────────────────────┐
│ 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 handles all structured data. OPFS handles all binary files. Communication between the main thread and the worker uses a simple RPC-over-postMessage pattern:

```javascript
// db-rpc.js - Main thread side
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 });
 });
}

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

This 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.

The 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.

## Frequently asked questions

### Is OPFS supported in all major browsers?

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+.

### Can SQLite-over-WASM handle large databases in the browser?

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.

### Should I use IndexedDB or OPFS for a new project?

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.

## Tips

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.
2. **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.
3. **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.

- opfs
- indexeddb
- sqlite
- wasm
- offline-first
- storage-api
- web-workers
- browser-storage

[Back to Blog](<https://lucioduran.com/blog>)
