Back to Blog
Optimistic UI: When Reality Bites Back (and How to Fix It)
9 min readJul 23, 20266 views

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.

FrontendWeb DevelopmentReactSoftware DesignPerformance
Share

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.

typescript
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.

More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕