Skip to content

Optimistic Updates Pattern

advanced18 min read

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.

Mental Model

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:

Execution Trace
User action
User clicks 'Like'. UI immediately shows liked state (heart fills, count increments).
No loading spinner
Background request
POST /api/posts/123/like fires in the background. User continues interacting.
UI already updated
Success path (99%)
Server confirms. Cache invalidated to reconcile any drift.
User never noticed the network request
Failure path (1%)
Server rejects. UI rolls back to previous state. Error toast shown.
User sees the revert and understands why

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):

  1. Cancel in-flight queries for this post (prevent stale data from overwriting our optimistic update)
  2. Snapshot the current cache value (for potential rollback)
  3. Optimistically update the cache
  4. 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.
Quiz
Why does onMutate call cancelQueries before the optimistic update?

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.

Quiz
How does React 19's useOptimistic handle rollback when a Server Action fails?

Error Recovery Strategies

What happens when the server says "no"? The strategy depends on the operation:

StrategyWhen to UseExample
Silent rollbackLow-stakes actions where failure is self-evidentLike button reverts, user understands
Rollback + toastActions where the user should know why it failedComment failed to post: 'Network error, try again'
Rollback + retryTransient failures (network blips)Auto-retry 3 times with exponential backoff
Keep optimistic + queueOffline-capable appsSave locally, sync when back online
Conflict resolution UICollaborative editing where server state divergedShow diff: 'Your version vs server version'

When NOT to Use Optimistic Updates

Optimistic updates aren't always appropriate. Don't use them when:

Key Rules
  1. 1Financial transactions: Never show 'payment successful' before the server confirms. The cost of a false positive is too high.
  2. 2Irreversible actions: Deleting data, sending emails, publishing content. If rollback means 'oops, we already sent the email', don't be optimistic.
  3. 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.
  4. 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.
  5. 5Low-confidence predictions: If you can't accurately predict the server's response (computed values, third-party integrations), don't guess.
Quiz
A user clicks 'Transfer $500' in a banking app. Should this use an optimistic update?

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.

Common Trap

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 doWhat 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