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 (resolveTransaction → cleanupRestorationTransaction). 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.
Summary
@tanstack/offline-transactionsdrains the outbox serially — onemutationFnat 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'sawaitTxId).The gap
The executor drops a transaction's optimistic overlay the instant its
mutationFnresolves (resolveTransaction→cleanupRestorationTransaction). 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
awaitthe confirmation inside themutationFn:But the drain is serial, so this
awaitblocks the next write. With an Electric shape stream whoseawaitTxIdbudget 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.restoreOptimisticStatecreates a standalonecreateTransaction({ autoCommit: false }), applies the mutations, registers it on each collection's_state, andcleanupRestorationTransactiontears 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.confirmWritehook that runs after the write commits and its outbox entry is removed, but off the serial drain path: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:
maxConfirmationHoldscap avoids O(n²) optimistic-recompute churn on a large, fast drain.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._stateand 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 intoOptimisticHold.tsand shared withrestoreOptimisticState, tests, changeset). Happy to bikeshed the hook name (confirmWrite/awaitSync/confirmTransaction) and the default cap. Will link it here.