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:

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:

  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:

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