Skip to content

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.

js
// 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()],
});
js
await db.storageKind(); // -> 'opfs' | 'indexeddb' | 'memory'
PluginPersistsWorks where
granth-storage-opfsin place, fastesta dedicated Worker + OPFS
granth-storage-indexeddbdebounced whole-file checkpointanywhere IndexedDB exists, incl. Safari private browsing
granth-storage-memorynot at allabsolutely 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; call await 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:

js
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 occupy

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

Single-writer tab topologyEvery tab runs its own worker, but only the tab holding the Web Lock opens the database file. Other tabs send queries to that tab over a BroadcastChannel. If the holding tab closes, the browser releases the lock and another tab takes over.TAB 1 — HOLDS THE LOCKmain threadworkerTAB 2idle workerTAB 3idle workerqueries routed overa BroadcastChannelone OPFS fileone connectionWeb Locks decides whoholds it. If that tab dies,the browser releases thelock and another takesover.
Every tab runs a worker; only the lock holder opens the file.

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 buildofficial sqlite.org WASMsame
VFSopfs-sahpool, to avoid COOP/COEPsame, same reason
Worker per tabyesyes
Who elects the writera SharedWorkerWeb Locks directly — no SharedWorker
Hops per query from a followertwo (main → SharedWorker → worker)one (main → the holding worker)
FailoverWeb LocksWeb Locks
If the browser can't do itcache is optional, app carries onfalls back OPFS → IndexedDB → memory
Writer dies mid-transactionnoted as an open caveattwo 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

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