The Storage API Landscape in 2026
Browsers now offer four meaningful storage mechanisms for application data:
- localStorage/sessionStorage, 5-10MB, synchronous, string-only. Still useful for small config data. Not relevant for this discussion.
- IndexedDB, The "database" API. Object stores, indexes, transactions. Async only. Supported everywhere.
- Cache API, Designed for HTTP response caching (Service Workers). Great for caching fetch responses, not for arbitrary application data.
- 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:
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:
// 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:
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.
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:
// 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
// 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:
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:
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:
- Requesting a home screen shortcut (which gets more generous quota treatment)
- Running a periodic sync via a Service Worker to "touch" the storage
- 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:
// 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.