In this tutorial we will learn about preventing out-of-order rollbacks in optimistic UI. Imagine you are using a web app and you click an “Upvote” button. In an ordinary app, the button does nothing for half a second while a request travels to the server and back. Then the number goes from 10 to 11. In an optimistic UI, the app does not make you wait. The moment you click, the number immediately jumps to 11. Behind the scenes, the browser sends the request to the database. If everything goes well, you never notice any delay.
This works great when someone clicks once and waits. But real people do not use apps that way. Users double-click by mistake. They click “Like”, change their mind a second later, click “Unlike”, and then click “Like” again.
When someone clicks quickly, your app sends multiple network requests at almost the same time. And the internet does not guarantee that the first message sent will be the first message to arrive.
Table of Contents
Why People Use Loading Spinners (And What Is Wrong With Them)
You might ask: “Why not just disable the button and show a loading spinner?”
That is what most websites do:
- Set
isLoading = true. - Disable the button.
- Show a spinning icon.
- When the server answers, enable the button again.
Does this stop race conditions? Yes, completely. If the user cannot click, they cannot create out-of-order requests.
So why stop using loading spinners?
Because loading spinners make your app feel slow and sticky. Think about apps like X, Slack, or Notion. If social feeds froze the heart icon with a loading spinner every time you tapped “Like” on mobile, the entire app would feel sluggish. You could not quickly scroll and like posts in a row. Modern web apps use optimistic UI because they want the interface to feel instant. But the moment you remove the loading spinner and let people click freely, you open the door to a nasty bug: the out-of-order rollback. But this does not mean you should avoid spinners in every app. First make sure the kind of experience you want to offer, or the nature of the platform.
The Wrong Way: The Naive Rollback Trap
When developers first try to build an optimistic update without a spinner, they usually write code like this:
const handleToggle = async () => {
const previousState = isUpvoted;
setIsUpvoted(!isUpvoted);
try {
await sendVoteToServer(!isUpvoted);
} catch (error) {
setIsUpvoted(previousState);
}
};
This looks clean:
- Save
previousState. - Immediately flip the UI.
- Send the update to the server.
- If the server throws an error, restore
previousState.
Now look at what happens in production when a user clicks twice quickly:
- Click 1: The vote is
false. You savepreviousState = false, update the screen totrue, and fire Request 1. - Click 2: One second later, the user clicks again. You save
previousState = true, update the screen tofalse, and fire Request 2. - The Network Issue: Request 2 takes a fast network route and succeeds in 100 milliseconds. The server confirms the vote is now
false. - The Failure: Request 1 gets delayed, fails after 2 seconds, and hits the
catchblock.
Request 1 runs setIsUpvoted(previousState). Because previousState was captured during Click 1, it resets the screen to stale data. Request 1’s late failure blows away the user’s newest choice from Request 2. The button flickers, jumps backward, and shows the wrong state.
The Right Way To Rollback UI
To fix this, we need a simple rule: Newer actions always beat older actions. The goal is to keep track of states, and if something goes back restore the last confirmed state that server had notified.
Think of it like taking a numbered paper ticket counter:
- Every time you click, you pull a ticket with the next number (Ticket 1, Ticket 2, Ticket 3).
- When a request returns from the server, we check its ticket number.
- If Ticket 1 fails, but you are already holding Ticket 2, you do nothing. You ignore Ticket 1’s failure because Ticket 2 is now in charge.
- You only reset the screen if the ticket that failed is the latest ticket you have.
This lets users click as fast as they want without ever freezing the button, and it guarantees that an old failed request cannot undo a newer action.
A Simple Coding Example:
Let us build a small, reusable React hook called useOptimisticAction:
import { useState, useRef, useCallback } from 'react';
interface Options<T> {
initialValue: T;
mutateFn: (value: T) => Promise<T>;
onError?: (error: Error) => void;
}
export function useOptimisticAction<T>({
initialValue,
mutateFn,
onError,
}: Options<T>) {
const [value, setValue] = useState<T>(initialValue);
const sequenceRef = useRef(0);
const settledRef = useRef(0);
const baselineRef = useRef<T>(initialValue);
const performAction = useCallback(
async (nextValue: T) => {
sequenceRef.current += 1;
const currentTicket = sequenceRef.current;
setValue(nextValue);
try {
const serverResult = await mutateFn(nextValue);
if (currentTicket >= settledRef.current) {
settledRef.current = currentTicket;
baselineRef.current = serverResult;
setValue(serverResult);
}
} catch (err) {
const error = err instanceof Error ? err : new Error('Network error');
if (currentTicket >= sequenceRef.current) {
setValue(baselineRef.current);
onError?.(error);
}
}
},
[mutateFn, onError]
);
return { value, performAction };
}
How this hook works:
sequenceRef: Every time the user clicks, we add 1 tosequenceRef.current. That gives this call a uniquecurrentTicketnumber.baselineRef: This holds the last value the server confirmed. We only roll back to this value if something breaks.- The Success Check (
currentTicket >= settledRef.current): When a request succeeds, we make sure it is not older than something already settled. If it is fresh, we updatebaselineRef. - The Failure Check (
currentTicket >= sequenceRef.current): When an error hits, we check ifcurrentTicketis still the newest ticket. If the user clicked again while this request was running,sequenceRef.currentis higher. The hook safely ignores the old error and leaves the newer state alone.
Using the Hook in a Real Component
Here is how you use this hook in a clean button component:
import React from 'react';
import { useOptimisticAction } from './useOptimisticAction';
async function syncVoteWithServer(newVoteState: boolean): Promise<boolean> {
const response = await fetch('/api/upvote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ upvoted: newVoteState }),
});
if (!response.ok) {
throw new Error('Server rejected vote');
}
const data = await response.json();
return data.upvoted;
}
export function UpvoteButton({ defaultUpvoted = false }: { defaultUpvoted?: boolean }) {
const { value: isUpvoted, performAction } = useOptimisticAction({
initialValue: defaultUpvoted,
mutateFn: syncVoteWithServer,
onError: (err) => {
console.warn('Could not save vote:', err.message);
},
});
return (
<button
type="button"
onClick={() => performAction(!isUpvoted)}
className={isUpvoted ? 'vote-btn active' : 'vote-btn'}
>
{isUpvoted ? 'Upvoted' : 'Upvote'}
</button>
);
}
Notice what we achieved:
- No button freezing or disabling.
- No loading spinner blocking the user.
- If the user clicks multiple times rapidly, only the newest intent survives.
- If all network requests fail, the button cleanly resets to the last confirmed server state.
Optimistic UI makes modern websites feel snappy and responsive. While a loading spinner is the easiest way to avoid race conditions, it destroys that instant feel.
By adding a simple monotonic sequence counter, you get the best of both worlds:
- Instant feedback on every click.
- Full protection against out-of-order network responses.
- Clean error recovery when network failures occur.
Frequently Asked Questions
Can I use Date.now() instead of a counter?
No. Computer and phone clocks can shift when reconnecting to networks. An integer counter (+ 1) is guaranteed to always increase in order.
What if every single request fails?
Our hook handles this automatically. If all requests fail, the last request’s currentTicket matches sequenceRef.current, so it safely restores baselineRef.current (the last confirmed server state).
Does this replace AbortController?
No, you can combine both. AbortController stops client HTTP processing, but it cannot guarantee the server aborted database operations. Sequence tracking protects the UI either way.
If you need a simpler approach to this React also provides a hook. Check out this React Tutorial.
