
Optimistic UI: When Reality Bites Back (and How to Fix It)
Optimistic UI is a powerful pattern, but it's not without its gotchas. I'll show you how to handle a subtle race condition that can lead to inconsistent states, especially when users interact rapidly.
by Sunil Band
The Double-Edged Sword of Optimistic UI
We all love optimistic UI. It's that magical feeling when you click a button, and the change instantly appears on screen, even before the server has confirmed it. The UI feels snappy, responsive, and frankly, delightful. It's a hallmark of a modern web application. But that instant gratification comes with a hidden cost: race conditions.
I've seen it time and again. You implement optimistic updates, everything looks great in testing, then a user with a fast connection and faster fingers starts mashing a button, and suddenly your UI is out of sync with your backend. It's not a bug that's easy to spot, because it often requires a specific sequence of rapid interactions and network timing to manifest. It's the kind of bug that makes you question your sanity and the very fabric of asynchronous programming.
The Problem: Multiple Optimistic Updates, One Source of Truth
Let's say you have a 'like' button. When a user clicks it, you instantly increment the like count on the UI and then send an API request to persist the change. If the request succeeds, great. If it fails, you roll back the UI. This works perfectly for a single click.
Now imagine the user clicks 'like' quickly, then 'unlike', then 'like' again, all within a second. Each click triggers an optimistic update and an API call. These API calls are asynchronous and their responses might not come back in the order they were sent. If the 'unlike' request finishes before the initial 'like' request, but its response comes after the initial 'like' response, your UI can end up in an incorrect state.
The core issue is that each optimistic update is based on the current UI state at the time of the click, not necessarily the true backend state, or even the state that will eventually be returned by an earlier, pending API call. When responses come back out of order, the later-arriving response (from an earlier request) can overwrite a more recent, correct UI state.
Tackling the Race: Request Keys and Stale Responses
The solution I've found most robust for this particular optimistic UI race condition involves correlating your API requests with your UI updates. You need a mechanism to identify which API response corresponds to which UI interaction, and, crucially, to discard responses that are stale relative to a more recent user action.
My approach involves assigning a unique request key to each optimistic action. This key is generated when the user initiates an action (e.g., clicks 'like'). This key is then passed to the API call. When the API response comes back, we can check if this response is still relevant. If a newer action with a newer key has already been initiated, we simply ignore the older response.
Let's walk through a simple useOptimisticAction hook in React that demonstrates this. We'll use a postId and a liked status to illustrate. Every time the user toggles the liked status, we generate a unique actionId. This actionId is then associated with the network request. If a response comes back with an actionId that is not the latest one, we ignore it.
import React, { useState, useCallback, useRef, useReducer } from 'react';
// Simulate an API call
async function toggleLikeAPI(postId: string, liked: boolean): Promise<{ success: boolean; newLikedStatus: boolean }> {
return new Promise(resolve => {
const delay = Math.random() * 500 + 200; // Simulate variable network latency
setTimeout(() => {
if (Math.random() > 0.1) { // 10% chance of failure
resolve({ success: true, newLikedStatus: liked });
} else {
resolve({ success: false, newLikedStatus: !liked }); // Simulate backend rejecting the change
}
}, delay);
});
}
type LikeState = {
liked: boolean;
likeCount: number;
pendingActionId: number | null; // Tracks the ID of the most recent pending action
};
type Action =
| { type: 'TOGGLE_OPTIMISTIC' } // User clicked, update optimistically
| { type: 'SET_PENDING_ACTION', id: number } // Store the ID of the pending API call
| { type: 'APPLY_API_RESULT', id: number, newLikedStatus: boolean, success: boolean } // API call returned
| { type: 'ROLLBACK_OPTIMISTIC', id: number }; // API call failed, revert UI
const likeReducer = (state: LikeState, action: Action): LikeState => {
switch (action.type) {
case 'TOGGLE_OPTIMISTIC':
// Increment/decrement like count based on current optimistic state
return {
...state,
liked: !state.liked,
likeCount: state.liked ? state.likeCount - 1 : state.likeCount + 1,
};
case 'SET_PENDING_ACTION':
return {
...state,
pendingActionId: action.id,
};
case 'APPLY_API_RESULT':
// Only apply if this response corresponds to the most recent action we're tracking
if (state.pendingActionId === action.id) {
if (action.success) {
return { ...state, liked: action.newLikedStatus, pendingActionId: null };
} else {
// If API failed, the liked status might have been changed by a subsequent optimistic click
// We need to revert *only if* the current UI state matches the optimistic state from this failed action.
// This is a subtle point. For simplicity here, we assume if API failed, the optimistic change from THIS action was wrong.
// A more complex reducer might store the 'expected' state for rollback.
return { ...state, liked: !action.newLikedStatus, pendingActionId: null }; // Revert to previous state based on initial click
}
}
return state; // Ignore stale response
case 'ROLLBACK_OPTIMISTIC':
// Rollback only if this is the most recent pending action.
// If a newer action has occurred, its optimistic state might already be different.
if (state.pendingActionId === action.id) {
return {
...state,
liked: !state.liked, // Revert the liked status
likeCount: state.liked ? state.likeCount + 1 : state.likeCount - 1, // Revert like count
pendingActionId: null,
};
}
return state; // Ignore stale rollback instruction
default:
return state;
}
};
export const useOptimisticLike = (initialLiked: boolean, initialLikeCount: number, postId: string) => {
const [state, dispatch] = useReducer(likeReducer, {
liked: initialLiked,
likeCount: initialLikeCount,
pendingActionId: null
});
const latestActionId = useRef(0);
const toggleLike = useCallback(async () => {
const currentActionId = ++latestActionId.current; // Generate a unique ID for this action
dispatch({ type: 'TOGGLE_OPTIMISTIC' });
dispatch({ type: 'SET_PENDING_ACTION', id: currentActionId });
try {
const response = await toggleLikeAPI(postId, !state.liked); // Note: !state.liked here refers to the state *before* the optimistic update for the API call
// We pass the intended new state to the API, then reconcile based on the response.
if (currentActionId === latestActionId.current) { // Only apply if this is the latest action
dispatch({
type: 'APPLY_API_RESULT',
id: currentActionId,
newLikedStatus: response.newLikedStatus,
success: response.success
});
} else {
console.log(`Ignoring stale response for action ID: ${currentActionId}`);
}
} catch (error) {
console.error("API Error:", error);
if (currentActionId === latestActionId.current) { // Only rollback if this is the latest action
dispatch({ type: 'ROLLBACK_OPTIMISTIC', id: currentActionId });
}
}
}, [postId, state.liked]); // state.liked dependency ensures toggleLike gets the *current* UI state for its API call
return { liked: state.liked, likeCount: state.likeCount, toggleLike };
};
// Example Usage:
function PostCard({ initialLiked, initialLikeCount, postId }: { initialLiked: boolean; initialLikeCount: number; postId: string }) {
const { liked, likeCount, toggleLike } = useOptimisticLike(initialLiked, initialLikeCount, postId);
return (
<div style={{ border: '1px solid #ccc', padding: '15px', margin: '10px', borderRadius: '8px' }}>
<h3>Post Title {postId}</h3>
<p>Likes: {likeCount}</p>
<button onClick={toggleLike} style={{
backgroundColor: liked ? '#ff4d4d' : '#4CAF50',
color: 'white',
padding: '10px 15px',
border: 'none',
borderRadius: '5px',
cursor: 'pointer'
}}>
{liked ? 'Unlike' : 'Like'}
</button>
<p style={{ fontSize: '0.8em', color: '#666' }}>
Click rapidly to see how it handles race conditions or API failures.
</p>
</div>
);
}
// To run this example in a React app:
// function App() {
// return (
// <div>
// <PostCard initialLiked={false} initialLikeCount={10} postId="post-1" />
// <PostCard initialLiked={true} initialLikeCount={5} postId="post-2" />
// </div>
// );
// }
// export default App;How latestActionId Prevents Stale Updates
The latestActionId useRef acts as our sentinel. Every time a new toggleLike action is initiated by the user, we increment latestActionId.current and assign it to currentActionId. This currentActionId is then used to track this specific network request.
When the toggleLikeAPI promise resolves, before dispatching the APPLY_API_RESULT or ROLLBACK_OPTIMISTIC action, we check if (currentActionId === latestActionId.current). This is the crucial check. If currentActionId is not equal to latestActionId.current, it means a newer action has already started since this network request was initiated. In such a scenario, the response from this older request is stale and should be ignored to prevent overwriting the UI with outdated information.
This pattern ensures that only the response corresponding to the most recent user action actually affects the UI state after an asynchronous roundtrip. Any earlier responses that happen to arrive later are simply discarded, protecting your UI from temporal inconsistencies.
Trade-offs and Considerations
This pattern, while effective, introduces a bit more complexity than a naive optimistic update. You're now managing unique action IDs and performing checks on every API response. For very simple optimistic updates where rapid-fire interaction isn't expected (e.g., changing a user's profile picture once every few months), this might be overkill.
Another consideration is how you handle dependent optimistic updates. If liking a post also updates a global feed, you'd need to propagate these actionIds or have a more sophisticated state management system that can coordinate multiple optimistic changes based on a single user interaction. For more complex scenarios, libraries like React Query or SWR provide built-in mechanisms for managing optimistic updates and invalidating caches, often abstracting away some of these lower-level race condition concerns.
However, even with these libraries, understanding the underlying problem and the actionId solution helps you debug when things go wrong, or when you need to implement a custom optimistic flow that doesn't fit neatly into a library's conventions. It's about knowing why something is done a certain way, not just how to use an API.
Wrapping up
Optimistic UI makes your applications feel responsive, but it's a subtle beast. The race condition described here, where fast user interaction leads to out-of-order API responses, is a classic. By introducing a simple actionId tracking mechanism, you can effectively prevent stale API responses from corrupting your UI state. It's a small change that makes a big difference in robustness.
Try integrating this useOptimisticLike hook into a small component in one of your React projects. Play around with network throttling in your browser's dev tools and click the button rapidly. Introduce some random API failures. You'll quickly see how this seemingly minor detail makes your optimistic UI much more resilient.

When Your UI Needs to Break the Flat Screen: React and 3D with React Three Fiber
We've been building UIs on flat screens for decades. But what happens when you need something more? When data visualization demands depth, or an interactive product showcase needs a real sense of presence? React Three Fiber is the tool that lets you bring the full power of Three.js into your React a

When Your State Management Needs to Stop Thinking in Actions and Start Mutating
Many state management libraries force you into an 'actions and reducers' pattern. While powerful, it often adds unnecessary boilerplate for simple updates. Sometimes, you just need to directly modify state, and mutators offer a more ergonomic and intuitive approach, especially for deeply nested data

When Next.js Cache Components Refuse to Build Your App
Next.js 16.3 introduced 'Cache Components' to optimize server-side rendering, but getting them to work can be a headache. I spent a frustrating afternoon debugging why a simple page wouldn't build, only to uncover some subtle yet critical design considerations. It turns out, this feature forces you


















