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
  1. Turn on Out-of-order network.
  2. Click Toggle favorite twice quickly.
  3. Wait for requests to settle (Requests in flight becomes 0).
  4. Observe: UI and Server can disagree → DRIFT.

Buggy — stale response overwrites latest intent

Network: Normal
Last click:
UI value:
Server value:
Status: OK
Requests in flight
0

What to look for

  1. The second click represents the latest user intent.
  2. Requests complete out of order.
  3. After the system settles, UI and Server may disagree.
  4. A stale response overwrote a newer intent.
  5. 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.

Detection condition

inFlight === 0 && uiValue !== serverValue

Reference (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 snippet

// ❌ 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 snippet

// ✅ 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”)

Network: Normal
UI value:
Server value:
Status: OK
Requests in flight
0

What to look for

  1. Each click is versioned as a mutation.
  2. Older responses are ignored.
  3. UI and Server always converge after settle.
  4. User decisions cannot be overwritten by stale responses.
  5. 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.