TanStack / TanStack/db

offline-transactions: confirm writes off the serial drain (hold optimistic state through the post-commit sync window)

Open
#1,602 0 comments 2 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
3.9k
Forks
266
Avg merge
1d 4h
Merged PRs (30d)
55

Description

Summary

@tanstack/offline-transactions drains the outbox serially — one mutationFn at a time — which is what preserves create-then-update / FK ordering. But there is no built-in way to keep a row's optimistic state painted through the gap between "the server committed the write" and "the sync stream echoed it back." Both available workarounds are bad, and this hurts real apps using an async confirmation stream (e.g. ElectricSQL's awaitTxId).

The gap

The executor drops a transaction's optimistic overlay the instant its mutationFn resolves (resolveTransactioncleanupRestorationTransaction). If the synced data hasn't caught up yet, the row flickers (disappears, then reappears when sync delivers it).

To avoid the flicker today, you must await the confirmation inside the mutationFn:

mutationFn: async ({ transaction }) => {
  const { txid } = await postToServer(transaction)
  await collection.utils.awaitTxId(txid) // runs ON the serial path
}

But the drain is serial, so this await blocks the next write. With an Electric shape stream whose awaitTxId budget is ~10s, drain throughput collapses to ~1 write / 10s, and each settled write tends to spawn a duplicate "already-applied" resend. So you're forced to choose between slow drain (await inline) and UI flicker (don't await). Neither is acceptable.

Notably, the library already has the right primitive — TransactionExecutor.restoreOptimisticState creates a standalone createTransaction({ autoCommit: false }), applies the mutations, registers it on each collection's _state, and cleanupRestorationTransaction tears it down — but it's private and only wired for rehydrate-on-load, not for the post-commit confirm window.

Proposed API

An opt-in OfflineConfig.confirmWrite hook that runs after the write commits and its outbox entry is removed, but off the serial drain path:

startOfflineExecutor({
  // ...
  confirmWrite: async ({ mutations, result }) => {
    // result = whatever your mutationFn returned (e.g. a server txid)
    await Promise.all(
      collectionsOf(mutations).map((c) => c.utils.awaitTxId(result.txid)),
    )
  },
})

While the returned promise is pending, the library keeps the committed mutations' optimistic overlay painted (reusing the existing hold primitive) and releases it when the hook settles. The serial chain still serializes the POSTs (ordering preserved); only the confirmation moves off it.

Key semantics:

  • Never rolls back — the write is already durably committed, so a rejected/timed-out hook just releases the overlay early (possible brief flicker), never data loss. Timeout / verify-by-state logic lives inside the hook.
  • Never throws into the drain — a throw can't make the executor retry an already-committed write.
  • No flicker — the hold is registered synchronously, before the original overlay is dropped.
  • Bounded — a maxConfirmationHolds cap avoids O(n²) optimistic-recompute churn on a large, fast drain.
  • Fully opt-in — with no confirmWrite, behavior is unchanged.

Context

We hit exactly this in production (offline-first app on Electric). We currently work around it with an external module that reaches into collection._state and re-implements the library's private hold/teardown — fragile, and it duplicates internals. A first-class hook would let that module collapse to the small callback above.

I have a PR ready that implements this (new confirmWrite/maxConfirmationHolds/getActiveConfirmationHoldCount, the create/release primitive factored into OptimisticHold.ts and shared with restoreOptimisticState, tests, changeset). Happy to bikeshed the hook name (confirmWrite / awaitSync / confirmTransaction) and the default cap. Will link it here.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with TransactionExecutor.restoreOptimisticState and the proposed OptimisticHold.ts primitive, then review the existing restoration cleanup and related tests. The change is complete when post-commit confirmation can hold optimistic state without blocking serial writes, rejected hooks do not retry or roll back committed writes, and the stated cap and changeset are covered.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
databases
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.