You tap the heart. It fills instantly. The request goes out behind it. If the server disagrees, you roll back.
That rollback — undo the toggle when the request fails — agrees with the server on 600 of 600 sessions when the control is a like button.
On a set-a-value control, the identical policy agrees on 219 of 600. Which is worse than not rolling back at all (411).
👉 Live, 600 enumerated sessions computed in your browser: https://dev48v.infy.uk/design/day79-optimistic-ui.html
The engine simulates the queue, not a browser
There is no DOM here and nothing is sampled. The model is a sequence of taps, a network that reorders and fails, a server with its own truth, and five reconciliation policies run over 600 enumerated sessions each. Every figure below is a count, not an estimate.
| policy | toggle | set |
|---|---|---|
| no rollback at all | 411 / 600 | 411 / 600 |
| undo the toggle on failure | 600 / 600 | 219 / 600 |
| restore the pre-tap value | 396 / 600 | 357 / 600 |
| last-write-wins from the server | 600 / 600 | 600 / 600 |
| refetch on failure | 600 / 600 | 600 / 600 |
The policy did not change. The operation did.
toggle and set are the same shape at the call site:
const applyTo = (st, a) => {
st[a.item] = a.op === "set" ? a.value : !st[a.item];
};
One line, two operations, and the rollback that inverts a toggle is its own inverse — apply it twice and you are back where you started. That is why it is perfect on a like button. It is a group operation with an inverse, and the inverse is the operation itself.
set has no such property. Undoing a set requires knowing what the value was before, and by the time a failure comes back, later taps have already moved the item. So the "undo" writes a value that was never correct at any point in the timeline.
Nothing in the UI code knows which one it is holding. The rollback function has the same signature either way, passes review either way, and is catastrophically wrong in one of the two cases.
The careful-looking alternative is worse at both
The obvious fix is to capture the previous value at tap time and restore that on failure. It looks more correct. It is more wrong:
396 on a toggle and 357 on a set — mediocre at both, and it loses to the naive toggle-undo on the toggle case.
Same reason. It restores a value that was accurate when the tap was made and is stale when the failure lands. Being careful about the wrong instant does not help.
The part that should worry you
I wrote every property test I could think of that does not require a live server:
- the optimistic state converges once the queue drains
- no tap is lost
- the state is always one of the values the user actually chose
- rolling back an untouched item is a no-op
- applying the same failure twice is idempotent
Every optimistic policy passes every one of them. Including the one that is right 36% of the time.
That is not a gap in my imagination. It is structural: all of those properties are about the queue, and the difference between the policies is about the server's truth, which is exactly the thing a test without a server does not have. A suite like that will stay green through this bug forever.
One honest result that reversed on me
I expected "restore the previous value" to at least beat "no rollback" on the set case, since it is strictly more information. It does — 357 against 411 is the wrong direction, so it does not.
Reordering alone breaks it, without any failures at all. Two set operations do not commute, so a queue that delivers them out of order lands on the wrong value even when both succeed. I had asserted "same on a set with zero failures" while building this and it is false. That turned into a stronger finding than the one I was looking for.
What this does not cover
One item at a time, no batching, no conflict resolution beyond these five policies, no CRDTs, no offline queue that survives a reload. The network model reorders and drops but never duplicates. And "agrees with the server" is the only metric — nothing here measures how the UI feels, which is the reason optimistic UI exists in the first place.
18 in-page checks, 69 verifier assertions, 0 failures. Pure vanilla JS, one file, no build step.
Top comments (0)