Skip to content
← All posts

Nov 8, 2026

Show it done, but don't fake it: optimistic updates that roll back cleanly

An optimistic update is a promise to the user. If the server says no, the UI has to keep that promise honestly — roll back, say why, and leave the data alone.

ReactDashboardsAPIs

The fastest-feeling dashboard I have built did not have a faster API. It just stopped making the user wait for it. When someone marked an order as shipped, the row changed immediately, before the server answered. That is an optimistic update, and users love it — until the day the server says no and the UI keeps pretending otherwise.

An optimistic update is a promise. The UI is telling the user "this is done" before it knows that for sure. So the rollback is not an edge case, it is half the feature. If the request fails, the row has to go back to its real state, the error has to be visible in the place the user was looking, and the user has to be able to try again without refreshing the page and losing their place.

Disabling the submit button while a request is in flight is the first line of defence, but it is not enough on its own. A double-click is only one way to get a duplicate. The same form can be submitted from two tabs, retried after a network blip, or re-sent when the user hits refresh at the wrong moment. The client can reduce duplicates, but only the server can prevent them.

That is where an idempotency key earns its keep. The client generates one unique key per logical action — one key for "ship order 1042", not one key reused for every click — and sends it with the request. The server stores the result against that key. If the same key arrives again, the server returns the stored result instead of running the action a second time.

// One key per logical action, generated on the client
// and sent with the request. A retry replays the result,
// it does not re-run the action.
await fetch('/api/orders/1042/ship', {
  method: 'POST',
  headers: { 'Idempotency-Key': 'ship-1042-9f3a' },
});

A few details matter. A key must never be reused for a different action, or a retry will return the wrong answer. A validation failure should not burn the key forever, or the user can never correct and resubmit the same form. And an optimistic update should never be used for something irreversible without a confirmation step first — speed is not worth a shipped order that was never meant to ship.

So the shape that works for me is layered: update the UI immediately so it feels instant, show a pending state so the user knows it is not confirmed yet, roll back cleanly with a clear error if it fails, and back all of it with a server-side idempotency key so a retry is safe. Any one of those layers alone is fragile. Together, they are why a dashboard can feel instant without lying to the person using it.