Optimistic UI Updates: Making Apps Feel Instant Without Breaking Things


Nothing makes an app feel sluggish like waiting for server responses before updating the UI. Optimistic updates solve this—you update the interface immediately, then reconcile with the server response later. When done right, it makes apps feel instant. When done wrong, it creates confusing states and lost data. Here’s how to get it right.

The Basic Principle

Traditional flow: user clicks “like” → send request → wait for server → update UI. The UI feels laggy because there’s perceptible delay.

Optimistic flow: user clicks “like” → update UI immediately → send request → if server confirms, great; if server rejects, roll back.

The user sees instant feedback. The app feels responsive. As long as the server usually agrees with your optimistic update, the user never sees the rollback. This creates the illusion of a faster app, even though network latency hasn’t changed.

Simple Example: Liking a Post

async function toggleLike(postId: string, currentlyLiked: boolean) {
  // Optimistic update
  setLiked(!currentlyLiked);
  setLikeCount(count => currentlyLiked ? count - 1 : count + 1);

  try {
    const result = await api.toggleLike(postId);
    // Server confirmed, we're good
  } catch (error) {
    // Rollback
    setLiked(currentlyLiked);
    setLikeCount(count => currentlyLiked ? count + 1 : count - 1);
    showError('Failed to update like');
  }
}

This works for simple cases. The instant UI update feels great. If the server rejects (network failure, permissions issue), you roll back and show an error.

The Complexity Appears With Multiple Changes

Things get trickier when users can make multiple changes before server responses arrive. Say someone likes three posts in quick succession. Three optimistic updates happen. Then the network responses arrive out of order. Now what?

You need to track pending operations and their rollback state. A simple approach:

const pendingUpdates = new Map<string, () => void>();

async function optimisticUpdate<T>(
  id: string,
  optimisticFn: () => void,
  rollbackFn: () => void,
  serverFn: () => Promise<T>
): Promise<T> {
  // Apply optimistic update
  optimisticFn();
  pendingUpdates.set(id, rollbackFn);

  try {
    const result = await serverFn();
    pendingUpdates.delete(id);
    return result;
  } catch (error) {
    // Rollback
    rollbackFn();
    pendingUpdates.delete(id);
    throw error;
  }
}

Now each update is tracked independently. If user clicks three likes and one fails, only that one rolls back. The others stay optimistically applied until their server responses confirm or reject.

Handling Conflicts

What if the server returns different data than your optimistic update predicted? Say you optimistically increment a counter to 15, but the server returns 14 because another user decremented it.

Don’t just keep your optimistic value—trust the server. The server is the source of truth:

async function incrementCounter(id: string) {
  const oldCount = getCount(id);
  setCount(id, oldCount + 1); // Optimistic

  try {
    const { newCount } = await api.increment(id);
    setCount(id, newCount); // Trust server response
  } catch (error) {
    setCount(id, oldCount); // Rollback on error
  }
}

The UI might show 15 briefly, then jump to 14 when the server responds. That’s acceptable—the brief flicker is better than showing stale data.

Queueing Dependent Operations

Some operations depend on others completing. You can’t optimistically delete a post, then optimistically edit it—the edit has no target. You need to queue operations and handle dependencies:

async function deletePost(postId: string) {
  // Remove from UI immediately
  removePostFromUI(postId);

  try {
    await api.deletePost(postId);
  } catch (error) {
    // Rollback
    addPostBackToUI(postId);
    throw error;
  }
}

If the user tries to edit while delete is pending, you either block the edit or queue it behind the delete. Blocking is simpler—disable the edit button until delete completes.

Using React Query for Optimistic Updates

React Query (now TanStack Query) has built-in optimistic update support that handles much of the complexity:

const mutation = useMutation({
  mutationFn: (newPost: Post) => api.createPost(newPost),
  onMutate: async (newPost) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['posts'] });

    // Snapshot previous value
    const previousPosts = queryClient.getQueryData(['posts']);

    // Optimistically update
    queryClient.setQueryData(['posts'], (old: Post[]) =>
      [...old, newPost]
    );

    // Return context for rollback
    return { previousPosts };
  },
  onError: (err, newPost, context) => {
    // Rollback to previous value
    queryClient.setQueryData(['posts'], context.previousPosts);
  },
  onSettled: () => {
    // Refresh from server
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  }
});

The library handles canceling in-flight requests, rolling back on error, and refreshing from the server after mutations. You just provide the optimistic update and rollback logic.

When Optimistic Updates Make Sense

Use optimistic updates for:

  • High-success-rate operations (likes, follows, simple edits)
  • Operations where user intent is clear
  • Actions where immediate feedback improves UX significantly

Don’t use optimistic updates for:

  • Payment processing
  • Destructive operations without undo (permanent deletes)
  • Operations with complex validation that might fail
  • Anything where showing wrong state briefly is worse than waiting

Error Handling That Doesn’t Confuse Users

When an optimistic update fails, tell the user what happened and what state they’re in:

catch (error) {
  // Rollback
  setLiked(originalState);

  // Clear, specific message
  toast.error(
    'Could not save your like. Please try again.',
    { action: { label: 'Retry', onClick: () => toggleLike() } }
  );
}

Don’t just silently roll back—users will think their action worked, then be confused when the state reverts. Make the error and rollback explicit.

Testing Optimistic Updates

Test rollback paths specifically. In development, they rarely trigger because your local API works reliably. In production, network failures happen, and rollback logic needs to work correctly:

// Test that rollback happens on failure
it('rolls back on server error', async () => {
  const { result } = renderHook(() => useLikePost());

  server.use(
    http.post('/api/likes', () => HttpResponse.error())
  );

  await result.current.mutate(postId);

  expect(result.current.isLiked).toBe(false); // Rolled back
  expect(screen.getByText(/failed/i)).toBeInTheDocument();
});

Mock failed API responses and verify that state rolls back correctly and users see appropriate error messages.

The Performance Win

Optimistic updates make apps feel dramatically faster. Users don’t notice 200ms network latency when the UI responds instantly. The psychological improvement is enormous—the app feels snappy and responsive.

Just don’t optimize prematurely. Start with standard request-response flow. Add optimistic updates where UX genuinely benefits, not everywhere. The added complexity is only worthwhile for high-frequency interactions where instant feedback matters.

When done thoughtfully, optimistic updates are one of the most effective ways to make web apps feel as responsive as native apps. The implementation complexity pays for itself in user experience improvement.