Fixing Optimistic UI Updates That Get Stuck in a Rolled-Back State After a Failed Request
A like button, a drag-to-reorder list, or an inline edit updates instantly when the user acts — then a flaky network request fails, and the UI is supposed to roll back to its previous state. Instead, it gets stuck showing neither the optimistic value nor the real one, or silently keeps the optimistic change forever even though the server rejected it.
The Problem
A UI updates immediately when a user takes an action — liking a post, reordering a list, editing a field inline — without waiting for the server to confirm. This is standard optimistic UI, and it works well most of the time. But when the underlying request actually fails (a flaky connection, a validation error, a conflict), the rollback doesn't behave correctly: the UI sometimes freezes in an inconsistent in-between state, sometimes silently keeps showing the optimistic value forever with no visible error, and sometimes rolls back the wrong item entirely if multiple optimistic updates were in flight at once.
Why It Happens
The rollback logic assumes only one optimistic update is ever in flight at a time
A simple implementation often stores "the previous state" in a single variable before applying the optimistic change, then restores it on failure. If a second optimistic update starts before the first one's request resolves, the "previous state" saved for rollback purposes gets overwritten — a failure of the first request can end up rolling back to the second update's pre-state instead of the actual original, corrupting an item that was never supposed to be touched by that failure.
The success and failure paths aren't both guaranteed to run
If the optimistic update, the request, and the rollback aren't structured so that exactly one of "confirm" or "rollback" always executes — for instance, an early return in the error handler that skips the rollback call for a specific error type — some failures never reach the code that would revert the UI, leaving the optimistic value stuck indefinitely with no follow-up.
Rollback restores data but not associated UI state (loading flags, error indicators, in-flight markers)
A rollback that resets the data but forgets to also clear a "this item is saving" flag, or an in-flight request tracker, can leave the UI in a state where the data looks correct again but a spinner or disabled control never goes away — visually stuck even though the underlying data rollback technically succeeded.
Race conditions between a slow rollback and a subsequent optimistic update on the same item
If a user retries an action (or a different action on the same item) while the first request's failure and rollback are still being processed, the rollback can arrive after the second optimistic update has already applied — overwriting the user's newer, still-pending change with the stale rolled-back value.
The Fix
1. Key rollback state per operation, not in a single shared variable
const pendingUpdates = new Map(); // keyed by item id + operation id
function optimisticUpdate(itemId, newValue) {
const operationId = crypto.randomUUID();
const previousValue = items[itemId];
pendingUpdates.set(operationId, { itemId, previousValue });
setItems((prev) => ({ ...prev, [itemId]: newValue }));
api.update(itemId, newValue)
.then(() => pendingUpdates.delete(operationId))
.catch(() => rollback(operationId));
}
function rollback(operationId) {
const pending = pendingUpdates.get(operationId);
if (!pending) return; // already resolved or rolled back
setItems((prev) => ({ ...prev, [pending.itemId]: pending.previousValue }));
pendingUpdates.delete(operationId);
}
Storing each optimistic update's pre-change value keyed by a unique operation id — rather than a single shared "previous state" variable — means concurrent updates to the same or different items don't clobber each other's rollback data, and a failure rolls back exactly the change that actually failed.
2. Structure the request flow so confirm and rollback are the only two possible outcomes
async function submitUpdate(operationId, itemId, newValue) {
try {
await api.update(itemId, newValue);
confirmUpdate(operationId); // clears pending state, keeps optimistic value
} catch (error) {
rollback(operationId); // always runs on any failure, no early-return bypass
reportError(error); // logging/toast happens separately, after rollback is guaranteed
}
}
Structuring the try/catch so rollback is the single, unconditional action taken on any failure — with error reporting handled as a separate concern afterward — removes the possibility of a specific error type accidentally skipping the rollback path through an early return or a conditional that wasn't meant to bypass it.
3. Clear associated UI state (loading, error flags) in the same operation that clears the data rollback
function rollback(operationId) {
const pending = pendingUpdates.get(operationId);
if (!pending) return;
setItems((prev) => ({ ...prev, [pending.itemId]: pending.previousValue }));
setSavingIds((prev) => {
const next = new Set(prev);
next.delete(pending.itemId); // clear the "saving" indicator alongside the data rollback
return next;
});
pendingUpdates.delete(operationId);
}
Treating the data rollback and its associated UI indicators as one atomic operation — rather than separate pieces of state updated in different places — ensures a spinner or disabled state can't outlive the rollback that was supposed to resolve it.
4. Guard against a stale rollback overwriting a newer optimistic update on the same item
function rollback(operationId) {
const pending = pendingUpdates.get(operationId);
if (!pending) return;
// Only roll back if this is still the latest known operation for this item
const latestOpForItem = getLatestOperationId(pending.itemId);
if (latestOpForItem !== operationId) return; // a newer update superseded this one
setItems((prev) => ({ ...prev, [pending.itemId]: pending.previousValue }));
pendingUpdates.delete(operationId);
}
Checking whether the failing operation is still the most recent one for that specific item before applying its rollback prevents a slow, late-arriving failure from clobbering a newer optimistic change the user has already made on top of it.
Why This Works
Each fix targets a different way concurrent or partial handling breaks the assumption that "one optimistic update, one clean rollback" always holds. Per-operation rollback state removes cross-contamination between simultaneous updates; guaranteeing exactly one of confirm/rollback always runs closes the gap where a failure silently never gets handled; bundling UI-state cleanup with the data rollback prevents a visually stuck spinner even when the data itself recovers correctly; and checking operation recency before rolling back prevents a stale failure from overwriting a newer, still-valid change.
Conclusion
An optimistic UI update getting stuck after a failed request isn't usually a broken rollback function — it's a gap in how concurrent operations, guaranteed execution paths, and associated UI state are tracked around that rollback. Key rollback state per operation instead of a single shared variable, structure the request flow so confirm and rollback are the only two possible outcomes, clear UI indicators as part of the same rollback operation as the data itself, and check that a failing operation is still the most recent one before rolling it back.
