Optimistic UI Without Desyncs: Snapshots, Rollbacks and Aborted Requests
Learn to build optimistic updates that stay correct: snapshot state, update instantly, roll back on failure, cancel stale requests, and know when not to use the pattern.
This article is published in English.
On a fast connection, the round trip behind a like button is invisible. On a train drifting between weak LTE and barely usable 3G, the same round trip can take five or ten seconds, and during that time the heart stays hollow while the user wonders whether the tap registered at all. Optimistic updates close that gap by changing the interface first and asking the server second. This guide shows how to implement the pattern in three explicit steps, how to protect it against racing requests, and how to decide where it belongs and where it is a liability.
The problem hidden inside a single tap
Picture a post sitting at 999 likes with an outlined heart. In the strictest implementation, tapping the heart sends a request to a server that may be on another continent. The server authenticates the caller, writes to a database and replies, and only then does the heart turn red and the counter move to 1,000. That wait is fine at a couple of hundred milliseconds and painful at five seconds. People who see no response tap again, then a third time, and eventually give up on the app, which is why this delay is such an effective way to lose users.
The tap also triggers more work than toggling one boolean. A typical platform does three things on the backend:
- It calls an API endpoint that increments the like counter for that post.
- It adds the post to a saved collection on the user's account so it can be found later.
- It sends a signal to the recommendation system, the part of the backend that picks what to show next, so similar content surfaces more often.
All three depend on the network. On the frontend, however, the tap only needs to change two pieces of state: whether the heart is filled, and the number next to it. If the interface waits for every server-side effect before touching either value, the app looks broken even when it is working correctly.
Optimism as a deliberate, recoverable lie
An optimistic update resolves this by having the frontend change its own state immediately, on the assumption that the request it just sent will succeed. The instant the finger leaves the screen, the heart is red and the count reads 1,000, even though nothing has reached the server yet. That small untruth is what makes the interaction feel instant.
It is only acceptable if there is a plan for when the assumption fails. The train might enter a tunnel and drop the connection, or the server might return an error. Leaving the screen at 1,000 while the database still says 999 creates a desync: the interface and the source of truth disagree. The frontend must then return to the real state, an outlined heart and 999, and usually tell the user with a toast, a short message that appears briefly and dismisses itself, such as "Couldn't save that. Try again."
Implementing the pattern in three steps
Flipping a variable and hoping the request works is a guess, not a design. A reliable optimistic update separates three responsibilities: capture the current state, show the expected result, then reconcile with the server's answer.
Step 1: capture a snapshot
Before anything on screen changes, record the values the user can currently see, here whether the post is liked and its current count. The handler receives the post, a state setter and a toast function, and imports the API call it will eventually make:
// likeButton.js
import { updateLikeOnServer } from "./api.js";
async function handleLikeClick(post, setPost, showToast) {
const previousState = { liked: post.liked, likeCount: post.likeCount };
// act two goes here
// act three goes here
}
After this step, you have a single untouched object that describes exactly what is on screen, ready to be restored if something goes wrong. Copying the two fields into a new object matters: if you kept a reference to a mutable object that later changes, the "snapshot" would change with it.
Step 2: apply the expected result
Next, update the UI directly, before the network call even starts:
async function handleLikeClick(post, setPost, showToast) {
const previousState = { liked: post.liked, likeCount: post.likeCount };
setPost({ liked: true, likeCount: post.likeCount + 1 });
// act three goes here
}
The screen now shows what the user asked for, a red heart and a higher count, while the server knows nothing yet. One detail to watch in your own code: this example passes a brand-new object with only liked and likeCount. If your setter replaces the whole post rather than merging fields, spread the existing post into the new value so that properties like the id are not lost.
Step 3: reconcile with the server
Finally, send the real request and either keep the optimistic state or tear it down. The request lives in a try block; if it throws, the handler restores the snapshot and shows the toast:
async function handleLikeClick(post, setPost, showToast) {
const previousState = { liked: post.liked, likeCount: post.likeCount };
setPost({ liked: true, likeCount: post.likeCount + 1 });
try {
await updateLikeOnServer(post.id);
} catch (error) {
setPost(previousState);
showToast("Couldn't save that. Try again.");
}
}
The API helper uses the browser's fetch. Note that fetch only rejects on network failures, not on HTTP error statuses, so the helper checks response.ok and throws explicitly. Without that check, a 500 response would count as success and the rollback would never run:
// api.js
export async function updateLikeOnServer(postId) {
const response = await fetch(`/api/posts/${postId}/like`, {
method: "POST",
});
if (!response.ok) {
throw new Error("Like request failed");
}
return response.json();
}
After the third step there are only two possible outcomes. Either the request succeeded and the optimistic state became true, or it failed and the UI returned to the snapshot from step one. In both cases, the interface never shows something the backend cannot support for longer than it takes the request to fail.
Where optimistic updates pay off
The extra structure is worth it only where a rollback is cheap and the perceived speed matters a lot. Three categories fit that profile consistently:
- Social interactions. Likes, follows and reactions are low stakes. If one out of many taps a day fails, the cost is that the user taps again.
- Messaging. In chat apps such as WhatsApp or Slack, a message sent on a poor connection appears in the conversation immediately, often faded or with a small grey clock icon. That is an optimistic update with a visible pending flag: the message is on screen before it has reached the server.
- Drag-and-drop boards. Dragging a card into the "Done" column of a kanban board like Jira or Trello would feel like dragging through wet sand if the card had to wait for confirmation. Optimistically, it lands at once and springs back to its original column if the request fails.
A useful refinement from the messaging case is the pending indicator. Showing that an action is not yet confirmed lets you be fast and honest at the same time, which softens the surprise if a rollback happens.
Where optimism becomes dangerous
Now imagine a banking app. A tenant taps "Send" to transfer five thousand dollars in rent to a landlord. With an optimistic update, the screen immediately reports that the transfer succeeded and the balance is now zero, and the user closes the app believing the rent is paid.
Behind the scenes the request fails; whether due to insufficient funds or a rejected transfer does not matter. The user keeps believing the money has moved until the landlord calls asking about the rent. At minimum that means a support ticket and damaged trust, and it happens because a pattern designed for cheap, reversible actions was applied to one where being wrong has real consequences.
The same caution applies to a few related categories:
- Financial transactions. Show a spinner. The wait signals that the system actually verified the operation before confirming it.
- Irreversible deletions. When someone deletes their account, the screen should not clear until the server confirms the data is gone.
- Security changes. Password updates and two-factor settings need certainty, not an interface claiming success in advance.
The dividing line is not how significant an action feels, but how costly it is to be wrong about it. Making someone wait three more seconds for a spinner is far better than telling them money moved when it did not.
When two taps race each other
There is a second failure mode that appears only when the user acts again before the first request finishes. Back on the slow train, the user likes the post, moving it from 999 to 1,000, and immediately unlikes it, returning to 999. Suppose the like needs five seconds to complete while the unlike needs only two; then the unlike can arrive first.
The server then processes the unlike for a post it does not yet consider liked, so nothing happens, and a few seconds later it processes the like. The account ends up liked on the server while the screen shows the opposite of what the user last did. When the final state is decided by arrival order instead of the order of the user's actions, you have a race condition.
Cancelling stale requests with AbortController
The usual remedy is cancellation: when a newer request for the same resource starts, tell the browser to abandon the older one. The built-in AbortController can cancel a fetch that is already in flight. The example keeps a reference to the active controller, aborts it before starting a new request, and chooses POST or DELETE based on the desired state:
// likeButton.js
let activeRequest = null;
async function toggleLike(postId, shouldLike) {
if (activeRequest) {
activeRequest.abort();
}
const controller = new AbortController();
activeRequest = controller;
await fetch(`/api/posts/${postId}/like`, {
method: shouldLike ? "POST" : "DELETE",
signal: controller.signal,
});
}
Each new tap cancels the previous request, so only the latest action is meant to land. Two caveats are worth knowing before relying on this alone:
- Aborting stops the browser from waiting for the response; it does not guarantee the server never received or processed the request. For stronger guarantees, design the endpoint around the desired final state (liked or not liked) so that repeated or reordered requests converge, or attach a sequence number the server can use to ignore stale writes.
- An aborted
fetchrejects with anAbortError. If you combine this with the rollback from step three, check for that error and skip the rollback, otherwise cancelling an outdated request would wrongly undo the newer optimistic state.
In React applications, data libraries handle much of this plumbing. TanStack Query, which manages server-state caching and request lifecycles, and Redux Toolkit, the official toolset for writing Redux logic, both provide built-in support for optimistic updates and cancellation, so you rarely write the AbortController wiring by hand. Check their documentation for exactly what gets cancelled in each case; it is still valuable to understand the mechanism underneath. For a deeper look at how rollbacks go wrong with React's own hook, see five failure modes of useOptimistic rollbacks.
Key takeaways
- A network round trip can be slow enough to make a working app look broken, so optimistic updates show the expected outcome first and confirm it later.
- Structure every optimistic update as snapshot, apply, reconcile; the snapshot is what makes the rollback trivial.
- Make failures visible:
fetchdoes not reject on HTTP errors, so throw on!response.ok, and tell the user with a toast when you roll back. - Use the pattern for cheap, reversible actions such as likes, messages and board moves; show a spinner for payments, deletions and security changes.
- Guard against races by cancelling superseded requests, ignoring
AbortErrorin rollback logic, and preferring endpoints that set a final state rather than toggling it.
Speed is a feature the network will not give you for free. An optimistic interface accepts that it cannot control the connection, answers the user confidently anyway, and stays ready to walk that answer back the moment it proves wrong.