Users expect instant feedback. When someone likes a post or saves their work, waiting for a network round-trip creates friction and feels slow. Optimistic UI: Design Patterns best practices explained—the pattern where we assume success, update the interface immediately, and gracefully handle failures in the background.
Why optimistic UI matters in modern applications
Optimistic updates transform the user experience from reactive (server decides, then we show) to proactive (we show, then verify). This shift is especially important in e-commerce platforms where adding items to a cart, in travel apps managing bookings, and in social features like liking or commenting should feel instant.
The pattern reduces perceived latency. Even though the network call takes the same amount of time, users see changes immediately and feel more in control. Network latency becomes invisible until something actually fails—and failures are rare in most well-built systems.
We've seen this work across ride-hailing platforms (instant ride acceptance UI), CRM systems (immediate contact updates), and trading dashboards (instant order placement). The consistency is clear: when users see their actions reflected instantly, engagement and satisfaction increase measurably.
Apply the change immediately
The first step is the most important. Before sending any network request, update your local state or DOM as if the operation succeeded. This is where the "optimistic" part happens.
Consider a real-world scenario: a user likes a post in a social feed. Instead of showing a loading state and waiting for the server to confirm, we immediately increment the like count, highlight the like button, and update any like-related UI.
// React example with local state
const [likes, setLikes] = useState(initialLikes);
const [isLiked, setIsLiked] = useState(false);
const handleLike = async () => {
// Apply change immediately (optimistic update)
setLikes(prev => prev + 1);
setIsLiked(true);
// Send request in background
try {
await api.post(`/posts/${postId}/like`);
} catch (error) {
// Roll back on failure (handled in next section)
rollback();
}
};
The key principle: make the change visible before the network request completes. Your UI layer becomes decoupled from server latency. Users type, click, or drag—and see results immediately. The optimistic UI pattern assumes the happy path and shows it first.
In NestJS or similar backends, this means your frontend owns the state transition. The server is consulted for confirmation, not permission.
Send the request in the background
Once the UI is updated, fire off the actual API call. This happens asynchronously without blocking the user from interacting further. The request runs in the background, and the user never waits on it.
Let's expand the previous example with proper error handling:
const handleLike = async () => {
const previousLikes = likes;
const previousIsLiked = isLiked;
// Apply change immediately
setLikes(prev => prev + 1);
setIsLiked(true);
// Send request in background
try {
const response = await api.post(`/posts/${postId}/like`, {
timestamp: new Date().toISOString()
});
// Confirm the server state matches our optimistic update
if (response.data.likeCount !== likes + 1) {
// Handle state mismatch (rare but possible)
setLikes(response.data.likeCount);
}
} catch (error) {
// Roll back on failure
setLikes(previousLikes);
setIsLiked(previousIsLiked);
// Show error to user
showErrorNotification('Failed to like post. Please try again.');
}
};
Notice we save the previous state before applying changes. This becomes critical for rollback. The background request validates our assumption. In production systems, we use error boundaries and retry logic to handle transient network failures gracefully.
Timing matters here. Some teams add a small delay before showing the loading state—if the request completes within 200-300ms, users never see a spinner. The cognitive load of watching brief loaders can actually feel slower than no loader at all.
Roll back on failure
This is the safety net. When the background request fails, we revert the UI to its previous state. The user sees the change they made momentarily, then it rolls back with an error message explaining what happened.
Rollback strategies vary by context:
- Hard rollback: Return to the exact previous state. Used for critical operations like payments or inventory changes.
- Soft rollback: Show the previous state but offer a retry button. Common in social features.
- Server-side correction: Let the user keep seeing their change while the backend corrects it silently. Riskier but sometimes acceptable.
Here's a more robust example with retry logic:
const handleLike = async () => {
const previousLikes = likes;
const previousIsLiked = isLiked;
setLikes(prev => prev + 1);
setIsLiked(true);
const executeRequest = async (attempt = 1) => {
try {
await api.post(`/posts/${postId}/like`);
} catch (error) {
if (attempt < 3 && isNetworkError(error)) {
// Retry transient failures
await new Promise(r => setTimeout(r, 1000 * attempt));
return executeRequest(attempt + 1);
}
// Hard rollback after retries exhausted
setLikes(previousLikes);
setIsLiked(previousIsLiked);
showErrorNotification(
'Could not like post. Changes reverted.',
{ retryFn: () => handleLike() }
);
}
};
executeRequest();
};
The rollback happens transparently to the user in most cases. They see their action, the app works in the background, and only on genuine failure does the UI revert. This respects the user's time and attention while maintaining data consistency.
Use for likes, edits and reorders
The optimistic UI pattern works best for operations where:
- Failure is rare (well-tested APIs, reliable networks)
- The cost of rolling back is low
- Users expect immediate visual feedback
- The operation is non-destructive or easily reversible
Likes and favorites: Toggle a boolean, update a counter. Instant feedback feels natural. A user expects their like to show up immediately.
Edits and form submissions: Update a text field, save a profile change. Show the new content immediately, then confirm it persisted. In a CRM system, changing a contact's email should update instantly while the save request runs in the background.
// Editing a contact in a CRM
const updateContact = async (contactId, updatedData) => {
const previousData = contactState[contactId];
// Apply change immediately
setContactState(prev => ({
...prev,
[contactId]: { ...prev[contactId], ...updatedData }
}));
try {
await api.patch(`/contacts/${contactId}`, updatedData);
// Optional: fetch fresh data to ensure sync
} catch (error) {
setContactState(prev => ({
...prev,
[contactId]: previousData
}));
showErrorNotification('Failed to save changes.');
}
};
Reordering and drag-drop: When users drag items in a list (sorting tasks, arranging kanban cards), show the new order immediately. The backend confirms the new sequence in the background. This feels especially snappy in desktop applications and trading platforms where order matters.
Not ideal for: Optimistic updates work poorly for operations with side effects (charging payments, deleting data, sending emails). These need explicit confirmation and cannot assume success.
Common mistakes
Forgetting to save previous state: If you don't store the previous values before updating, rollback becomes impossible. Always preserve state before optimistic updates.
No error feedback: Rolling back silently confuses users. They made a change, saw it, then it disappeared with no explanation. Always show a clear error message and offer a retry option.
Assuming all operations are retriable: Some actions (like marking a payment as processed) shouldn't be retried automatically. Distinguish between safe and unsafe retries in your code.
Not handling concurrent requests: If a user optimistically updates, then the server sends a conflicting change, your state can become inconsistent. Use versioning (ETag, timestamp) or request deduplication to prevent this.
Over-optimizing: Not every operation needs optimistic updates. Payment forms, destructive actions, and operations with rare success rates should wait for server confirmation. Use the pattern selectively where it genuinely improves experience.
Ignoring network conditions: On poor connections (3G, rural areas), optimistic updates can fail frequently, creating frustration. Consider disabling the pattern or showing more explicit loading states on slow networks.
Key takeaways
- Update the UI immediately before the network request completes; users feel instant feedback even on slow networks
- Save previous state before applying changes so rollback is always possible
- Handle failures gracefully with clear error messages, retry logic, and easy recovery options
- Apply optimistic updates to non-destructive operations: likes, edits, reorders, favorites—not payments or deletions
- Distinguish between transient network errors (retry automatically) and genuine failures (ask user to retry)
- Test rollback paths thoroughly; errors are rare but must work perfectly when they occur
- Monitor for state inconsistencies between client and server; use versioning or checksums for safety






Comments
Loading comments…