I wrote a while back about revalidatePath vs revalidateTag in Next.js cache and left a loose thread there on purpose: when a Server Action runs and invalidates the server cache, the client UI — if you're running TanStack Query alongside it — doesn't hear about any of it until the next fetch. There's a window, short but real, where the user is staring at old data while the server already has the new one. That window is today's problem, and it's the piece I skipped last time.
My thesis: revalidateTag and setQueryData aren't competing for the same job, and treating them as interchangeable is where most of this pain comes from. revalidateTag fixes the server's memory of the data. setQueryData fixes what the browser is showing right now. If you only reach for one of them because it's the one that shows up first in the docs you read, you'll ship a UI that's correct eventually and annoying in the meantime.
The concrete problem: two caches that don't talk to each other
Combine Server Actions with TanStack Query on the client and you've got two cache systems running in parallel with zero automatic coupling:
- Next.js's cache (
fetchcache, Data Cache, Router Cache) — managed byrevalidatePathorrevalidateTag. - TanStack Query's cache on the client — lives in browser memory, with its own
queryKeyand its ownstaleTime.
A Server Action can invalidate the first and leave the second completely untouched. The server already has the updated row, but the component reading it with useQuery keeps showing whatever the last fetch brought back until something triggers a refetch — a window focus, an interval, a navigation. That "until something triggers" is a disguised double fetch: first the Server Action does its job, then — late, on its own schedule — the client query catches up.
What the official docs say (and what they leave out)
TanStack Query's Optimistic Updates guide lays out two paths for updating the UI before the server confirms: use useMutation's onMutate to write directly to the cache with setQueryData, or manage UI state variables without touching the cache at all. The docs are clear on something a lot of people skip anyway: if you write in onMutate, you have to save the previous snapshot with getQueryData and return it in the context so you can roll back on onError. That's not a nice-to-have, it's the contract.
What the docs don't say — because it's not their scope — is how any of this plays with Next.js Server Actions. TanStack Query assumes the mutation is an API call you control from the client through mutationFn. A Server Action isn't that: it's a function that runs on the server and gets invoked as if it were local. The bridge between those two worlds is something you build by hand, and nobody hands you a diagram for it.
// mutation hook that wraps a Server Action
const queryClient = useQueryClient()
const { mutate } = useMutation({
mutationFn: updateProfile, // Server Action
onMutate: async (newProfile) => {
await queryClient.cancelQueries({ queryKey: ['profile'] })
const previous = queryClient.getQueryData(['profile'])
queryClient.setQueryData(['profile'], newProfile) // optimistic update
return { previous }
},
onError: (_err, _vars, context) => {
queryClient.setQueryData(['profile'], context?.previous) // rollback
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] })
},
})The onSettled with invalidateQueries is the safety net: no matter what happens, it ends up syncing with whatever the server actually returns. The setQueryData in onMutate is the part that buys you the feeling of instantaneousness — nothing more, nothing less.
Where people get it wrong
The recipe I keep seeing repeated — in forums, in example repos, in Next.js's own App Router scaffolding — is trusting everything to revalidateTag inside the Server Action and assuming "since the server already revalidated, the client will just find out on its own." That works fine if the component showing the data does a direct fetch inside a Server Component and the user navigates or refreshes. It stops working the moment that same data also lives in a TanStack query inside a Client Component, because revalidateTag has zero notion that there's a queryClient sitting in the browser waiting for news.
The hidden cost is the feeling of lag: the user clicks "save," watches the spinner disappear, and the value on screen takes one or two seconds — or until the next window focus — to catch up. It's not a bug that breaks anything. It's a perception friction, and it's exactly the kind of thing users describe as "sometimes it takes a while to save" without being able to point at why.
The counterexample worth keeping around: if the data you're updating is not read afterward with useQuery on the client — say, a Server Action that just fires a side effect and the page re-renders server-side on the next navigation — then setQueryData doesn't add anything. There, revalidatePath or revalidateTag alone are enough, and dragging TanStack Query into it is complexity with no payoff.
sequenceDiagram
participant U as User
participant C as Client (TanStack Query)
participant S as Server Action
U->>C: Click save
C->>C: setQueryData (optimistic)
C->>S: invoke Server Action
S->>S: revalidateTag / DB mutation
S-->>C: response (success or error)
alt error
C->>C: rollback with previous snapshot
else success
C->>C: invalidateQueries (onSettled)
endDecision checklist
| Situation | What to check first | Decision |
|---|---|---|
Data is read with useQuery in a Client Component and the user needs immediate feedback | Is there a real risk of the mutation failing often? | setQueryData in onMutate + rollback in onError |
| Data is only shown in Server Components and the page re-renders on the next navigation | Is there any useQuery reading that same queryKey? | revalidateTag/revalidatePath alone, no TanStack |
| The mutation touches data other users also see (collaborative) | Could optimism show a state that never existed for the server? | Prefer invalidateQueries without optimism, accept the delay |
| The form is high-frequency (autosave, likes, counters) | Is a visible rollback's cost acceptable? | setQueryData is nearly mandatory so it doesn't feel stuck |
| You're debugging why "sometimes it doesn't update" | Is onSettled with invalidateQueries missing? | Always add it, it's the safety net |
This isn't a table of universal truths, it's a starting point for deciding with judgment based on how critical it is that the optimistic data actually match reality.
Limits of this
I don't have my own perceived-latency metrics or an A/B experiment comparing "with setQueryData" against "without it" on a real production case, and I'm not going to fake having one. What I can back up is what the official docs describe as the pattern's contract: snapshot, update, conditional rollback, final invalidation. If you need to quantify the real impact on user experience, that requires interaction logging or testing with actual users, not a blog post.
It's not a free pattern either. Every optimistic setQueryData is a promise your code makes to the UI about how the state is going to end up, and if that promise fails often — because the Server Action rejects the mutation on business validation, not a network hiccup — the rollback becomes visible and annoying. In mutations with a high rejection rate, optimism generates more visual noise than it saves. That's the trade-off I'm not willing to pretend doesn't exist just to make the pattern sound universally good.
When to use it and when not
My take after sitting with this: use setQueryData when the data lives on the client via useQuery and perceived latency matters more than momentary accuracy. Don't use it when the data is shared between users or when a visible rollback would be worse than a small delay. And always, no exceptions, close the loop with invalidateQueries in onSettled — optimism without a safety net is just a bug waiting for the wrong moment to show up.
If you've read about how Cline puts limits on autonomous mode you'll recognize the same logic here: automating without control is a promise that gets paid for eventually, one way or another. The next practical step, if you're using this pattern, is instrumenting how many times the rollback actually fires in development — not production yet, just to get a real number on the mutation's failure rate before deciding whether optimism earns its place in that specific case.
FAQ
Does setQueryData replace revalidateTag?
No. They solve different things: revalidateTag invalidates the server's cache (Next.js), setQueryData updates the client's cache (TanStack Query). In a flow with both systems, you probably need both.
What happens if I don't roll back in onError? The client's cache ends up holding data that never existed on the server. The next refetch self-corrects it, but in the meantime the user is looking at false information.
Can I use this with React's useOptimistic instead of TanStack Query?
Yes, they're different tools for similar needs. useOptimistic lives in the component and has no notion of a cache shared across queries; setQueryData does, because it operates on the global queryClient.
Does this add latency or reduce it? It doesn't change the mutation's real latency. It changes the perceived latency: the UI reacts before the server confirms.
Does it work for data coming from a Server Component without useQuery?
No. If there's no TanStack Query query reading that queryKey on the client, setQueryData has nothing to update.
Do I still need to invalidate if the optimism already showed the correct data?
Yes. The invalidateQueries in onSettled isn't redundant: it's what guarantees that if the server returned something different from what you assumed, the client corrects itself.
Original source: https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates</content> <parameter name="excerpt">The Server Action resolves, the toast says "saved," and the UI still shows the old data for a beat. That beat is the gap setQueryData closes without waiting for the full server roundtrip — and it's not the same fix as revalidateTag, even though people keep treating it that way.</parameter> <parameter name="metaTitle">TanStack Query + Server Actions: invalidation without</parameter> <parameter name="metaDescription">setQueryData invalidation pattern after Server Actions in Next.js: when to use it over revalidateTag, a checklist, and the limits per TanStack Query docs.
Related Articles
fp-ts Alternatives in TypeScript: When the Abstraction Is Worth It
After showing how a native union type replaces Either and Option in most cases, now comes the uncomfortable part: saying where fp-ts actually wins. It's not everywhere, and that's the point.
Sep 08 2026 · 7′ · Tutorials · TypeScript · node.js
revalidatePath is brute force, revalidateTag is precision
I confused revalidatePath with revalidateTag on a small project and ended up invalidating pages that had nothing to do with the change. Here's the real difference between the two cache granularities in Next.js 16, with a decision checklist.
Sep 04 2026 · 7′ · Tutorials · Next.js · React
pnpm wins in monorepos, npm wins on zero friction
The difference between npm and pnpm isn't install speed. It's a different storage model — content-addressable store vs a folder tree — and that model matters based on project size, not on what's trendy.
Aug 29 2026 · 8′ · Tutorials · Next.js · pnpm
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.
No comments yet. Be the first — your take matters most when we're few.