Optimistic UI Rollback: How to Update Instantly and Handle Failures Gracefully

Security & Architecture

When a user clicks "Like" on a post, they expect instant feedback. If the UI waits for the server to respond before updating, there is a 200–500ms pause where nothing happens. This pause feels sluggish even when the connection is fast.

Optimistic UI solves this by immediately applying the expected state change and then reconciling with the server response. If the server confirms, nothing visible changes. If the server rejects, you roll back.

The Basic Pattern

function LikeButton({ postId, initialLiked }) {
  const [liked, setLiked] = useState(initialLiked)
  const [isPending, setIsPending] = useState(false)

  async function handleClick() {
    const previous = liked
    setLiked(!liked)         // Optimistic update
    setIsPending(true)

    try {
      await api.toggleLike(postId)
    } catch {
      setLiked(previous)     // Rollback on failure
    } finally {
      setIsPending(false)
    }
  }

  return (
    <button onClick={handleClick} disabled={isPending}>
      {liked ? '❤️' : '🤍'}
    </button>
  )
}

The UI updates immediately. The previous state is captured before the update so it can be restored. On failure, the rollback restores the pre-mutation state.

The Snapshot Trap

The simple pattern above captures liked by value — that works for primitive state. For complex objects, you need a deep clone:

// ❌ Reference — mutations to posts will corrupt previousItems
const previous = items

// ✅ Snapshot — safe to restore even if items is later mutated
const previous = JSON.parse(JSON.stringify(items))
// or with structuredClone:
const previous = structuredClone(items)

TanStack Query's onMutate context mechanism handles this cleanly:

useMutation({
  mutationFn: (id) => api.toggleLike(id),
  onMutate: async (id) => {
    await queryClient.cancelQueries({ queryKey: ['posts'] })
    const previous = queryClient.getQueryData(['posts'])

    queryClient.setQueryData(['posts'], (old) =>
      old.map(p => p.id === id ? { ...p, liked: !p.liked } : p)
    )

    return { previous } // context passed to onError
  },
  onError: (err, id, context) => {
    queryClient.setQueryData(['posts'], context.previous) // rollback
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] }) // sync with server
  },
})

cancelQueries prevents an in-flight refetch from overwriting the optimistic state before the mutation resolves.

Server-Returned State vs Rollback

The cleanest approach is not to compute the optimistic state locally, but to apply the server's returned value on success:

onSuccess: (serverPost) => {
  queryClient.setQueryData(['posts'], (old) =>
    old.map(p => p.id === serverPost.id ? serverPost : p)
  )
}

This avoids divergence between the local optimistic state and the server's canonical state. If the server modifies other fields during the mutation (like updating a likeCount), those changes are captured automatically.

Race Conditions in Optimistic Updates

Rapid sequential mutations create race conditions. A user likes, unlikes, and likes a post within 300ms. Three requests are in flight. They may resolve in any order:

User action: like → unlike → like
Requests: Req1(like) Req2(unlike) Req3(like)
Response order: Req2, Req3, Req1
Final server state: liked (Req1 was last to arrive)
UI final state: liked (matches Req3, the last user action)
Server ← UI: mismatch possible

Solutions:

  1. Debounce: Collapse rapid successive changes into a single request
  2. Cancel previous requests: Use AbortController to cancel pending mutations when a new one is initiated
  3. Queue mutations: Process mutations strictly sequentially, waiting for each to resolve before sending the next
  4. Server sequencing: Use version numbers or timestamps to let the server detect and resolve conflicts

TanStack Query's useMutation with retry: false and careful onSettled invalidation covers most cases for non-critical data. For financial or booking data, sequential queuing is worth the implementation cost.

UX: Communicating Rollbacks

A silent rollback is disorienting. The user clicked something, the UI changed, then it changed back — with no explanation. Good rollback UX includes a notification:

onError: (error, id, context) => {
  queryClient.setQueryData(['posts'], context.previous)
  toast.error('Could not update. Please try again.')
}

The rollback should be instant (no animation delay) and the error message should clearly indicate the action failed — not describe technical details. "Could not save" is better than "500 Internal Server Error."

When Not to Use Optimistic UI

Optimistic updates are most appropriate for low-stakes, reversible actions: likes, bookmarks, toggles, list reordering. They are less appropriate for:

  • Financial transactions (send money, confirm payment)
  • Destructive actions (delete account, clear all data)
  • Actions where failure is common (uploads to nearly-full storage)

For high-stakes actions, the standard pattern — show a loading state, block interaction, confirm on success — is less convenient but more trustworthy.

Conclusion

Optimistic UI is a UX improvement that requires engineering discipline. The fast path is simple: update, then revert on failure. The traps are in snapshot integrity, race conditions between rapid mutations, and communicating failures clearly. TanStack Query's mutation lifecycle hooks (onMutate, onError, onSettled) are purpose-built for this pattern and handle most of the edge cases if you follow the cancel-query-then-invalidate structure.