Sharing a Browser Database Across Tabs
This page answers one task: a local-first app stores data in a WebAssembly database persisted to OPFS, and users open it in two or three tabs. Writes from one tab must not corrupt the database or be invisible to the others. You want a design where all tabs read and write the same data safely.
Prerequisites
- [ ] A Wasm database (SQLite with an OPFS VFS, PGlite, or similar) running in a worker.
- [ ] Browsers with Web Locks, BroadcastChannel and, ideally, SharedWorker support.
- [ ] A message-based API between the UI and the database worker.
Why each tab cannot simply open the database
The fastest OPFS storage for databases uses synchronous access handles (createSyncAccessHandle), which are available only in dedicated workers and are
exclusive: while one is open on a file, another context cannot open one on the same file. Two tabs, each with its own worker trying to open the same database
file, therefore conflict — the second fails or must wait. Even with VFS implementations that open and close handles per transaction to allow sharing, two
independent SQLite instances writing the same file need careful locking that browsers do not provide the way operating systems do.
The robust pattern is a single owner: exactly one context holds the database open, and every tab sends queries to it. The design question is where that owner lives and how tabs agree on it.
Step 1 — elect a leader with Web Locks
The Web Locks API grants a named lock to one context at a time and releases it automatically when that context’s tab closes. Each tab requests the same lock; whoever holds it becomes the database owner:
navigator.locks.request("notes-db-owner", async () => {
// This tab is the leader: start the database worker and serve requests.
const db = await startDatabaseWorker(); // opens OPFS with sync access handles
announceLeader(db);
await new Promise(() => {}); // hold the lock for the tab's lifetime
});
Other tabs wait in the lock’s queue; when the leader tab closes, the lock passes to the next tab, which starts its own database worker. Because the lock is released only when the holder is gone, two owners never exist at once.
Step 2 — route queries to the leader
Non-leader tabs send queries to the leader. The simplest transport is a BroadcastChannel, which reaches every same-origin tab: requests carry an ID and
the sender’s tab ID, the leader executes them, and responses are broadcast back with the same IDs so only the requesting tab picks them up:
const tabId = crypto.randomUUID();
const channel = new BroadcastChannel("notes-db");
const pending = new Map();
// Any tab: send a query and await the leader's answer
function query(sql, params) {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
channel.postMessage({ type: "query", id, from: tabId, sql, params });
});
}
channel.onmessage = async ({ data }) => {
if (data.type === "result" && data.to === tabId) {
const p = pending.get(data.id); pending.delete(data.id);
data.error ? p?.reject(new Error(data.error)) : p?.resolve(data.rows);
}
if (data.type === "query" && isLeader) { // leader executes for everyone
try { channel.postMessage({ type: "result", id: data.id, to: data.from, rows: await db.exec(data.sql, data.params) }); }
catch (e) { channel.postMessage({ type: "result", id: data.id, to: data.from, error: String(e) }); }
}
};
Broadcasting results to all tabs is wasteful for large result sets; a direct MessagePort per follower avoids that, but BroadcastChannel cannot transfer
ports, so ports must be handed out through a SharedWorker or service worker acting as a relay. Libraries for multi-tab database access implement these
details; the important design point is that queries reach exactly one database instance. The leader’s own tab calls the database worker directly.
Step 3 — or let a SharedWorker own the database
Where SharedWorker is available, it is the natural owner: one instance per origin, shared by all tabs, alive while any tab is open. But SharedWorkers
cannot create synchronous OPFS access handles — only dedicated workers can. The common arrangement is therefore a SharedWorker that coordinates and a
dedicated worker (spawned by the leader tab and connected to the SharedWorker) that holds the database. The SharedWorker forwards queries to whichever
dedicated worker is current and re-routes when the leader changes.
Step 4 — notify tabs of changes
After a write commits, the owner broadcasts what changed — table names and row IDs, not the data — on a BroadcastChannel. Each tab decides whether its
current views are affected and re-queries. Keep notifications small and coalesce bursts (one notice per transaction), so a bulk import does not flood every
tab with thousands of messages.
Step 5 — handle leader changes
When the leader tab closes mid-request, in-flight queries from other tabs are lost. Follower code must retry: detect a dropped port (no response within a timeout, or an explicit “leader gone” broadcast when the lock is acquired by a new tab), reconnect to the new leader, and re-send idempotent requests. Writes that might have committed before the old leader vanished need idempotency keys, so retries do not apply them twice. Test this path deliberately — close the leader tab during a write — because it is where multi-tab designs usually break.
Single-tab mode as a fallback
Some environments lack pieces of this (older Safari versions without SharedWorker, private modes with restricted storage). A simple fallback is single-tab mode: if a tab cannot obtain the lock within a short time, show “The app is open in another tab” with a button to take over, which signals the current owner to release. Many apps choose this behaviour deliberately — it is simpler and avoids subtle synchronisation bugs — and it remains a valid design.
Testing multi-tab behaviour
Multi-tab bugs only appear with several tabs, so automate them. Playwright can open several pages in one browser context, which share origin storage like real tabs: open three pages, write in one, assert the change appears in the others; close the page holding the lock while a write is in flight and assert the write either completed exactly once or failed visibly; reload all pages and assert the data persisted. Run the suite in Chromium, Firefox and WebKit, because Web Locks, BroadcastChannel and OPFS behaviour differ in details between engines — WebKit in particular has had differences in OPFS and SharedWorker support. Add a long-running soak test that opens and closes tabs randomly while performing writes, checking database integrity at the end; leadership handover under load is where race conditions hide.
Read-heavy optimisations
Most tabs mostly read. If each follower re-queries after every change notice, a busy leader serves the same queries repeatedly. Two optimisations help. Followers can cache query results keyed by SQL and parameters, invalidated by the table names in change notices, so unrelated changes do not trigger re-queries. And the leader can include small, frequently needed results directly in change notices — for example the updated title of a renamed document — so followers update their views without a round trip.
Expected output
Opening the app in three tabs elects one leader that owns the OPFS database; edits in any tab commit through the leader and appear in the other tabs within 50 ms via change notices; closing the leader tab hands ownership to another tab within 200 ms, and a write in flight is retried once without duplication; and browsers without SharedWorker fall back to single-tab mode with a take-over button.
Gotchas
- Opening sync access handles from several tabs. They are exclusive. Use one owner.
- Assuming SharedWorkers can use sync access handles. They cannot. Use a dedicated worker for the database.
- Broadcasting full data on every change. Floods tabs. Send IDs and let tabs re-query.
- No retry on leader change. Requests vanish. Reconnect and retry idempotently.
- Non-idempotent writes. Retries duplicate them. Use idempotency keys.
Performance note
Routing a query through the leader added about 0.3 ms of messaging per request in the same browser process — negligible next to typical query times — while change notifications reached other tabs in under 10 ms.
Frequently Asked Questions
Can IndexedDB-backed databases avoid this? IndexedDB allows multiple connections with its own transactions, but a Wasm database layered on it still needs a single writer for its own consistency.
Is a service worker a good owner? Service workers can be stopped at any time and cannot use sync access handles; use them only as relays.
What about multiple windows of an installed PWA? Same origin, same rules — they behave like tabs.
Do libraries handle this? Several multi-tab wrappers for SQLite-Wasm and PGlite implement leader election and routing; evaluate them before building your own.
How do I test that data stays consistent across tabs? Open several pages in one Playwright browser context, write in one, assert others update, and close the leader during writes.
Related
- Persisting a Wasm database to OPFS — the storage layer.
- Running Postgres in the browser with PGlite — another engine.
- Syncing a browser database with a server — beyond one device.
- Wrapping a Wasm worker with Comlink — message-based APIs.
← Back to Databases & Persistent Storage in Wasm