Okay, confession. I switched a small project from SWR to TanStack Query in April, switched it back six weeks later, then switched it forward again at the end of May. Three migrations on the same codebase. Not proud.
What I am proud of is that I finally know why I kept flipping. It wasn’t bug-of-the-week or some new release. I was reading feature checklists. “TanStack has mutation queues. SWR has a smaller API.” Sure. None of that actually decided anything.
So this is the post I wish I had on day one. Not a matrix. A short answer to “for this specific app, which library will get in my way the least?” I’ll show the call I make every time now, with code on both sides, and the place the two libraries actually diverge.
Short version for the impatient: if you have mutations, optimistic UI, or anything touching a backend you control, reach for TanStack Query. If you’re mostly fetching read-only data from APIs you don’t own, SWR is still excellent and you’ll write less code. The reasoning is below.
The real difference is the cache model
Most comparison posts list features. I’ll start with the only thing that changes how you write code.
SWR’s cache is keyed by a string. You pass a string (or a tuple that becomes a string), SWR holds the latest response, and any component using the same key gets the same value. It’s a thin layer over fetch.
TanStack Query’s cache is keyed by an array, and each cache entry has a state machine attached: idle, pending, success, error, plus fetch counts, last-updated timestamps, error counts, and a mutation registry. You can subscribe to any of those.
That sounds like overhead. It is, until you need it. The first time I had to invalidate a list after a successful mutation, or roll back an optimistic update on a 4xx, the extra machinery paid for itself in one afternoon.
Here’s the difference made concrete. SWR:
import useSWR, { mutate } from 'swr'
function TodoList() {
const { data: todos } = useSWR('/api/todos', fetcher)
async function add(title: string) {
await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ title }),
})
mutate('/api/todos') // revalidate
}
return <List items={todos} onAdd={add} />
}
TanStack Query:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
function TodoList() {
const qc = useQueryClient()
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
const add = useMutation({
mutationFn: (title: string) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ title }),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['todos'] }),
})
return <List items={todos} onAdd={(title) => add.mutate(title)} />
}
For a single-mutation toy app, SWR wins on lines of code. For a real app with retries, optimistic UI, and a dozen mutation paths, TanStack’s structure wins on debuggability. The bet you’re making is “how many of these will my app need?”
When I reach for React Query
I reach for TanStack Query when the app meets any one of these.
A backend I control where data changes because of my own writes, not just polling. The moment users edit or delete things and need to see the result without a full refetch round-trip, the mutation plus cache invalidation flow earns its keep. I covered the optimistic UI pattern that pairs with this in my post on useOptimistic and the optimistic UI I stopped hand-rolling.
A multi-step user flow where one screen depends on data fetched on the previous one. TanStack’s queryClient.prefetchQuery and the React 19-friendly useSuspenseQuery let me move data loading up the tree without prop drilling.
Anything where I will need devtools. The TanStack devtools are not nice-to-have. The first production incident where you can see exact cache contents, refetch counts, and stale times in the panel, you stop asking why anyone puts up with the extra package size.
I wrote a longer take on this in my post on TanStack Query in 2026 and what I reach for instead of useEffect. The short of it: useEffect(() => fetch()) is a code smell now.
When SWR is still the right answer
I reach for SWR when:
The data is read-only and comes from APIs I don’t own. Public GitHub stars, weather endpoints, a CMS exposed over GraphQL. SWR’s smaller API and the revalidateOnFocus default give me most of what I want with no ceremony.
The site is mostly static with light interactivity. If I’m building marketing pages with a “latest blog posts” widget, SWR is faster to ship.
The team is allergic to indirection. SWR’s “your function returns data” model is easier to onboard a junior into. TanStack’s options object is a small mountain on day one.
The honest tradeoff: SWR’s mutate API works, but it shifts the work to you. Optimistic updates work, but you write the rollback. For one or two mutations that’s liberating. For ten, it stops being.
The Next.js angle, because everyone asks
This is where the conversation gets confusing because Next.js 15 has Server Components, Server Actions, and built-in fetch caching. The official line is “you might not need a client cache library at all.”
That’s true for read-only Server Components. It stops being true the moment a client component does optimistic UI, polling, or real-time refresh on top of Server Component data.
My current rule:
If the screen is mostly read-only and the data fits Server Components, don’t pull in either library. Use the native fetch cache and revalidation tags.
If you need a client-side cache layer because of optimistic UI or polling, use TanStack Query. The hydration story (HydrationBoundary, server-side dehydrate) is better than SWR’s right now.
If you’re on Pages Router still, SWR is from the same team as Next.js and the integration is the smoothest. There’s a reason it was the default suggestion for years.
For the broader caching conversation, I went deep on the Next.js App Router caching defaults that burned me. Some of those tradeoffs overlap with this one.
What I do when the choice isn’t obvious
When a project is too small to know what it’ll become, I default to SWR. The cost of moving to TanStack Query later is one afternoon of mechanical refactoring. The cost of moving the other direction is also one afternoon, but it almost never happens. Once you’re past three mutations, you’ll feel why TanStack exists.
The clearest tell that you’ve outgrown SWR: you start writing a helper called optimisticMutate that wraps mutate with rollback logic. That helper is two-thirds of what TanStack already gives you for free. When I see that pattern in my own code, that’s when I migrate. The same thing tends to happen with ORM choices, which I cover in Drizzle vs Prisma six months in, what I actually use. Library choice is rarely about the library. It’s about whether the abstraction matches your problem.
If you want to see how I wire data layers in actual projects, I keep notes on the builds I’m working on over on my work page. The patterns there explain more than any feature checklist will.
One concrete thing to try this week
Open the React app you’re working on. Count the components that fetch data. Count the components that mutate data. If the second number is more than 30% of the first, give TanStack Query an afternoon. Wire one screen end to end. The dev experience tells you more than any benchmark.
If the second number is small or zero, stay on SWR. You’re not going to win a deserve-it-more contest by adopting a heavier library.
The library should fall out of the app, not the other way around. That’s the part the feature-matrix posts never tell you.