Running SQLite in a browser tab is not the hard part. Emscripten does that, and
the official @sqlite.org/sqlite-wasm build works.
The hard part is that users open your app in more than one tab.
Why this is corruption and not a race
SQLite expects a filesystem with locking. In the browser, the storage that can
support it is OPFS — the Origin Private File System — and OPFS gives you
createSyncAccessHandle(), a synchronous read/write handle at byte offsets.
That is the primitive that makes a real database possible.
It is also exclusive. One handle per file. A second tab calling
createSyncAccessHandle() on the same file does not queue behind the first — it
throws.
You can work around the throw. What you cannot work around is what happens if you succeed: two writers appending to one SQLite file, each with its own page cache and its own idea of the free list. That is not a lost update you retry. That is a file whose B-tree no longer describes its own contents.
Notion shipped exactly this and wrote it up. Users saw a comment attributed to the wrong colleague. Underneath were multiple rows carrying the same id with different content — two tabs, both writing, neither wrong from its own point of view.
That bug is why the single-writer design exists. It is not an architectural preference; it is the thing that had to be true.
The header problem, before you even get there
There are two OPFS VFS implementations, and the obvious one is the wrong one.
The standard sqlite3_vfs OPFS backend uses SharedArrayBuffer and
Atomics.wait, so it requires cross-origin isolation — meaning these headers
on your document:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
require-corp means every cross-origin resource on the page must opt in. Every
analytics script, every embedded video, every payment iframe, every font from a
CDN. If one of them doesn't send the header, it stops loading.
Notion called asking their third-party vendors for that "an unrealistic ask" and abandoned the approach. For most real applications that is the correct read.
The alternative is opfs-sahpool: a VFS that pre-allocates a pool of sync
access handles and needs no cross-origin isolation at all. The trade is that it
can only be open in one place at a time — which, given the previous section, you
were going to need anyway.
Two independent teams, the same two constraints, the same VFS. That convergence is worth noticing: the platform leaves very little room here.
Electing one writer
So: one tab owns the database, and everyone else asks it.
The election runs on Web Locks, which has the one property that matters — a lock held by a tab is released by the browser when that tab goes away. Not by a timeout you tuned, not by a heartbeat that might be late. Crash the tab, close the laptop, kill the process: the lock drops.
navigator.locks.request('granth/myapp', { mode: 'exclusive' }, async () => {
// I am the leader. Open the database and hold it until this resolves.
await new Promise(() => {}); // never resolves; released when the tab dies
});
Every other tab is waiting in that same queue. When the leader disappears the
next tab in line is granted the lock and opens the database. Writes from any tab
are routed to whoever holds it, and change notifications go back out over a
BroadcastChannel so the others can re-run their queries.
Web Locks is doing the load-bearing work here. Any election built on timestamps or heartbeats has to guess how long a frozen tab might come back.
The failure everybody gets wrong
Here is the part that took the longest to get right, and it is not about SQL.
A tab sends a write to the leader. The leader disappears. What do you tell the caller?
There are two completely different situations, and from the calling tab they look identical — a message went out and no reply came back:
- The leader never received it. Nothing ran. The write did not happen. Retrying is safe and correct.
- The leader received it, ran it, committed it, and died before replying. The write did happen. Retrying applies it twice.
If you collapse these into one error, you have to pick a wrong answer. Retry everything and you double-apply payments. Retry nothing and you drop writes that never ran.
The distinction has to be created deliberately, and the mechanism is an acknowledgement before execution. The leader replies "received" the moment the message lands, then runs the work. Now the calling tab knows which side of the line it is on:
- No ack → the leader never had it →
NoLeaderError, safe to retry. - Ack but no result → it may have run →
LeaderLostError, never retried automatically, surfaced to the caller as an unknown commit state.
try {
await db.transfer(from, to, amount);
} catch (err) {
if (err instanceof NoLeaderError) return retry(); // nothing ran
if (err instanceof LeaderLostError) return askUser(); // unknown — do not retry
throw err;
}
That second one is not a nicer error message. It is the difference between a library that can be used for money and one that cannot.
Why not a SharedWorker?
Notion coordinates through a SharedWorker: one shared context that tracks which tab is active and routes queries to it. It is a clean design and it works.
I elect over Web Locks directly instead, and the reason is availability. Shared workers are still absent or unreliable in enough mobile and embedded contexts that depending on one narrows where the library runs. Web Locks and BroadcastChannel are available everywhere OPFS is.
The cost of my choice is real: the coordinator is a normal tab, so it can vanish at any moment, which is precisely why the two-error distinction above had to exist. A SharedWorker outlives its tabs and mostly sidesteps that. Different trade, not a better one.
Testing the thing that only happens between tabs
None of this is reachable from a single page, which means none of it is covered by a normal test suite. The bugs that live here are the worst ones I found: schema lost on failover, a write absorbed by an abandoned transaction, two transactions merging into one.
So the suite drives real browsers with multiple real tabs, and two tabs behaves differently from three — with two, the survivor elects itself immediately; with three, there is a queue. Both run.
The failover test does the only thing that actually proves it: it kills the leader mid-write and asserts the survivor picks up the database with the schema intact and the in-flight write resolved to one of the two errors above — not to a hang, and not to a silent success.
A guard for this that you have never watched fail is telling you nothing. That is true generally, but it is especially true here, because the happy path passes whether or not the election works at all.
What this buys, in one line
One tab owns the file. Every tab can read and write. Nobody corrupts anything, and when the owner dies the next one takes over without asking you to think about it.
db.onChange(() => render()); // including writes from another tab
That is the whole surface area of it, which is the goal — the coordination should be invisible until you go looking for it.
Get it
npm install granthdb @sqlite.org/sqlite-wasm
- Package — granthdb on npm
- Documentation — granthlabs.github.io
- Source — granthlabs/granth
- The error types in full — LeaderLostError and NoLeaderError
If you want the reasoning that led here, that is why I put SQLite in the browser. If you want to know whether you need any of it, when a browser app needs a real database is the honest version. The project write-up is at /case-studies/granthdb-browser-sqlite.