Skip to content

BrowserCollectionCoordinator + Electric adapter: collections never reach ready state #1443

@jwaltz

Description

@jwaltz

BrowserCollectionCoordinator + Electric adapter: collections never reach "ready" state

Package & Version

  • @tanstack/browser-db-sqlite-persistence@0.1.5
  • @tanstack/db@0.6.1
  • @tanstack/electric-db-collection@0.2.43
  • @electric-sql/client@1.5.14

Environment

  • Chrome 136 (latest stable)
  • Vite / TanStack Start
  • Single browser tab (no multi-tab involved)

Description

When BrowserCollectionCoordinator is passed to createBrowserWASQLitePersistence and the persistence wraps Electric collections (via electricCollectionOptions), collections never reach the "ready" state. Electric shape HTTP requests go out and return 200 with data (confirmed via Network tab), but useLiveQuery subscribers return empty results.

Removing the coordinator (falling back to the default SingleProcessCoordinator) immediately fixes the issue — Electric data flows through persistence and renders correctly.

Isolation Testing

To isolate whether the issue is the coordinator generally or specific to Electric, I tested with queryCollectionOptions (TanStack Query adapter) + BrowserCollectionCoordinator:

Config Collections Result
queryCollectionOptions + no coordinator 1 ✅ Works
queryCollectionOptions + BrowserCollectionCoordinator 1 ✅ Works
queryCollectionOptions + BrowserCollectionCoordinator 21 ✅ Works
electricCollectionOptions + no coordinator 21 ✅ Works
electricCollectionOptions + BrowserCollectionCoordinator 21 ❌ No data

The coordinator works correctly with the query adapter at any scale. The issue is specific to the Electric adapter + coordinator combination.

Test Coverage Note

The official example (examples/react/offline-transactions/src/db/persisted-todos.ts) uses BrowserCollectionCoordinator only with local-only persistence — no sync adapter. The e2e test (browser-single-tab-persisted-collection.e2e.test.ts) also uses no coordinator. The Electric + coordinator combination may be an untested code path.

Reproduction (Real App)

const database = await openBrowserWASQLiteOPFSDatabase({
  databaseName: "myapp.sqlite",
});

const coordinator = new BrowserCollectionCoordinator({ dbName: "myapp" });
const persistence = createBrowserWASQLitePersistence({ database, coordinator });

// ❌ BROKEN — Electric data arrives but collection never becomes "ready"
const myCollection = createCollection(
  persistedCollectionOptions({
    ...electricCollectionOptions({
      id: "my-collection",
      shapeOptions: { url: "/api/my-shape", columnMapper: snakeCamelMapper() },
      syncMode: "eager",
      getKey: (item) => item.id,
      schema: mySchema,
    }),
    persistence,
    schemaVersion: 1,
  }),
);

// Electric requests return 200 with data in Network tab
// useLiveQuery returns { data: [] }

Works when coordinator is removed:

const persistence = createBrowserWASQLitePersistence({ database }); // no coordinator

Observed Behavior

  1. OPFS database opens successfully
  2. BrowserCollectionCoordinator instantiates without error
  3. Electric shape HTTP requests fire and return data (confirmed in Network tab)
  4. useLiveQuery returns empty arrays — collections appear stuck in non-ready state
  5. No errors in the console after clearing stale OPFS data

Earlier Error (Before Clearing OPFS)

On the first attempt, before clearing stale OPFS data, we saw:

Failed to acquire leadership for auth-state: OPFSWorkerRequestError: UNIQUE constraint failed: collection_registry.tombstone_table_name

After clearing OPFS, this error stopped, but data still didn't flow.

Suspected Root Cause

Based on source analysis: markReady() is wrapped in persisted.ts:2250-2264 to defer until runtime.ensureStarted() resolves. The sync function runs unconditionally (Electric data arrives regardless of leadership), but useLiveQuery waits for "ready" before returning results.

If runtime.ensureStarted() hangs when the coordinator is involved — possibly due to how Electric's SSE-based sync interacts with the coordinator's OPFS write path or startup sequence — markReady() is never called.

Supporting evidence: console.warn("Failed persisted sync startup before markReady:") was not observed, suggesting the startup promise hangs rather than rejects.

Since the query adapter (which uses a simple fetch-and-complete pattern) works correctly with the coordinator, the issue may be related to Electric's long-lived SSE connection interacting with the coordinator's transaction serialization or leader election timing.

Workaround

Omit the coordinator to use SingleProcessCoordinator (default). This disables multi-tab coordination but allows single-tab persistence with Electric to work correctly:

const persistence = createBrowserWASQLitePersistence({ database });

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions