Optimistic Retries Preserve User Intent
Optimistic retries preserve user intent
If an optimistic UI has already told the user "done", a transient failure should not immediately roll the world back. Rollback means "we know this did not apply", not "the database pool was busy for two seconds".
The user has already moved on. If they archived a message and the row disappeared, bringing it back a minute later is not a neutral correction; it breaks the contract the UI just made.
The rule
Retry transient optimistic mutations before reconciliation.
Keep the optimistic state while retrying. Keep the rollback snapshot too. Only reconcile after bounded retries are exhausted or after the error is clearly terminal: validation failure, permission failure, missing account, unsupported provider capability, and so on.
Mxr example
In Mxr, the TUI queues optimistic mailbox mutations through QueuedMutation. The current code truth:
crates/tui/src/app/mutation_snapshot.rsstoresattemptsandrun_afteron each queued mutation.TRANSIENT_MUTATION_MAX_RETRIESis3.- Backoff is
2s,4s,8s, capped by30s. crates/tui/src/app/mutation_helpers.rsclassifies transient IPC/database failures such as pool timeouts, locked tables, closed connections, and temporary unavailability.crates/tui/src/runner.rspreserves the optimistic UI while retrying, then drops the rollback snapshot only after the daemon acknowledges the mutation.- Preview auto-mark-read is marked
best_effort: it retries transient failures, but if it finally cannot reconcile, it refreshes quietly instead of blocking the mailbox with a mutation-failed modal.
That last distinction matters. Auto-mark-read is background politeness. Archive, trash, spam, label, and explicit read/unread are user intent. Both can retry; only explicit intent deserves an error surface after the retries fail.
What this generalizes to
Use this when:
- the action is idempotent or carries a correlation/idempotency key
- the optimistic projection is what the user expects to keep seeing
- the failure may be resource pressure, transport flake, or a temporary lock
- a late rollback would be more surprising than a short invisible retry
Do not use it when:
- the action charges money, sends a message, or creates an external side effect without idempotency
- the error says the request is invalid
- the provider rejected the user's permission or account state
- retrying could duplicate work
The pattern is not "hide errors". It is "do not confuse temporary admission failure with semantic failure".