offline-transactions: confirm writes off the serial drain (hold optimistic state through the post-commit sync window)
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 (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
maxConfirmationHoldscap 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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