BrowserCollectionCoordinator: secondary tabs fail to acquire leadership in multi-tab sessions
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 3.9k
- Forks
- 266
- Avg merge
- 1d 4h
- Merged PRs (30d)
- 55
Description
BrowserCollectionCoordinator: secondary tabs fail to acquire leadership in multi-tab sessions
Related to #1443, but distinct — that issue is single-tab (hung markReady, UNIQUE constraint failed). This one is specifically multi-tab, different error, almost certainly different root cause.
Versions
@tanstack/browser-db-sqlite-persistence@0.1.9@tanstack/db@0.6.5@tanstack/electric-db-collection@0.3.3@electric-sql/client@1.5.15- Chrome 135, macOS.
What happens
Open the app in one tab, it works. Open a second tab (same origin), and the second tab fills its console with:
Failed to acquire leadership for sales/salesCentres: { code: 'INTERNAL', name: 'OPFSWorkerRequestError' }
Failed to ensure persisted index through coordinator: {}
Failed to ensure persisted index through coordinator: {}
Failed to ensure persisted index through coordinator: {}
...
acquireLeadership retries forever at browser-coordinator.js:212. Collections never finish hydrating. useLiveSuspenseQuery in the second tab stays suspended or renders empty. The first tab keeps working normally. Closing the first tab eventually unblocks the second (web lock handoff).
Switching focus between tabs doesn't help. Neither does reloading the second tab.
What I think is going on
openBrowserWASQLiteOPFSDatabase spawns a dedicated new Worker() per tab (opfs-database.ts, createOPFSWorkerInstance). Each tab's worker loads wa-sqlite independently and wants to hold the OPFS file via createSyncAccessHandle, which is exclusive per file per origin.
BrowserCollectionCoordinator does elect a leader via navigator.locks, but leader election and OPFS file access are orthogonal — the coordinator's leader election doesn't stop the non-leader tab's wa-sqlite worker from trying to read/write the same OPFS file.
So when the second tab's coordinator wins (or tries to win) its web lock and calls this.requireAdapter().getStreamPosition(...) inside the lock callback, that RPCs into its own local worker, which tries to run SQL against the OPFS-backed DB, which fails because the first tab's worker has the SyncAccessHandle. The coordinator surfaces that as OPFSWorkerRequestError: INTERNAL and retries.
Short version: the coordinator assumes non-leader tabs can still run adapter operations locally. With wa-sqlite over exclusive OPFS, that's not true — only one worker in the whole origin can read/write at a time.
Reproduction
I don't have a clean standalone repro yet, but the shape is minimal: two tabs of the same origin, both using BrowserCollectionCoordinator + any Electric collection (or probably any adapter that makes adapter calls off the main thread). The second tab logs the error above on every collection.
If a standalone repo would help, I can put one together.
Workaround
Drop OPFS persistence for any collection that might be observed in more than one tab — i.e., just pass the raw electricCollectionOptions to createCollection and skip persistedCollectionOptions entirely. Costs offline support and warm-start caching, but multi-tab works.
Fix directions
I can see two architectures that would actually fix it, but both are non-trivial and I'd rather get a maintainer read on which direction you'd accept before writing code.
SharedWorker-backed OPFS driver. One SharedWorker per origin owns the OPFS handle; all tabs RPC into it via MessagePort. Leader election becomes advisory — controls sync ownership, not file access. Cleanest fix architecturally. Complications: SharedWorker lifecycle on last-tab-close, Safari pre-16.4 doesn't support SharedWorker (so a dedicated-worker-with-leader-gating fallback is still needed — which is the second option below).
Route adapter calls through the leader tab. Non-leader coordinators stop calling their local adapter for getStreamPosition, ensurePersistedIndex, etc. Instead they BroadcastChannel-RPC the leader and await the response. The non-leader's local wa-sqlite worker sits idle for OPFS ops. Fits how the coordinator is already shaped, works in every browser with navigator.locks. Latency cost on secondary tabs, and a leader-tab crash blocks followers until re-election. Also unclear (to me — I've only read the compiled output in node_modules, not the source) whether the non-leader's worker still holds the OPFS handle just from init — if it does, that needs a path to skip opening it when following.
Context
The coordinator + Electric adapter combination doesn't appear to have a multi-tab e2e — browser-single-tab-persisted-collection.e2e.test.ts is single-tab, and the offline-transactions example uses local-only persistence. Not sure how #1443 and this relate under the hood, but fixing either probably teaches you something about the other.
Happy to build a standalone repro, help with whichever direction you prefer, or iterate if I've got the analysis wrong.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reproducing the two-tab case, then inspect browser-coordinator.js around line 212 and opfs-database.ts, including createOPFSWorkerInstance. Compare this with browser-single-tab-persisted-collection.e2e.test.ts and determine how non-leader adapter calls and OPFS access are coordinated. Done means secondary tabs hydrate without repeated OPFSWorkerRequestError failures while the first tab continues working.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- sqlite, typescript
- Domain
- databases, distributed-systems
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 35/100