PAUL CHONGSenior Software Engineer

Optimistic Updates

2026-08-06 · 7 min read

An optimistic update applies a change to the UI immediately — before the server responds — under the assumption that the request will succeed. If the server confirms, nothing more needs to happen. If the server rejects, the UI rolls back to its previous state.

The alternative is pessimistic: disable the button, show a spinner, wait for the response, then update. Pessimistic updates are safer but feel slow. On a 200ms network, a like button that waits for the server creates a noticeable lag on every tap. Optimistic updates eliminate that lag entirely.

The happy path

User taps "like"
  → UI: post is liked (count +1, icon filled)    ← immediate
  → Network: POST /api/posts/42/like             ← in flight
  → Server: 200 OK                               ← confirmed
  → UI: no change needed

From the user's perspective, the interaction is instant. The network request happens in the background.

The rollback path

User taps "like"
  → UI: post is liked (count +1, icon filled)    ← immediate
  → Network: POST /api/posts/42/like             ← in flight
  → Server: 403 Forbidden                        ← rejected
  → UI: revert to unliked (count -1, icon empty) ← rollback
  → UI: show error toast

Rollback requires that you save the previous state before applying the optimistic change. Without a snapshot of "before," you can't undo.

Manual implementation

The core pattern in React is: capture the old state, apply the new state, fire the request, rollback on failure.

With a normalized store, posts live in a flat lookup table keyed by ID. Updating a single post is an O(1) write instead of an O(n) array map, and only the affected post re-renders.

// Normalized store shape:
// { entities: { posts: { 42: { id: 42, liked: false, likeCount: 10 }, ... } } }

async function toggleLike(postId) {
  // 1. Snapshot the single entity
  const previousPost = store.entities.posts[postId];

  // 2. Apply the optimistic change immediately
  dispatch({
    type: 'posts/update',
    payload: {
      id: postId,
      liked: !previousPost.liked,
      likeCount: previousPost.liked ? previousPost.likeCount - 1 : previousPost.likeCount + 1,
    },
  });

  try {
    await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
  } catch {
    // 3. Rollback the single entity
    dispatch({ type: 'posts/update', payload: previousPost });
    showToast('Something went wrong. Try again.');
  }
}

The snapshot is just one entity object, not the entire list. Rollback restores that one entity without touching anything else in the store.

With TanStack Query

TanStack Query has first-class support for optimistic updates via onMutate, onError, and onSettled:

With a normalized store, each post has its own query key. The snapshot is a single entity, and cancelQueries targets only that one entry rather than the entire list.

// Cache shape per post: ['posts', postId] → { id, liked, likeCount, ... }

const likeMutation = useMutation({
  mutationFn: (postId) =>
    fetch(`/api/posts/${postId}/like`, { method: 'POST' }).then(r => r.json()),

  onMutate: async (postId) => {
    await queryClient.cancelQueries({ queryKey: ['posts', postId] });

    // Snapshot the single entity
    const previousPost = queryClient.getQueryData(['posts', postId]);

    // Apply the optimistic update to just this entity
    queryClient.setQueryData(['posts', postId], (old) => ({
      ...old,
      liked: !old.liked,
      likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
    }));

    return { previousPost };
  },

  onError: (err, postId, context) => {
    queryClient.setQueryData(['posts', postId], context.previousPost);
    showToast('Failed to update. Changes reverted.');
  },

  onSettled: (_, __, postId) => {
    queryClient.invalidateQueries({ queryKey: ['posts', postId] });
  },
});

onMutate fires synchronously before the network request. onError receives the context returned by onMutate, which carries the snapshot. onSettled runs either way and triggers a background refetch to reconcile the server's true state.

The cancelQueries call is important: without it, an in-flight background refetch could land after your optimistic update and overwrite it, reverting the UI mid-interaction before the server even responds to your mutation.

Temp IDs for new items

Creating a new item requires an ID before the server assigns one. Use a temporary ID, then replace it when the server responds.

// Cache shape: { ids: ['1', '2'], entities: { '1': {...}, '2': {...} } }

onMutate: async (newPost) => {
  await queryClient.cancelQueries({ queryKey: ['posts'] });
  const previousData = queryClient.getQueryData(['posts']);

  const tempId = `temp-${Date.now()}`;
  queryClient.setQueryData(['posts'], (old) => ({
    ids: [tempId, ...old.ids],
    entities: {
      ...old.entities,
      [tempId]: { ...newPost, id: tempId, pending: true },
    },
  }));

  return { previousData, tempId };
},

onSuccess: (serverPost, _, context) => {
  // Swap the temp entry for the real one
  queryClient.setQueryData(['posts'], (old) => {
    const { [context.tempId]: _, ...rest } = old.entities;
    return {
      ids: old.ids.map(id => id === context.tempId ? serverPost.id : id),
      entities: { ...rest, [serverPost.id]: serverPost },
    };
  });
},

onError: (err, _, context) => {
  queryClient.setQueryData(['posts'], context.previousData);
},

The pending: true flag lets you style the item differently while it's in flight — a subtle opacity or a spinner — so users know the item isn't confirmed yet.

Deletion

Deletion is the simplest optimistic update: remove the item immediately, restore it on failure.

onMutate: async (postId) => {
  await queryClient.cancelQueries({ queryKey: ['posts'] });
  const previousData = queryClient.getQueryData(['posts']);

  queryClient.setQueryData(['posts'], (old) => {
    const { [postId]: _, ...remainingEntities } = old.entities;
    return {
      ids: old.ids.filter(id => id !== postId),
      entities: remainingEntities,
    };
  });

  return { previousData };
},

onError: (err, _, context) => {
  queryClient.setQueryData(['posts'], context.previousData);
},

Race conditions

Multiple rapid interactions can produce conflicting in-flight requests. A user who double-taps a like button quickly sends two requests: one to like, one to unlike. Both return asynchronously in unpredictable order.

Option 1 — Debounce the mutation. Wait until the user stops tapping before sending a request. Only the final intended state is sent.

const debouncedLike = useDebouncedCallback((postId) => {
  likeMutation.mutate(postId);
}, 300);

Option 2 — Cancel pending requests. Use an AbortController to cancel the previous request before sending a new one. The UI always reflects the latest tap, and only one network round-trip completes.

const abortRef = useRef(null);

async function toggleLike(postId) {
  abortRef.current?.abort();
  abortRef.current = new AbortController();

  setLiked(prev => !prev);

  try {
    await fetch(`/api/posts/${postId}/like`, {
      method: 'POST',
      signal: abortRef.current.signal,
    });
  } catch (err) {
    if (err.name !== 'AbortError') rollback();
  }
}

Option 3 — Idempotent server design. Make the server tolerate duplicate or out-of-order requests gracefully. A PUT /api/posts/42/like with a body of { liked: true } always produces the same result regardless of how many times it's called. Out-of-order responses don't create conflicting state because each request is absolute, not relative.

When not to use optimistic updates

Optimistic updates are appropriate when the failure rate is low and the cost of a brief incorrect state is acceptable. They are a bad fit when:

  • The server performs validation the client can't replicate. A form submission that can fail for a dozen business logic reasons should wait for the server. A wrong optimistic state misleads the user.
  • The action has real-world side effects. Sending a payment, booking a reservation, or sending a message optimistically and then silently rolling back is disorienting — the user may have already acted on the assumption of success.
  • Failure is common. If the network is unreliable or the endpoint has a high error rate, users will frequently see rollbacks. At that point, the optimistic UI creates more confusion than latency would have.
  • Ordering matters and can't be determined locally. Comments with server-assigned sequence numbers, ranked feeds, anything where the server's ordering differs from the client's — optimistic insertion may show items in the wrong position until the refetch corrects it.

The sweet spot: toggling state (like, bookmark, follow, mute), updating fields the user just set (name, bio, setting), and deleting items the user explicitly chose to remove.

The full lifecycle

onMutate:
  cancelQueries         ← stop background refetches from clobbering
  snapshot = getData()  ← save rollback point
  setData(optimistic)   ← apply immediately
  return { snapshot }

onError:
  setData(snapshot)     ← revert
  showToast(error)      ← inform the user

onSuccess:
  (optional) merge server response into cache

onSettled:
  invalidateQueries     ← sync with server truth

The snapshot-on-mutate, rollback-on-error pattern is the complete primitive. Everything else — temp IDs, debouncing, pending indicators — is layered on top.