Skip to content
Architecture Patterns — Part 18 of 30

Optimistic Updates: Instant UI for Slow APIs

Written by claude-sonnet-4 · Edited by claude-sonnet-4
optimistic-updatesTanStack QueryReact 19useOptimisticmutationsrollbackUI patternsarchitectureTanStack DBuser experience

Architecture Patterns — Part 18 of 30


The Like Button That Lied

We were building a social feed feature for a mid-size SaaS product — collaboration timelines, reactions, the whole thing. Our backend team had done a good job: the API was clean, the database was properly indexed, and response times were sitting at around 400–600ms under load. Not fast, but not embarrassing.

Then we shipped the like button.

Within 48 hours, the product team was in my inbox. "The like button feels broken." Users were rage-clicking. Some were reporting their likes "not registering." The button showed a spinner. Half a second of nothing. Then the count incremented by one. From a pure data standpoint, the feature worked perfectly. From a UX standpoint, it was dead on arrival.

This is a story I've told in some form dozens of times across 25 years of building production systems. The API is fast enough for engineers. It is never fast enough for users. The gap between those two realities is exactly where optimistic UI updates live.

This article is about making the right architectural choices around optimistic updates — not just how to implement them, but when to use them, where they break, and how to build rollback strategies that don't make failures worse than the original latency.


What Optimistic Updates Actually Are

An optimistic update is a deliberate lie you tell your UI.

When a user clicks "like," you immediately update the local state to show the like as confirmed — before the server has responded, before the database has written, before you have any proof the action succeeded. You're betting that the request will succeed, and you're showing the user the world as it should be rather than waiting to confirm it.

The name comes from the assumption baked into the pattern: you are optimistic about success. Most of the time, that optimism is justified. Network requests succeed vastly more often than they fail. The user's like will almost certainly land. So why make them wait to see it?

The philosophical contract is:

  1. Apply the change locally, immediately
  2. Send the mutation to the server in the background
  3. On success: Sync the confirmed server state (often a no-op — you were already showing it)
  4. On failure: Roll back to the pre-mutation state and surface the error

Simple to state. Non-trivial to build correctly.


The Decision Framework: Should You Go Optimistic?

Not every mutation deserves an optimistic update. Before you reach for useOptimistic or onMutate, run this decision tree:

1. Is the outcome predictable?

Optimistic updates work best when the client can accurately replicate what the server will do. Toggling a boolean (liked/not liked) is trivially predictable. Creating a new row with a server-generated UUID, cascading foreign key writes, and computed fields? Much less so.

Use optimistic updates for: Toggle states, counter increments, text field saves, reordering items, marking complete.

Be cautious with: Record creation where the server generates IDs, complex validations that may reject the write, multi-table transactions where partial failure is possible.

2. How bad is a failed rollback?

Rolling back a like is invisible to the user — the heart icon reverts, maybe a toast appears. Rolling back a "delete account" action is catastrophic if the user has already navigated away based on what they saw.

Optimistic: yes — Likes, comments, reactions, drag-and-drop reorder, toggle settings, cart quantity adjustments.

Optimistic: no — Deletions with cascading effects, financial transactions, anything that triggers downstream automations (emails sent, webhooks fired), account/permission changes.

3. What is the realistic failure rate?

If your API has a 99.9% success rate and a 500ms average response time, going optimistic is a clear win: almost all users get a snappy experience, and the rare failure is handled gracefully. If your API is flaky (legacy third-party, high timeout rate), optimistic updates can create a flickery nightmare — updates appear and then snap back constantly.

4. Can the user tolerate the deception?

A like button reverting after 600ms is annoying but forgivable. A message that appeared in a chat thread vanishing after the server rejected it is deeply confusing. Consider the context and severity of the data being mutated.


Implementing in TanStack Query: The Right Way

TanStack Query (formerly React Query) has been the dominant data-fetching library in React applications for years, and its mutation API has first-class support for optimistic updates. As of April 2025, TkDodo's blog published a deep technical dive on concurrent optimistic updates in React Query, surfacing race conditions that trip up even experienced teams.

Here's the standard pattern:

import { useMutation, useQueryClient } from '@tanstack/react-query';

function useToggleLike(postId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (liked: boolean) => api.posts.toggleLike(postId, liked),

    onMutate: async (newLikedState: boolean) => {
      // 1. Cancel in-flight refetches that could clobber our optimistic update
      await queryClient.cancelQueries({ queryKey: ['posts', postId] });

      // 2. Snapshot the current value so we can roll back
      const previousPost = queryClient.getQueryData(['posts', postId]);

      // 3. Apply the optimistic update
      queryClient.setQueryData(['posts', postId], (old: Post) => ({
        ...old,
        liked: newLikedState,
        likeCount: old.likeCount + (newLikedState ? 1 : -1),
      }));

      // 4. Return context for rollback
      return { previousPost };
    },

    onError: (_err, _newLikedState, context) => {
      // Roll back to the snapshot
      if (context?.previousPost) {
        queryClient.setQueryData(['posts', postId], context.previousPost);
      }
    },

    onSettled: () => {
      // Always refetch to sync with server truth
      queryClient.invalidateQueries({ queryKey: ['posts', postId] });
    },
  });
}

Three things to never skip:

cancelQueries in onMutate — This is the most commonly omitted step. Without it, a background refetch (e.g., from refetchOnWindowFocus) can complete while your optimistic update is in flight and overwrite your local state. The user sees the counter flicker back to the old value mid-interaction. Cancelling in-flight queries prevents this entirely.

The snapshot pattern — Always capture previous state before mutating. Your rollback is only as good as your snapshot.

invalidateQueries in onSettled — On success, this syncs any server-side side effects (e.g., the server deduped a double-like). On failure, the rollback via onError handles the revert; onSettled still fires and cleans up.

The Concurrent Mutations Problem

Here's a failure mode that bit several production teams in 2025. Imagine a user rapidly toggling a liked/unliked state — click, click, click. You have multiple mutations in flight. Each one's onSettled tries to invalidateQueries. The first invalidation triggers a refetch. The refetch completes. It returns server state from before the second or third mutation resolved. The UI snaps back to an older state.

The fix, as documented by TkDodo in April 2025, is to check whether other mutations are still running before invalidating:

onSettled: () => {
  // Only invalidate if no other mutations are running
  // (isMutating() === 1 means only *this* mutation is still counted)
  if (queryClient.isMutating({ mutationKey: ['toggleLike'] }) === 1) {
    queryClient.invalidateQueries({ queryKey: ['posts', postId] });
  }
},

The 1 check is intentional: when onSettled fires, your own mutation is still counted in the running mutations. So === 1 means "only me is running" — safe to invalidate. > 1 means another related mutation is still in flight — let it settle first.


React 19: useOptimistic

React 19, released in late 2024, shipped useOptimistic as a first-party hook. If you're on React 19 and not using a full data-fetching library, this is your native option.

import { useOptimistic, useState, startTransition } from 'react';

function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);

  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    likes,
    (currentLikes: number, delta: number) => currentLikes + delta
  );

  const handleLike = async () => {
    startTransition(async () => {
      // Apply optimistic change immediately within the transition
      addOptimisticLike(1);

      try {
        await api.posts.like(postId);
        setLikes((prev) => prev + 1); // Commit real state on success
      } catch (err) {
        // optimisticLikes auto-reverts to `likes` when the transition ends
        // Show a toast to the user
        toast.error('Could not like post. Please try again.');
      }
    });
  };

  return (
    <button onClick={handleLike}>
      ❤️ {optimisticLikes}
    </button>
  );
}

The useOptimistic hook takes two arguments: the real state value and an updater function. It returns the optimistic state (used for rendering) and a setter. Crucially, useOptimistic automatically reverts the optimistic state back to the real state when the async action that wraps it completes or errors — you don't manage the rollback yourself; React does it at the transition boundary.

This makes useOptimistic elegant for simple cases. Its limitation is that it's tied to React's transition model — it works beautifully within startTransition and server actions, but requires more wiring to integrate with external mutation libraries.

When to choose useOptimistic vs. TanStack Query mutations:

  • Use useOptimistic for local-only state that doesn't need query cache synchronization
  • Use TanStack Query's onMutate pattern when you have complex cache relationships, need fine-grained rollback, or are managing multiple queries that depend on the mutated data

TanStack DB: The Next Evolution (Beta, August 2025)

In August 2025, TanStack entered beta with TanStack DB — an embedded client-side database that addresses longstanding pain points with optimistic updates in TanStack Query. Community member Shayan put it bluntly in a Medium post cited in the InfoQ coverage: "Optimistic updates using just TanStack Query? Let's be honest, they kind of suck."

TanStack DB changes the model fundamentally. Instead of manually snapshotting, mutating the cache, and rolling back, you work with typed collections that handle all of this internally:

import { createCollection, createOptimisticAction } from '@tanstack/react-db';

// Define a collection with its mutation handler
const postCollection = createCollection({
  id: 'posts',
  onUpdate: async ({ transaction }) => {
    const mutation = transaction.mutations[0];
    await api.posts.update(mutation.original.id, mutation.changes);
    await postCollection.utils.refetch(); // sync confirmed state back
  },
});

// Intent-based optimistic action
const likePost = createOptimisticAction<string>({
  onMutate: (postId) => {
    postCollection.update(postId, (draft) => {
      draft.likeCount += 1;
      draft.likedByMe = true;
    });
  },
  mutationFn: async (postId) => {
    await api.posts.like(postId);
    await postCollection.utils.refetch();
  },
});

// Usage is dead simple
likePost(postId);

TanStack DB stores synced data and optimistic state separately, rebasing optimistic state on top of confirmed data. When the server write syncs back, the optimistic layer is discarded — not merged, not reconciled, simply dropped because the ground truth has arrived. Rollbacks are automatic; no context object to pass, no snapshot to manage.

The tradeoff: TanStack DB is still in beta and introduces a new mental model (collections, live queries) that requires buy-in from the whole team. For greenfield projects, it's worth evaluating seriously. For existing apps with heavy TanStack Query investment, the onMutate pattern is battle-tested and sufficient.


Rollback Strategies: The Three Failure Modes

Every optimistic update architecture needs answers to three failure scenarios:

Failure Mode 1: Immediate API Error (4xx/5xx)

This is the easy case. The mutation fails synchronously with an error response. Your onError handler fires, you restore the snapshot, and you show a toast: "Couldn't save — please try again."

The user experience question is how you show the rollback. Options in order of increasing assertiveness:

  • Silent revert: State snaps back, no message (bad — user doesn't know what happened)
  • Toast notification: Non-blocking message in corner of screen (good for low-stakes mutations)
  • Inline error + revert: Error message adjacent to the changed element (good for form fields, comments)
  • Blocking error dialog: Reserve for high-severity failures (payment failures, destructive actions)

Failure Mode 2: Network Timeout / Offline

This is trickier. You showed the optimistic state. The request never came back. Did it succeed? Fail? You don't know.

Options:

onError: (err, variables, context) => {
  if (err.name === 'AbortError' || err.message.includes('timeout')) {
    // Don't revert — show a "pending" indicator instead
    // Retry the mutation with exponential backoff
    queryClient.setQueryData(['posts', postId], (old: Post) => ({
      ...old,
      _pendingSync: true, // Render a "syncing" indicator in UI
    }));
  } else {
    // Definitive failure — roll back
    queryClient.setQueryData(['posts', postId], context.previousPost);
  }
},

For offline-tolerant apps, consider libraries like TanStack DB with ElectricSQL — the sync engine monitors the replication stream and discards optimistic state only when the write has confirmed via the backend, not just when the API call completes.

Failure Mode 3: Stale State After Concurrent Mutations

As described above: the user fires off multiple mutations faster than the server can respond. You get interleaved responses, and earlier responses overwrite later ones. The isMutating() check is your primary defense. Additionally, consider debouncing rapid-fire mutations:

// For real-time slider controls or auto-save inputs:
// Don't send every keystroke — debounce the mutation
const debouncedSave = useMemo(
  () => debounce((value: string) => mutation.mutate(value), 500),
  []
);

TanStack DB's usePacedMutations with debounceStrategy handles this natively, merging multiple rapid mutations into a single transaction before sending to the server.


The Patterns That Break Optimistic Updates

Avoiding these saves production incidents:

Pattern 1: Server-generated data you fake on the client

You insert an item optimistically with a client-generated temp ID. The server assigns a real ID. Now you have two items — the temp one and the real one — because your invalidation fetched the server list before you cleaned up the temp record.

Fix: Use a deterministic temporary ID scheme and explicitly remove temp records in onSuccess:

const tempId = `temp-${Date.now()}`;
// ... optimistic insert with tempId ...
onSuccess: (serverItem) => {
  queryClient.setQueryData(['items'], (old: Item[]) =>
    old.map((item) => item.id === tempId ? serverItem : item)
  );
},

Pattern 2: Forgetting query cancellation on refetchOnWindowFocus

User switches tabs during a mutation. refetchOnWindowFocus fires when they return. The fresh data overwrites the optimistic state. The solution is always await queryClient.cancelQueries(...) in onMutate.

Pattern 3: Optimistically updating read-only derived data

You optimistically update a likeCount field, but your UI also has a topLikedPosts query that sorts by like count. The list doesn't reorder because you only updated the individual post cache. Either update all affected queries optimistically (complex, brittle) or accept that derived/sorted views won't update until the next onSettled refetch.


Architecture Checklist: Optimistic Updates

Before shipping any feature with optimistic mutations, verify:

  • Decision gate passed: Mutation is predictable, reversible, and low-stakes on failure
  • cancelQueries in onMutate: All queries that could overwrite the optimistic state are cancelled
  • Snapshot captured: Previous state is stored in the onMutate context return value
  • onError rollback: Context snapshot is restored on any API failure
  • onSettled invalidation: Queries are refreshed after mutation completes (success or failure)
  • Concurrent mutation guard: isMutating() check prevents premature invalidation under rapid-fire usage
  • Failure UX defined: Error states have appropriate feedback (toast, inline, or modal) — no silent failures
  • Network timeout handling: Distinction between definitive failure and ambiguous timeout
  • Derived/sorted views accounted for: Understand which cached queries are affected by the mutation
  • Temp ID cleanup: If inserting with client-generated IDs, server IDs are reconciled in onSuccess
  • Manual QA on slow network: Throttle to 3G in DevTools and verify rollback behavior is readable

Ask The Guild

Community Prompt:

What's the worst optimistic update failure you've shipped to production? Was it a rollback that made things worse than the original latency, a concurrency bug, or something else entirely? Drop your war story — and how you fixed it — in the Guild Discord. The best architectural lessons come from the times our optimism was wrong.


Tom Hundley is a software architect with 25 years of experience. He coaches teams building production systems at scale through the AI Coding Guild.

Copy A Prompt Next

Think in systems

If this article changed how you think about the problem, copy a prompt that turns that judgment into one safe, reviewable next step.

Matching public prompts

7

Keep the task scoped, copy the prompt, then inspect one reviewable diff before the agent continues.

Need the safest first move instead? Open the curated sample prompts before you browse the broader library.

Foundations for AI-Assisted BuildersFoundations for AI-Assisted Builders

Choosing Your Tech Stack — A Decision Framework

A practical framework for choosing the right tools and technologies for your project — with sensible defaults for AI-assisted builders.

Preview
"Recommend a tech stack for this project.
Project type: [describe it]
Constraints: [budget, hosting, mobile, data, auth, payments, privacy]
My experience level: [describe it]
Give me:
Architecture

Translate this architecture idea into system-level judgment

Architecture articles sharpen judgment. The system-design paths give you the layered context behind the tradeoffs so you can reuse the pattern instead of memorizing a slogan.

Best Next Path

Architecture and System Design

Guild Member · $29/mo

See the full system shape: boundaries, scaling choices, failure modes, and the tradeoffs that matter before complexity gets expensive.

20 lessonsIncluded with the full Guild Member library

Need the free route first?

Start with Start Here — Build Safely With AI if you want the workflow and vocabulary before you dive into the deeper path above.

T

About Tom Hundley

Tom Hundley writes for builders who need stronger technical judgment around AI-assisted software work. The Guild turns production experience into public articles, copy-paste prompts, and structured learning paths that help non-software developers supervise AI agents more safely.

Do this next

Leave this article with one concrete move. Copy the matching prompt, or start with the path that teaches the safest next skill in sequence.