Keeping TanStack Query v5 Cache Consistency Intact — Invalidation Boundaries, Optimistic Updates, and SSR Traps, Worked Through with Antigravity
A like that snaps back a moment after you tap it; a stale value that lingers when you return from another tab. This walks through the three places TanStack Query v5 cache consistency breaks, with working code for invalidation boundaries, onMutate rollback, and per-request QueryClient isolation.
You tap "like," the heart turns red, and a fraction of a second later it slides back to grey. The server returned 200, yet the screen is lying. The first time I saw this in a personal app, I reread the mutation code several times. It looked exactly like the textbook version, but the display kept reverting now and then. The culprit was neither onError nor mutationFn — it was a separate fetch that had started just before the mutation.
Most TanStack Query v5 (formerly React Query) defects take this shape: they look like isolated bugs but are really cache-consistency design problems. The library itself is well made, and each individual API is straightforward. The moment several queries and mutations begin sharing one cache, though, you suddenly need to decide how consistency is preserved. Antigravity's AI agent will write the hook boilerplate in seconds, but this one question — where consistency is guaranteed — has to stay in the hands of whoever reads the generated code, or it falls apart.
As an indie developer, here I will sort the inconsistencies I actually hit during my own development by the place they occur and close them one at a time. Rather than lining up code fragments, sharing "why it breaks there" first tends to help tomorrow's code more.
The cache usually breaks in only three places
After running this for a few years, what I noticed is that TanStack Query inconsistencies concentrate in roughly three spots. First, the spot where the invalidation boundary after a mutation is too wide or too narrow. Second, the spot where the optimistic update's rollback path is missing. Third, the spot where SSR shares a QueryClient and one request's data bleeds into another.
Put differently: when you meet a new bug, asking "which of the three is this?" narrows the search dramatically. The reverting-heart example above looks like the second, but it was actually a collision between invalidation and the optimistic update — a problem sitting on the line between the first and second. Let's go in order.
First, the v5 baseline — bake isPending and signal into the template
Before the consistency discussion, just two foundations from v5. Unlike v4, status: 'loading' was renamed to pending, and isPending (cache empty, still loading) and isFetching (refetching but the old data is still shown) are now clearly separated. Blur this distinction and you get a flickering UI that flashes a skeleton on every refetch.
import { useQuery } from "@tanstack/react-query";export function useUserProfile(userId: string) { return useQuery({ queryKey: ["users", userId, "profile"], // always receive signal and pass it to fetch — tied to aborting on unmount queryFn: ({ signal }) => fetchUserProfile(userId, { signal }), staleTime: 60_000, gcTime: 5 * 60_000, enabled: Boolean(userId), });}async function fetchUserProfile(userId: string, { signal }: { signal: AbortSignal }) { const res = await fetch(`/api/users/${userId}`, { signal }); if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`); return (await res.json()) as UserProfile;}
Receive signal as an argument to queryFn and pass it to fetch. This was possible in v4, but in v5 it became the standard with "no reason to skip it." Ask Antigravity to "write a user-fetch hook" and you'll sometimes get a template that drops signal. I keep this on the checklist I run before merging generated code. Whether you can stop a request on unmount matters more to server load the more navigation-heavy the app is.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A diagnostic lens that isolates the three places cache inconsistency is born: invalidation boundaries, optimistic rollback, and SSR client sharing
✦Templating the five steps (cancelQueries to snapshot to setQueryData to onSettled) so revert bugs disappear structurally
✦Why QueryClient must be created per request, and how a singleton becomes a personal-data leak
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Place 1: invalidation boundary — express "how far it reaches" through query keys
Invalidate too widely and refetches fire on unrelated screens. Too narrowly and the display never changes after an update. The only way to make this boundary readable is to layer query keys and route everything through a factory rather than scattering string literals.
// queryKeys.ts — resource -> scope -> identifier -> subresource, four layersexport const queryKeys = { users: { all: ["users"] as const, lists: () => [...queryKeys.users.all, "list"] as const, list: (filters: UserFilters) => [...queryKeys.users.lists(), filters] as const, detail: (id: string) => [...queryKeys.users.all, "detail", id] as const, profile: (id: string) => [...queryKeys.users.detail(id), "profile"] as const, posts: (id: string) => [...queryKeys.users.detail(id), "posts"] as const, },} as const;
With this factory, the intent of invalidation survives as code. After a profile update, writing queryClient.invalidateQueries({ queryKey: queryKeys.users.detail(id) }) invalidates the profile and posts hanging beneath it together. If you only want to change a list's filter, scope it under queryKeys.users.lists(). The advantage is that the boundary becomes visible as "key depth."
What pays off in practice is that this convention propagates to AI generation too. Use the queryKeys.users.detail(id) pattern two or three times in the project, and Antigravity's agent references the existing code and starts matching this shape on its own. Which is exactly why writing the first few hooks carefully by hand is worth it. Don't forget to apply as const at every level; drop it and the key type received in callbacks loosens, breaking silently during refactors.
One more judgment call: don't substitute removeQueries for invalidation. removeQueries drops the cache entirely, so the old data still on screen blanks out in an instant and the skeleton reappears. invalidateQueries keeps the old data visible while refetching behind it, so the seam never shows. "Remove" and "invalidate" are different things; holding that distinction sharpens your boundary design.
Place 2: optimistic updates — template the five steps so rollback is guarded by structure
The reverting-heart problem lived here. An optimistic update rewrites the cache as if the mutation succeeded, without waiting for the response. The perceived speed jumps, but drop even one rollback step and you get "the display changed even though it didn't succeed" or "it doesn't revert on failure."
I've decided to always write optimistic updates in these five steps: stop in-flight fetches with cancelQueries, take a snapshot, update the cache ahead, restore on failure, and finally sync regardless of outcome. The order has meaning.
The culprit behind the revert was the absence of (1). Without cancelQueries, a useQuery fetch that had started just before the mutation overwrites the cache slightly later with "the old server response." The value you just updated ahead gets repainted with that old one and snaps back. The server returns a correct 200, yet only the display lies — because of this collision. After hitting it twice, I turned my optimistic-update snippet into a fixed template that begins at (1).
Many people wonder whether (5) can be replaced by a success-path setQueryData. But to pull the server's normalized values into your hands — the assigned id, the final related counts — you ultimately need a fetch. The principle here is: don't try to finish with local state alone. When Antigravity writes optimistic updates, it tends to drop either (1) or (5), so after generation I check for those two steps first.
Place 3: SSR hydration — create the QueryClient per request
When combined with the Next.js App Router, this is the quietest and most dangerous place. Bridging server-component prefetch results to the client through HydrationBoundary is the proper v5 approach.
// app/users/[id]/page.tsx — server componentimport { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";import { UserPageClient } from "./UserPageClient";import { queryKeys } from "@/lib/queryKeys";export default async function Page({ params }: { params: { id: string } }) { const qc = new QueryClient(); // <- always create a fresh one per request await qc.prefetchQuery({ queryKey: queryKeys.users.profile(params.id), queryFn: () => fetchUserProfileServer(params.id), }); return ( <HydrationBoundary state={dehydrate(qc)}> <UserPageClient userId={params.id} /> </HydrationBoundary> );}
Calling new QueryClient() inside the function is the crux. On the Next.js server, multiple users' requests are processed in the same process. Place a QueryClient as a module-top singleton and one user's profile lingers in the cache, bleeding into the next user's render. That is, another person's personal data appears on screen. This isn't a performance topic; it's a design accident that can become a real data leak in production. When Antigravity writes SSR integration, it sometimes proposes a singleton for convenience, so a human must stop it here.
One more point: keep fetchUserProfileServer on the server and fetchUserProfile on the client sharing only the return type, with separate implementations. The server may need to reconstruct an auth cookie or call an internal RPC directly; cram both into one function and you get a thicket of isServer branches. Share the type, split the implementation. That line makes later debugging far easier.
Split Suspense boundaries "narrow and many"
useSuspenseQuery, stabilized in v5, hands loading to the parent <Suspense> and failure to <ErrorBoundary>. Erasing the if (isPending) branch from components is a big win, but misplacing the boundary causes "the whole screen vanishes when one half loads" or "the error persists even after pressing recover."
Put one giant boundary at the top and the entire screen sinks into a skeleton when either the header or the posts wait. Cut it per child and the header stays visible while the posts appear later, with failure kept local. I adopt the convention that "a component that fetches data is responsible for wrapping itself in <Suspense>." Add a single line to the component-generation prompt — "do not expect a <Suspense> from the parent" — and the consistency of generated code visibly improves.
Guard the invalidation boundary through "operations" — injecting conventions into the AI
Even with a correct design, boundaries erode gradually as a team or an AI grows. The practical way to prevent this is to embed the convention in code and prompts, not in documentation. In my case I fix four points in the .antigravity/rules equivalent: query keys always go through the queryKeys factory; queryFn receives signal and passes it to fetch; mutations hold both cancelQueries and onSettled.invalidateQueries; components using useSuspenseQuery sit inside a Suspense boundary.
With these four in the AI's rules, generated code comes out conforming from the start, so review shifts from "hunting for convention violations" to "confirming design decisions." What you can leave to the AI is boilerplate and following existing patterns. What you must hold yourself is the design judgment: the invalidation boundary, the Suspense placement, the optimistic rollback path, and whether to propagate signal. Keep that line and you can raise generation speed while preserving consistency.
One next step
If you touch only one thing tomorrow, add a queryKeys factory in a single file and rewrite just one existing useQuery to go through it. That alone makes invalidation granularity visible as "key depth," and revert bugs like the opening one become answerable as "which of the three places." Consistency design begins quietly, from that first file.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.