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.
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.15What happens
Open the app in one tab, it works. Open a second tab (same origin), and the second tab fills its console with:
acquireLeadershipretries forever atbrowser-coordinator.js:212. Collections never finish hydrating.useLiveSuspenseQueryin 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
openBrowserWASQLiteOPFSDatabasespawns a dedicatednew Worker()per tab (opfs-database.ts,createOPFSWorkerInstance). Each tab's worker loads wa-sqlite independently and wants to hold the OPFS file viacreateSyncAccessHandle, which is exclusive per file per origin.BrowserCollectionCoordinatordoes elect a leader vianavigator.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 asOPFSWorkerRequestError: INTERNALand 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
electricCollectionOptionstocreateCollectionand skippersistedCollectionOptionsentirely. 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 withnavigator.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 frominit— 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.tsis 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.