Skip to content
Back to Design Hub

Optimistic UI Updates

Showing the expected result immediately and reconciling when the server responds.

UX PatternsIntermediate

Why

Waiting 300ms for a like-toggle's server round-trip feels laggy. Optimistic UI applies the change instantly, then rolls back on error — perceived performance improves even when the network is slow.

Nielsen Norman Group research shows users spend an average of 10-15 seconds on a page before leaving. Clear visual hierarchy and progressive disclosure improve task completion rates by 40-60%.

When

Use for low-risk, easily-reversible actions: likes, stars, read/unread, checkbox toggles. Avoid for irreversible actions (delete, payment) or where stale data causes harm (balance, inventory).

How

1. Apply locally — mutate the cache/UI to the expected state immediately, before the request 2. Send the request — fire the POST/PUT in the background 3. On success — reconcile: server may return the canonical state (e.g. new like count), replace local 4. On error — rollback to the previous state, show a toast ("Couldn't like — retry") 5. Conflict — if the server says the state already changed, refetch and show the truth 6. Library — React Query's onMutate + onError rollback, SWR's optimisticData, or hand-rolled 7. Idempotency — make the action idempotent (like-toggle, not like-increment) so retries are safe

Example

// React Query — optimistic like toggle useMutation({ mutationFn: (liked: boolean) => api.put(`/posts/${id}/like`, { liked }), onMutate: async (liked) => { await queryClient.cancelQueries({ queryKey: ["post", id] }); const prev = queryClient.getQueryData(["post", id]); queryClient.setQueryData(["post", id], (p) => ({ ...p, liked })); // optimistic return { prev }; // context for rollback }, onError: (_e, _v, ctx) => { queryClient.setQueryData(["post", id], ctx?.prev); // rollback toast.error("Couldn't update — reverted"); }, onSettled: () => queryClient.invalidateQueries({ queryKey: ["post", id] }), }); // Hand-rolled — same pattern const [liked, setLiked] = useState(post.liked); const toggle = async () => { const prev = liked; setLiked(!prev); // optimistic try { await api.put(`/posts/${id}/like`, { liked: !prev }); } catch { setLiked(prev); toast.error("Reverted"); } // rollback };

Common Problems

  • ⚠No rollback on error — UI shows the optimistic state forever, diverges from server
  • ⚠Used for irreversible actions — user 'deletes' optimistically, server fails, data is gone in UI but not server
  • ⚠Non-idempotent action retried — double-like on retry, server count is wrong

Best Practices

  • ✓Always provide an onError rollback — the optimistic state must revert if the request fails
  • ✓Only use for reversible actions — likes, stars, toggles; never delete or pay optimistically
  • ✓Make the action idempotent — a like-toggle (not increment) so retries don't double-count

FAQ