← Back to Blogs

Implementing Optimistic UI and Prefetching Strategies for Instant Page Transitions

Tue May 26 2026

Hey, I'm Prashuk. I spend most of my time building with the MERN stack and Next.js, and if there’s one thing I’ve learned, it’s that users don’t care how "clean" your backend architecture is if the UI feels sluggish.

I’m obsessed with perceived performance. Even if an API takes 500ms to respond, the app should feel like it’s reacting instantly.

In this post, I want to talk about how I use Optimistic UI and Prefetching to make transitions feel seamless.

1. The Death of the Loading Spinner

Let’s be honest: loading spinners are boring.

I ran into this issue when I was building a task management dashboard for a client. Every time a user clicked "Complete Task," I’d show a small spinner, wait for the database to update, and then refresh the list.

Technically, it worked. But it felt clunky.

The user knows they clicked the button. Why make them wait for a server in Ohio to tell them, "Yeah, I got it"? That’s where Optimistic UI comes in. You just assume the request will succeed, update the UI immediately, and only roll back if things actually break.

2. Implementation: The "Fake It Till You Make It" Strategy

What worked for me was using TanStack Query (React Query). It has a built-in way to handle this without making the code a mess.

Instead of waiting for the onSuccess callback, you use onMutate. You manually update your local cache to show the new state. If the server throws an error (maybe the user lost their connection), you use the onError hook to revert to the previous state.

It makes the app feel 10x faster because the "action" happens the millisecond the user clicks.

3. Predicting the Future with Prefetching

Optimistic UI handles the actions, but what about navigation?

In one of my projects—a multi-page data table—users were constantly clicking into "Details" pages. Even with a fast Next.js setup, there was that tiny 200ms flicker while the data fetched.

I started implementing Prefetching. If a user hovers over a link or a row, I trigger a fetch for that page's data in the background. By the time they actually click, the data is already in the cache. The page transition becomes instant.

4. A Practical Code Example

Here’s how I usually handle an optimistic "Like" or "Toggle" feature using React Query. It’s clean and avoids that awkward UI jump.

const queryClient = useQueryClient();

const mutation = useMutation({
  mutationFn: updateTaskStatus,
  // This is the magic part
  onMutate: async (newTask) => {
    // Cancel any outgoing refetches so they don't overwrite our optimistic update
    await queryClient.cancelQueries({ queryKey: ['tasks'] });

    // Snapshot the previous value
    const previousTasks = queryClient.getQueryData(['tasks']);

    // Optimistically update to the new value
    queryClient.setQueryData(['tasks'], (old) => 
      old.map(t => t.id === newTask.id ? { ...t, completed: true } : t)
    );

    // Return a context object with the snapshotted value
    return { previousTasks };
  },
  onError: (err, newTask, context) => {
    // If it fails, roll back to the old data
    queryClient.setQueryData(['tasks'], context.previousTasks);
    toast.error("Something went wrong!");
  },
  onSettled: () => {
    // Always refetch after error or success to keep things synced
    queryClient.invalidateQueries({ queryKey: ['tasks'] });
  },
});

5. My Best Practices

After breaking a few UIs and dealing with "out-of-sync" data, here’s my personal checklist:

  • Don't over-prefetch: Only prefetch on hover or when a user is likely to click. Don't kill the user's data plan by prefetching 50 different pages they’ll never visit.
  • Always handle errors: Optimistic UI is a lie you tell the user. If that lie becomes reality (server error), you must have a clean way to revert.
  • Visual feedback is still key: Even if the UI updates instantly, I like to add a tiny subtle progress bar or a slight color change to show "Syncing..." in the background. It builds trust.
  • Keep logic in hooks: Don't bloat your components with mutation logic. I usually wrap these in custom hooks like useToggleTask.

Wrapping Up

At the end of the day, users equate "fast" with "good."

You can spend weeks optimizing your SQL queries to save 50ms, or you can spend 10 minutes implementing a prefetch strategy that saves the user a perceived 500ms. I know which one I'm choosing every time.

What’s your take? Do you prefer showing a global loader, or do you go the optimistic route? Let me know!