Optimistic UI Updates: Making Your App Feel Instant
Why Perceived Speed Matters More Than Actual Speed
There’s a number that changed how we think about UI at Harbor Software: 100 milliseconds. That’s the threshold below which a user perceives an action as instantaneous. Above it, they start noticing. Above 300ms, they start feeling friction. Above 1 second, they start wondering if something is broken.
Your API will never consistently respond in under 100ms. Network latency alone — from Pakistan to a US-East server — can eat 200-400ms. So you have two choices: make your server faster (expensive, diminishing returns) or make your UI lie convincingly (cheap, immediately effective).
Optimistic UI updates are that convincing lie. You update the interface immediately as though the operation succeeded, then reconcile with the server response in the background. If the server confirms, the user never notices the gap. If the server rejects, you roll back gracefully.
This isn’t a hack. It’s the same pattern every major application uses — Gmail, Slack, Twitter, Linear, Notion. When you star an email in Gmail, the star appears instantly. Google didn’t build a server that responds in 10ms. They built a client that assumes success.
The Core Pattern: Assume Success, Handle Failure
Every optimistic update follows the same four-step lifecycle:
- Capture the current state (your rollback snapshot)
- Apply the expected change immediately (the optimistic update)
- Send the request to the server (the actual mutation)
- On failure, restore the snapshot (the rollback)
Let’s build this from scratch with a real example. We built a task management board for an internal tool at Harbor. Tasks can be toggled between complete and incomplete. Here’s the naive approach first:
// Naive approach: wait for server before updating UI
function TaskItem({ task }) {
const [isComplete, setIsComplete] = useState(task.completed);
const [isLoading, setIsLoading] = useState(false);
const toggleComplete = async () => {
setIsLoading(true);
try {
const updated = await api.updateTask(task.id, {
completed: !isComplete
});
setIsComplete(updated.completed);
} catch (err) {
toast.error('Failed to update task');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex items-center gap-3 p-3">
<button
onClick={toggleComplete}
disabled={isLoading}
className={isLoading ? 'opacity-50 cursor-wait' : ''}
>
{isComplete ? <CheckCircle /> : <Circle />}
</button>
<span className={isComplete ? 'line-through text-gray-400' : ''}>
{task.title}
</span>
</div>
);
}
This works, but every toggle has a visible delay. The button goes disabled, opacity drops, users wait 200-800ms for the checkbox to actually change. On a board with 50 tasks, this friction compounds into genuine frustration.
Now the optimistic version:
// Optimistic approach: update immediately, reconcile later
function TaskItem({ task }) {
const [isComplete, setIsComplete] = useState(task.completed);
const toggleComplete = async () => {
const previousState = isComplete; // Step 1: snapshot
setIsComplete(!isComplete); // Step 2: optimistic update
try {
await api.updateTask(task.id, { // Step 3: server request
completed: !previousState
});
} catch (err) {
setIsComplete(previousState); // Step 4: rollback on failure
toast.error('Failed to update task. Change has been reverted.');
}
};
return (
<div className="flex items-center gap-3 p-3">
<button onClick={toggleComplete}>
{isComplete ? <CheckCircle /> : <Circle />}
</button>
<span className={isComplete ? 'line-through text-gray-400' : ''}>
{task.title}
</span>
</div>
);
}
The checkbox toggles the instant you click. No loading state, no disabled button, no delay. If the server fails (network error, validation failure, conflict), the checkbox reverts and a toast explains what happened. In practice, the server succeeds 99%+ of the time, so the user almost never sees the rollback.
Optimistic Updates with React Query (TanStack Query)
The manual approach above works for simple cases, but in real applications you’re dealing with shared caches, multiple components reading the same data, pagination, and background refetches. React Query handles all of this with first-class optimistic update support.
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useToggleTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ taskId, completed }) =>
api.updateTask(taskId, { completed }),
onMutate: async ({ taskId, completed }) => {
// Cancel outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['tasks'] });
// Snapshot the previous value
const previousTasks = queryClient.getQueryData(['tasks']);
// Optimistically update the cache
queryClient.setQueryData(['tasks'], (old) =>
old.map(task =>
task.id === taskId ? { ...task, completed } : task
)
);
// Return snapshot for rollback
return { previousTasks };
},
onError: (err, variables, context) => {
// Rollback to snapshot
queryClient.setQueryData(['tasks'], context.previousTasks);
toast.error('Update failed. Your change has been reverted.');
},
onSettled: () => {
// Always refetch after error or success to ensure cache consistency
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
The key insight in this pattern is cancelQueries before the optimistic update. Without it, a background refetch might land between your optimistic update and the server response, overwriting your change with stale data. The user clicks, sees the change, then it reverts for no apparent reason. We hit this bug in production and it took two days to track down.
Optimistic Updates for Lists: Adding and Removing Items
Toggling a boolean is the simplest case. Adding and removing items from lists is where optimistic updates get interesting because you need to generate temporary IDs, handle ordering, and deal with the eventual real ID from the server.
function useAddComment(postId) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newComment) => api.createComment(postId, newComment),
onMutate: async (newComment) => {
await queryClient.cancelQueries({ queryKey: ['comments', postId] });
const previousComments = queryClient.getQueryData(['comments', postId]);
// Create an optimistic comment with a temporary ID
const optimisticComment = {
id: `temp-${Date.now()}`,
...newComment,
author: currentUser,
createdAt: new Date().toISOString(),
_optimistic: true, // Flag for UI styling
};
queryClient.setQueryData(['comments', postId], (old) => [
...old,
optimisticComment,
]);
return { previousComments };
},
onError: (err, variables, context) => {
queryClient.setQueryData(
['comments', postId],
context.previousComments
);
toast.error('Failed to post comment.');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
},
});
}
The _optimistic flag lets you style temporary items differently — a subtle opacity reduction or a “Posting…” label — so users understand the comment hasn’t been confirmed yet without blocking them from continuing to interact.
function Comment({ comment }) {
return (
<div className={comment._optimistic ? 'opacity-60' : ''}>
<p className="font-medium">{comment.author.name}</p>
<p>{comment.text}</p>
{comment._optimistic && (
<span className="text-xs text-gray-400">Posting...</span>
)}
</div>
);
}
The Danger Zone: When Optimistic Updates Go Wrong
Not every operation is a good candidate for optimistic updates. Here’s our internal checklist at Harbor for deciding when to use them and when to wait for the server.
Good candidates for optimistic updates:
- Toggling boolean states (like/unlike, complete/incomplete, read/unread)
- Adding items to lists (comments, messages, tasks)
- Updating user preferences (theme, notification settings)
- Reordering items (drag-and-drop sorting)
- Incrementing counters (upvotes, view counts)
Bad candidates for optimistic updates:
- Financial transactions. Never optimistically show a payment as complete. Users will act on that information.
- Irreversible destructive actions. Deleting an account, canceling a subscription, sending an email. If the server rejects it but the user already saw “Deleted,” trust is broken.
- Operations with complex server-side validation. If the server might reject the change for reasons the client can’t predict (inventory checks, permission changes, business rules), the rollback will confuse users.
- Multi-step workflows. If step 2 depends on the server response of step 1, you can’t optimistically proceed.
Handling Concurrent Mutations
Real users don’t wait for one action to complete before starting another. They’ll toggle three tasks in rapid succession, or add a comment while editing their profile. Each of these creates a separate optimistic update, and they can collide.
// Problem: rapid toggles can desynchronize state
// User clicks toggle, then clicks again before server responds
// Toggle 1: complete=true (optimistic) -> server confirms
// Toggle 2: complete=false (optimistic) -> server confirms
// But if Toggle 2's snapshot captured the pre-Toggle-1 state...
// Solution: use the queryClient cache as source of truth, not closures
onMutate: async ({ taskId, completed }) => {
await queryClient.cancelQueries({ queryKey: ['tasks'] });
// Read current state from cache (includes previous optimistic updates)
const previousTasks = queryClient.getQueryData(['tasks']);
queryClient.setQueryData(['tasks'], (old) =>
old.map(task =>
task.id === taskId ? { ...task, completed } : task
)
);
return { previousTasks };
},
The trick is that getQueryData reads the current cache state, which includes any previous optimistic updates that haven’t been settled yet. Each mutation’s rollback snapshot captures the world as it looks after all prior optimistic updates, not the original server state.
Optimistic Updates with Server Actions (Next.js)
Next.js 14 introduced the useOptimistic hook, which provides a framework-native way to do optimistic updates with Server Actions:
'use client';
import { useOptimistic } from 'react';
import { toggleTaskAction } from './actions';
function TaskList({ tasks }) {
const [optimisticTasks, addOptimistic] = useOptimistic(
tasks,
(state, { taskId, completed }) =>
state.map(task =>
task.id === taskId ? { ...task, completed } : task
)
);
const handleToggle = async (taskId, currentStatus) => {
addOptimistic({ taskId, completed: !currentStatus });
await toggleTaskAction(taskId, !currentStatus);
};
return (
<ul>
{optimisticTasks.map(task => (
<li key={task.id}>
<button onClick={() => handleToggle(task.id, task.completed)}>
{task.completed ? 'Undo' : 'Complete'}
</button>
{task.title}
</li>
))}
</ul>
);
}
The useOptimistic hook is simpler than React Query for this specific pattern. The optimistic state automatically resets when the Server Action completes and the component re-renders with fresh server data. No manual rollback needed — the server truth replaces the optimistic state naturally.
Rollback UX: How to Fail Gracefully
The rollback experience matters more than most developers realize. A poorly handled rollback destroys user trust faster than a slow API. Here are the patterns we’ve standardized on:
Pattern 1: Toast with context
Don’t just say “Something went wrong.” Tell the user what was reverted.
toast.error('Could not mark task as complete. Your change has been reverted.', {
action: {
label: 'Retry',
onClick: () => toggleComplete(taskId)
}
});
Pattern 2: Inline error state
For items in a list, show an error indicator on the specific item rather than a global toast. The user can see exactly which action failed.
Pattern 3: Retry queue
For offline-capable apps, queue failed mutations and retry when connectivity returns. This is the approach we use in mobile-first applications where users might be on flaky connections.
const mutation = useMutation({
mutationFn: updateTask,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
onMutate: optimisticUpdate,
onError: rollback,
});
Optimistic Updates for Drag-and-Drop Reordering
Reordering is a compelling use case because users expect immediate visual feedback when dragging items. Waiting for a server response before moving the item would make drag-and-drop feel broken.
function useReorderTasks(listId) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ taskId, newIndex }) =>
api.reorderTask(listId, taskId, newIndex),
onMutate: async ({ taskId, newIndex }) => {
await queryClient.cancelQueries({ queryKey: ['tasks', listId] });
const previous = queryClient.getQueryData(['tasks', listId]);
queryClient.setQueryData(['tasks', listId], (old) => {
const tasks = [...old];
const currentIndex = tasks.findIndex(t => t.id === taskId);
const [moved] = tasks.splice(currentIndex, 1);
tasks.splice(newIndex, 0, moved);
return tasks.map((task, i) => ({ ...task, order: i }));
});
return { previous };
},
onError: (err, vars, context) => {
queryClient.setQueryData(['tasks', listId], context.previous);
toast.error('Reorder failed. Items have been restored to their original positions.');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['tasks', listId] });
},
});
}
Debouncing Optimistic Saves
For text fields that auto-save (think Notion-style editing), you want optimistic local state but debounced server writes. The user types freely, sees their changes immediately, and the server gets batched updates instead of a request per keystroke.
function AutoSaveField({ initialValue, onSave }) {
const [value, setValue] = useState(initialValue);
const [saveStatus, setSaveStatus] = useState('saved'); // 'saved' | 'saving' | 'error'
const timeoutRef = useRef(null);
const handleChange = (newValue) => {
setValue(newValue); // Instant local update
setSaveStatus('saving');
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(async () => {
try {
await onSave(newValue); // Debounced server write
setSaveStatus('saved');
} catch (err) {
setSaveStatus('error');
}
}, 800);
};
return (
<div>
<textarea value={value} onChange={(e) => handleChange(e.target.value)} />
<span className="text-xs text-gray-400">
{saveStatus === 'saving' && 'Saving...'}
{saveStatus === 'saved' && 'Saved'}
{saveStatus === 'error' && 'Save failed. Retrying...'}
</span>
</div>
);
}
Testing Optimistic Updates
Testing optimistic behavior requires controlling timing. You need to verify the optimistic state appears before the server responds, and verify rollback happens on failure.
describe('TaskItem optimistic toggle', () => {
it('shows completed state immediately before server responds', async () => {
// Create a promise we control
let resolveApi;
const apiPromise = new Promise((resolve) => { resolveApi = resolve; });
vi.spyOn(api, 'updateTask').mockReturnValue(apiPromise);
render(<TaskItem task={{ id: '1', title: 'Test', completed: false }} />);
// Click toggle
await userEvent.click(screen.getByRole('button'));
// Optimistic state: should show completed BEFORE server responds
expect(screen.getByText('Test')).toHaveClass('line-through');
// Now resolve the server call
resolveApi({ completed: true });
await waitFor(() => {
expect(screen.getByText('Test')).toHaveClass('line-through');
});
});
it('rolls back on server error', async () => {
vi.spyOn(api, 'updateTask').mockRejectedValue(new Error('Server error'));
render(<TaskItem task={{ id: '1', title: 'Test', completed: false }} />);
await userEvent.click(screen.getByRole('button'));
// Briefly shows optimistic state
expect(screen.getByText('Test')).toHaveClass('line-through');
// After error, rolls back
await waitFor(() => {
expect(screen.getByText('Test')).not.toHaveClass('line-through');
});
});
});
The Bigger Picture: Perceived Performance as a Feature
Optimistic updates are part of a broader philosophy: perceived performance is a feature, not a trick. It’s the same philosophy behind skeleton screens (show structure before content), progressive image loading (blur-up to sharp), and instant page transitions (prefetch on hover).
The applications that feel the best aren’t necessarily the fastest on the server. They’re the ones that respect the user’s time by never making them wait for something that can be predicted. And most mutations can be predicted — because most of them succeed.
At Harbor Software, we treat optimistic updates as a default pattern, not an optimization. Every mutation starts with the question: “Can we show the result immediately?” If the answer is yes, we do. If the answer is no (payments, deletions, complex validations), we use the best non-optimistic pattern available — progress indicators, skeleton states, or staged confirmations.
The goal isn’t to eliminate all loading states. It’s to eliminate the unnecessary ones — the ones where the user is waiting for a server to confirm something that was always going to succeed.