Skip to content

Cold-start persist storm blocks hydrates for seconds on the shared SQLite driver (browser/OPFS) #1752

Description

@EmilEinarsen

Related to the persistence hardening RFC #1659.

Setup: 13 Electric collections in one browser tab, all wrapped in persistedCollectionOptions over @tanstack/browser-db-sqlite-persistence 0.2.12 / @tanstack/db-sqlite-persistence-core 0.2.12 (WA-SQLite/OPFS), one database for the whole app. @tanstack/db 0.7.2, @tanstack/electric-db-collection 0.3.18. Measured 2026-08-19 in Chrome via a Playwright journey with adapter-level and driver-level instrumentation.

What we measured on a cold tab (first 30 s):

  • 16 applyCommittedTx calls, 66,126–80,141 mutations, 24.8 s of serialized adapter time, max single op 7,431 ms.
  • Per-collection snapshot persists: collection A 9,481 mutations → 2,094 ms; collections B/C/D ~14,000 mutations each → 1,647–1,773 ms each.
  • Driver FIFO: max queue depth 24, max queue wait 7,199–7,354 ms, max single exec 2,094 ms.

Four things compound into a user-visible 7 s delay applying a live change:

  1. No statement batching in applyCommittedTx. Roughly three worker round trips per row (existing-row SELECT, upsert, tombstone delete). 14k mutations take ~1.7–2.1 s where a batched multi-row write would be an order of magnitude cheaper. This is the root cost; everything below is a consequence.
  2. Hydrate windows are not a narrow race. One collection opened 11 loadSubset calls per page life, most returning 0–1 rows, and was inside a hydrate window for 52–54% of the tab's first 14 s. Any live transaction arriving in that half is buffered (queuedBecauseHydrating).
  3. A buffered live transaction awaits its own persist. The hydrate it was buffered behind performed a 1-row loadSubset that waited 7,052 / 7,218 / 7,262 ms in the driver FIFO behind the storm; bufferedMs for the transaction was 6,815 / 6,994 / 7,032 ms. Applying it once flushed took 1–2 ms. 100% of the 7 s was post-network-arrival, inside the persistence layer.
  4. The subset upstream-forward waits for the disk hydrate. loadSubset resolves from disk before the upstream request is issued (measured 7,433 ms before forward). In our journey the value arrived on a live stream so this added nothing, but for a change arriving via a subset snapshot it would be the entire delay by itself.

Secondary: getStreamPosition/loadCollectionMetadata for collections created after the storm begins block for the storm's full length, delaying the source sync() start by +8.9 s for two collections.

What we did downstream, and what we could not fix from there. We put a write-behind queue in our persistence adapter: applyCommittedTx resolves on enqueue, nothing starts while a read is pending, and snapshot-sized transactions wait for requestIdleCallback. That took measured propagation from 6.8–7.0 s to 0–1.7 s. The residual is irreducible outside the library: applyCommittedTx is one transaction — rows and stream offset must commit together — so a read arriving mid-write waits out the whole ~2 s op.

Suggestions: batch statements inside applyCommittedTx; chunk large committed transactions into driver-level batches that still commit atomically, yielding between them; and let hydrates be served without queueing behind unrelated collections' writes (per-collection queues, or a read lane).

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