Storage
OPFS first, IndexedDB as fallback
Storage is an ordered list of plugins, not a mode string. The first available one wins, and an open() failure falls through to the next — availability is a prediction, opening is the proof.
// db.worker.js
import { startGranthWorker } from 'granth-runtime-worker/entry';
import { opfsStorage } from 'granth-storage-opfs';
import { indexeddbStorage } from 'granth-storage-indexeddb';
import { memoryStorage } from 'granth-storage-memory';
startGranthWorker({
sqlite3InitModule,
filename: '/myapp.sqlite3',
storage: [opfsStorage(), indexeddbStorage(), memoryStorage()],
});await db.storageKind(); // -> 'opfs' | 'indexeddb' | 'memory'| Plugin | Persists | Works where |
|---|---|---|
granth-storage-opfs | in place, fastest | a dedicated Worker + OPFS |
granth-storage-indexeddb | debounced whole-file checkpoint | anywhere IndexedDB exists, incl. Safari private browsing |
granth-storage-memory | not at all | absolutely everywhere: Node, SSR, tests, sandboxed frames |
Drop memoryStorage() from the list if you would rather fail loudly than run against a store that silently forgets on reload.
OPFS is the fast path, but it is not universally available:
- Safari private browsing has no OPFS at all — a hard failure, not a slow path.
- Chrome incognito caps an OPFS database at ~100 MB, with surprising errors at the limit.
- iOS Capacitor apps lose access handles when backgrounded.
'auto' (the default) tries OPFS and falls back to IndexedDB, so your app keeps working in a private window instead of throwing.
The fallback is the same engine
Not a second implementation: the same SQLite build on an in-memory database, whose bytes are checkpointed into IndexedDB. Every query, index, trigger and migration behaves identically.
Trade-offs worth knowing:
- checkpoints are debounced and whole-file, so cost is O(database size). Right for the fallback case (tens of MB); wrong as a primary store.
- writes since the last checkpoint are lost on a crash.
close()flushes automatically; callawait db.flush()before anything you cannot lose.
The local database is a cache, never the source of truth
Browser storage is evictable:
- Safari evicts all script-writable storage after 7 days without site interaction (ITP). Home-screen PWAs and
navigator.storage.persist()are exempt. - Cleanup tools delete OPFS as "Internet Cache"; Windows low-disk cleanup clears it.
- Field data across the ecosystem shows ~0.1–0.2% of users hit corruption anyway.
So:
await navigator.storage.persist(); // ask to be exempt from eviction
const { quota, usage } = await navigator.storage.estimate();
const bytes = await db.size(); // what we actually occupyAlways keep a rebuild-from-server path.
Multi-tab
opfs-sahpool is the fastest OPFS VFS and needs no COOP/COEP headers, at the cost of allowing exactly one connection. opfs-leader elects one tab via Web Locks; its worker is the only thing that opens the file, and every other tab routes queries to it. When that tab dies the browser releases the lock and another takes over.
Two tabs writing one OPFS file is what corrupted Notion's first WASM-SQLite rollout. This is the fix, not a mitigation.
How this compares to Notion's
Same shape, arrived at from the same constraint — opfs-sahpool allows one connection, so something has to decide who holds it. Two deliberate differences:
| Notion (2024) | granth | |
|---|---|---|
| SQLite build | official sqlite.org WASM | same |
| VFS | opfs-sahpool, to avoid COOP/COEP | same, same reason |
| Worker per tab | yes | yes |
| Who elects the writer | a SharedWorker | Web Locks directly — no SharedWorker |
| Hops per query from a follower | two (main → SharedWorker → worker) | one (main → the holding worker) |
| Failover | Web Locks | Web Locks |
| If the browser can't do it | cache is optional, app carries on | falls back OPFS → IndexedDB → memory |
| Writer dies mid-transaction | noted as an open caveat | two typed errors, see below |
That last row is the one worth reading. Roy Hashimoto — whose design Notion credits — flagged that if the active worker dies mid-transaction the caller cannot know whether it committed. granth answers it explicitly: NoLeaderError means nothing ran and retrying is safe, while LeaderLostError means the outcome is genuinely unknown and is never retried for you. Making that distinction true rather than merely documented needed a deadline on every call, because a frozen tab keeps its lock and the browser queues its messages — so "nobody acknowledged it" does not by itself mean "nothing ran". See Errors.
What granth deliberately does not do, both of which Notion needed: it does not race the local read against your network fetch (their fix for a p95 regression on slow Android devices — a local read is not automatically faster than the network), and it is not a sync engine. Those stay your application's job.
Worker options
startGranthWorker({
sqlite3InitModule,
filename: '/myapp.sqlite3',
storage: [opfsStorage(), indexeddbStorage(), memoryStorage()],
checkpointMs: 250, // IndexedDB checkpoint debounce
pragmas: { cache_size: -8000 }, // optional
upgrades: { 2: (engine) => { /* data migration */ } },
});PRAGMA synchronous = NORMAL was measured and made no meaningful difference, so it is not recommended — single-row write cost is the durable commit itself, not fsync tuning. Batch your writes instead.