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.

Each tab opens the database versus one owner serves all tabs When each tab opens the database in its own worker, exclusive OPFS access handles conflict, and concurrent writers risk lock errors or stale views. With one owner, a single worker holds the database and other tabs send queries to it, so there is one writer and change notifications keep every tab current. every tab opens the file access handles conflict lock errors and retries stale views in other tabs fragile one owner serves all tabs single writer queries via messages change notifications robust

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.

Multi-tab access through one database owner Every tab requests the same Web Lock. The holder starts a dedicated worker that opens the OPFS database. Tabs send queries to the owner through message ports. After each write, the owner broadcasts a change notice, and tabs re-run affected queries. When the leader tab closes, the lock passes to another tab, which takes over. tabs request lock Web Locks leader starts DB worker sync access handles tabs send queries MessagePorts owner broadcasts changes BroadcastChannel leader closes → handover next tab gets lock

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.

Query latency from leader and follower tabs Milliseconds for a simple indexed query issued from the leader tab directly to its database worker and from a follower tab routed through message ports to the leader's worker. ms per query from leader tab 0.9 ms from follower tab 1.2 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.

← Back to Databases & Persistent Storage in Wasm