Skip to content

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

@TomasGonzalez

Description

@TomasGonzalez

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions