Optimistic Updates Pattern
The Latency Problem
Every network request has latency. Even on fast connections, a round trip to the server takes 50-300ms. That might sound small, but research from Google and Microsoft shows that users perceive delays above 100ms. At 300ms, the app feels sluggish. At 1000ms, users lose focus.
The traditional approach: click a button, show a spinner, wait for the server, update the UI. The user waits for confirmation of an action they already know the outcome of. You clicked "like" — you know it's going to work. Why wait?
Optimistic updates flip this: update the UI immediately with the expected result, send the request in the background, and roll back only if it fails. The UI feels instant because it is instant — the network request happens invisibly.
Optimistic updates are like a waiter who starts preparing your regular order when they see you walk in the door. They don't wait for you to sit down, read the menu, and formally order. 99% of the time they're right and you get your food faster. The 1% of the time you want something different, they adjust. The key insight: the expected outcome is almost always correct, so acting on the prediction and handling the rare exception is better than always waiting for confirmation.
The Pattern: Predict, Update, Verify, Recover
Every optimistic update follows the same four-step pattern:
With TanStack Query
The most common implementation. TanStack Query's mutation hooks provide the perfect lifecycle for optimistic updates:
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Post {
id: string;
title: string;
likes: number;
isLiked: boolean;
}
function useLikePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (postId: string) =>
fetch(`/api/posts/${postId}/like`, { method: 'POST' }).then((r) => r.json()),
onMutate: async (postId) => {
await queryClient.cancelQueries({ queryKey: ['post', postId] });
const previousPost = queryClient.getQueryData<Post>(['post', postId]);
queryClient.setQueryData<Post>(['post', postId], (old) =>
old ? { ...old, likes: old.likes + 1, isLiked: true } : old
);
return { previousPost };
},
onError: (_error, postId, context) => {
if (context?.previousPost) {
queryClient.setQueryData(['post', postId], context.previousPost);
}
},
onSettled: (_data, _error, postId) => {
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});
}
Let's break down each callback:
onMutate (before the request fires):
- Cancel in-flight queries for this post (prevent stale data from overwriting our optimistic update)
- Snapshot the current cache value (for potential rollback)
- Optimistically update the cache
- Return the snapshot as context
onError (request failed):
- Restore the snapshot from context. The UI reverts to its previous state.
onSettled (request finished, success or failure):
- Invalidate the query to refetch the true server state. This reconciles any drift between our prediction and reality.
With Zustand
For client state managed by Zustand, the pattern is simpler since there's no cache layer:
import { create } from 'zustand';
interface TodoStore {
todos: Todo[];
addTodo: (text: string) => Promise<void>;
toggleTodo: (id: string) => Promise<void>;
}
const useTodoStore = create<TodoStore>((set, get) => ({
todos: [],
addTodo: async (text) => {
const tempId = `temp-${Date.now()}`;
const optimisticTodo: Todo = { id: tempId, text, completed: false };
set((state) => ({ todos: [...state.todos, optimisticTodo] }));
try {
const serverTodo = await createTodoAPI(text);
set((state) => ({
todos: state.todos.map((t) => (t.id === tempId ? serverTodo : t)),
}));
} catch {
set((state) => ({
todos: state.todos.filter((t) => t.id !== tempId),
}));
}
},
toggleTodo: async (id) => {
const previousTodos = get().todos;
set((state) => ({
todos: state.todos.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t
),
}));
try {
await toggleTodoAPI(id);
} catch {
set({ todos: previousTodos });
}
},
}));
The Zustand pattern is manual but explicit: snapshot before, update optimistically, rollback on error.
With React 19 useOptimistic
React 19 introduces useOptimistic — a built-in hook for optimistic updates, designed to work with Server Actions:
'use client';
import { useOptimistic } from 'react';
interface Message {
id: string;
text: string;
sending?: boolean;
}
function MessageThread({
messages,
sendMessage,
}: {
messages: Message[];
sendMessage: (text: string) => Promise<void>;
}) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(currentMessages, newText: string) => [
...currentMessages,
{ id: `temp-${Date.now()}`, text: newText, sending: true },
]
);
async function handleSend(formData: FormData) {
const text = formData.get('text') as string;
addOptimisticMessage(text);
await sendMessage(text);
}
return (
<div>
{optimisticMessages.map((msg) => (
<div key={msg.id} style={{ opacity: msg.sending ? 0.6 : 1 }}>
{msg.text}
</div>
))}
<form action={handleSend}>
<input name="text" />
<button type="submit">Send</button>
</form>
</div>
);
}
useOptimistic takes the actual state and a reducer function. When you call addOptimisticMessage, it immediately renders the optimistic version. When the Server Action completes and the parent re-renders with new messages, the optimistic state is automatically replaced with the real state. No manual rollback needed — React handles it.
Error Recovery Strategies
What happens when the server says "no"? The strategy depends on the operation:
| Strategy | When to Use | Example |
|---|---|---|
| Silent rollback | Low-stakes actions where failure is self-evident | Like button reverts, user understands |
| Rollback + toast | Actions where the user should know why it failed | Comment failed to post: 'Network error, try again' |
| Rollback + retry | Transient failures (network blips) | Auto-retry 3 times with exponential backoff |
| Keep optimistic + queue | Offline-capable apps | Save locally, sync when back online |
| Conflict resolution UI | Collaborative editing where server state diverged | Show diff: 'Your version vs server version' |
When NOT to Use Optimistic Updates
Optimistic updates aren't always appropriate. Don't use them when:
- 1Financial transactions: Never show 'payment successful' before the server confirms. The cost of a false positive is too high.
- 2Irreversible actions: Deleting data, sending emails, publishing content. If rollback means 'oops, we already sent the email', don't be optimistic.
- 3Complex server-side validation: If the server might reject the request for business logic reasons (insufficient funds, permission denied), optimistic updates show a false success.
- 4Multi-step operations: If step 2 depends on step 1's server response (e.g., payment ID from step 1 needed for step 2), you can't skip ahead.
- 5Low-confidence predictions: If you can't accurately predict the server's response (computed values, third-party integrations), don't guess.
Conflict Resolution
In collaborative or real-time apps, the server state might have changed between when you read it and when your mutation arrives. This is the "last write wins" problem.
onMutate: async (postId) => {
await queryClient.cancelQueries({ queryKey: ['post', postId] });
const previous = queryClient.getQueryData<Post>(['post', postId]);
queryClient.setQueryData<Post>(['post', postId], (old) =>
old ? { ...old, likes: old.likes + 1 } : old
);
return { previous };
},
onError: (_error, postId, context) => {
queryClient.setQueryData(['post', postId], context?.previous);
toast.error('Like failed. Someone else may have updated this post.');
},
onSettled: (_data, _error, postId) => {
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
The onSettled invalidation is your safety net. Even on success, it refetches the true server state — so if another user unliked the post between your read and your mutation, the final state is correct.
For more complex scenarios (collaborative editing, real-time state), CRDTs provide conflict-free resolution — covered in the real-time state topic.
A subtle bug in optimistic updates: multiple rapid mutations. If a user clicks "like" three times quickly, you get three onMutate calls, each snapshotting the current (already optimistic) state. If the second mutation fails and rolls back, it restores the snapshot from before the second mutation — which includes the first optimistic update. But what if the first mutation also fails? The rollback cascade gets complicated. TanStack Query handles this correctly with its mutation queue, but custom implementations often get it wrong. Test rapid-fire mutations.
| What developers do | What they should do |
|---|---|
| Skipping onSettled invalidation because the optimistic update 'should be correct' Your optimistic update is a prediction. The server might have computed a different result (timestamps, derived fields, concurrent modifications). Invalidation ensures you eventually converge with reality. | Always invalidate onSettled to reconcile with server truth |
| Using optimistic updates for operations with complex server-side validation If the server might reject the request for reasons you can't predict client-side (unique constraint violations, business rules), the optimistic UI shows a false success that erodes trust. | Only use optimistic updates when you can predict the server's response with high confidence |
| Not providing user feedback when an optimistic update rolls back From the user's perspective, the action 'worked' (UI updated) then 'un-worked' (UI reverted). Without explanation, they'll think the app is buggy. A toast like 'Comment failed to post — please try again' makes the rollback understandable. | Show a clear toast or notification explaining what happened and what action to take |