When optimistic UI breaks causality
Optimistic updates improve perceived latency — but under concurrency, stale responses can violate causality and overwrite user intent.
Break it: out-of-order responses overwrite the latest intent
Turn on Out-of-order network. Click Toggle favorite twice quickly. The second click is your latest intent — but an older response can arrive later and overwrite it.
Repro steps
- Turn on Out-of-order network.
- Click Toggle favorite twice quickly.
- Wait for requests to settle (Requests in flight becomes 0).
- Observe: UI and Server can disagree → DRIFT.
Buggy — stale response overwrites latest intent
Last click: —
UI value: ☆
Server value: ☆
What to look for
- The second click represents the latest user intent.
- Requests complete out of order.
- After the system settles, UI and Server may disagree.
- A stale response overwrote a newer intent.
- Causality was violated.
Why this breaks
Mental model: intents have an order
- User actions are ordered intents — newer must win.
- With overlapping requests, responses can arrive out of order.
- If effects are committed by arrival time, an older intent can overwrite a newer one.
- If the client doesn’t reconcile after settle, it can keep showing a value the server no longer has.
Broken invariant
Invariant: after requests settle, UI and Server must converge — and represent the latest user intent.Detected issue
Detector
Status: DRIFT when inFlight === 0 and UI ≠ Server.inFlight === 0 && uiValue !== serverValueReference (correct mental model)
Correct mental model
Incorrect / Buggy
- Clicks are ordered intents, but responses arrive out of order.
- The system commits effects by arrival time (“latest arrival wins”).
- UI doesn’t reconcile after settle → it can drift and lie.
// ❌ Buggy: system commits by arrival time ("latest arrival wins")
api.update({ next }).then(res => {
setServer(res.value); // older completion can overwrite newer intent
// ❌ no settle-time reconcile => UI can drift from server
});Correct
- Version every mutation to preserve causality and intent ordering.
- Server applies only the newest mutation (“latest wins”).
- Client ignores stale responses and converges UI to server truth.
// ✅ Correct: version intents ("latest wins")
const id = ++mutationIdRef.current;
setUi(next); // optimistic
api.update({ next, mutationId: id }).then(res => {
if (id !== mutationIdRef.current) return; // ignore stale responses
setServer(res.value);
setUi(res.value); // converge after settle
});Fix: Version every mutation to preserve intent ordering.
The fix is user-visible: even with out-of-order responses, the server and UI converge to the latest intent.
Fixed — versioned intents (“latest wins”)
UI value: ☆
Server value: ☆
What to look for
- Each click is versioned as a mutation.
- Older responses are ignored.
- UI and Server always converge after settle.
- User decisions cannot be overwritten by stale responses.
- No drift is possible.
Key takeaways
- Each click is an ordered intent — preserve causality.
- Out-of-order responses are normal; stale writes must not overwrite newer intent.
- Detect drift only after settle (inFlight === 0).
- Fix with versioned mutations (“latest wins”) + ignoring stale responses.
- Converge UI to server truth after settle.
Approach & tradeoffs
Approach
- Optimistically update UI immediately for perceived speed.
- Assign a monotonic mutation id per click.
- Server applies only if mutationId is newest (prevents stale overwrites).
- Client ignores stale responses and reconciles UI to server truth.
Tradeoffs
- More state: mutation ids + last-applied tracking.
- Some work is wasted when stale responses are ignored — correctness wins.
- For complex payloads, you may need patch-based merges instead of full overwrites.
Common pitfall
“Apply whatever response arrives.” That’s latest-arrival wins, not latest-intent wins. Under concurrency, it violates causality — stale intent can overwrite user decisions.