TanStack Query vs SWR vs Apollo: Data Fetching Library Choice
TanStack Query, SWR, or Apollo — which data fetching library fits your React app? A real comparison of caching, bundle size, and DX tradeoffs.
Three Libraries, One Job — Why Does It Still Feel Complicated?
Honestly, picking a data fetching library in 2026 shouldn't be this hard. Yet here we are, with three serious contenders — TanStack Query v5, SWR v2, and Apollo Client v3.11 — each with passionate advocates and legitimate reasons to exist.
The confusion is real. All three solve the same core problem: fetch remote data, cache it, keep it fresh, and make your UI reflect that state without you writing a 300-line custom hook. But the way they go about it differs enormously, and those differences matter at 2 AM when something breaks in production.
This isn't a benchmark post with synthetic numbers. It's a practical walkthrough of when each library earns its place in your stack, when it doesn't, and what the tradeoffs actually feel like when you're shipping real features. If you're also evaluating your broader React stack, the best free UI frameworks for React article covers the surrounding ecosystem choices.
TanStack Query v5: The Most Opinionated Cache You'll Actually Enjoy
TanStack Query (formerly React Query) v5 shipped a significant API cleanup. The useQuery hook is now fully typed without workarounds, the queryOptions helper lets you colocate query definitions, and the devtools are genuinely useful rather than just decorative. The bundle comes in around 13 kB gzipped for the core, which is reasonable.
The mental model is query keys as cache keys. Once you internalize that, everything clicks. You define a key like ['users', userId], and every component that references that key shares the same cached data. Invalidation is explicit — queryClient.invalidateQueries({ queryKey: ['users'] }) — which feels verbose at first but eliminates entire categories of stale data bugs.
Where TanStack Query really pulls ahead is mutation ergonomics and background refetch control. The staleTime option (default 0, meaning always refetch on mount) and gcTime option (default 5 minutes before cache garbage collection) give you fine-grained control. Setting staleTime: 1000 * 60 * 5 means data stays fresh for 5 minutes without a network trip. That's the kind of knob you reach for constantly in real apps.
A Real TanStack Query Setup With Optimistic Updates
Here's a pattern you'll use constantly — fetching a list and updating it optimistically on mutation, with rollback on error:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
type Post = { id: number; title: string; published: boolean }
const queryClient = useQueryClient()
function useTogglePost() {
return useMutation({
mutationFn: (post: Post) =>
fetch(`/api/posts/${post.id}`, {
method: 'PATCH',
body: JSON.stringify({ published: !post.published }),
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json()),
onMutate: async (post) => {
// Cancel any outgoing refetches so they don't overwrite
await queryClient.cancelQueries({ queryKey: ['posts'] })
// Snapshot the previous value
const previousPosts = queryClient.getQueryData<Post[]>(['posts'])
// Optimistically update
queryClient.setQueryData<Post[]>(['posts'], old =>
old?.map(p =>
p.id === post.id ? { ...p, published: !p.published } : p
) ?? []
)
return { previousPosts }
},
onError: (_err, _post, context) => {
// Roll back on error
queryClient.setQueryData(['posts'], context?.previousPosts)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
}The onMutate / onError / onSettled pattern is explicit and predictable. You know exactly what happens at each stage. It's more boilerplate than SWR's mutate helper, but it's also more readable six months later when you're debugging why a toggle broke.
SWR v2: Minimal Surface Area, Maximum Pragmatism
SWR from Vercel is the lean option. At roughly 4.4 kB gzipped, it's a third the size of TanStack Query. If you're building a Next.js app with light data requirements — a dashboard, a marketing site with a few API calls, a small SaaS — SWR is often the right call. The API surface is small enough to learn in an afternoon.
The hook signature is useSwR(key, fetcher, options). That fetcher is just a function that returns a Promise, which means you can use fetch, axios, or anything else without any adapter layer. SWR doesn't care. This is genuinely liberating compared to Apollo, which wants to own your entire data layer.
What you give up is mutation complexity. SWR's mutate function does local cache updates, but optimistic updates with rollback require more manual orchestration than TanStack Query's lifecycle callbacks. For read-heavy apps, that's a fine tradeoff. For apps where writes are frequent and complex — e-commerce carts, collaborative editors, form-heavy dashboards — you'll feel the friction. If your Next.js setup matters here, it's worth reading the Next.js vs Remix comparison to see how routing choices affect data fetching patterns too.
Apollo Client v3.11: GraphQL First, REST Optional
Apollo Client is a different beast. It's not competing with SWR on bundle size (it's around 32 kB gzipped for the full client) and it's not trying to be a thin cache wrapper. It's a full GraphQL client with normalized caching, local state management, subscriptions, and a plugin ecosystem. If you're on GraphQL, this is still the default choice for most teams.
The normalized cache is the real value prop. Apollo stores every object by its __typename + id, which means when you update a User:42 anywhere, every query that includes that user updates automatically. You don't think in terms of query keys — you think in terms of objects. For apps with complex relational data, this model is genuinely better. For apps that don't need it, it's overhead.
Apollo's friction points are real, though. The cache is notoriously hard to debug when things go wrong. Cache updates after mutations require writing update functions or carefully crafted refetchQueries configs. And if you're not on GraphQL, using Apollo for REST via @apollo/link-rest works but feels like driving with the handbrake half-on. The v3.11 release improved cache eviction and reduced memory overhead, but the complexity ceiling is higher than TanStack Query or SWR.
One underappreciated Apollo feature: useFragment. In v3.8+, you can subscribe to a specific fragment of the cache from any component, which enables very granular reactivity. A single normalized cache write to User:42 will update every useFragment subscriber across your whole app. That's architecturally elegant when you're working with large datasets.
Bundle Size, DevX, and the Metrics That Actually Matter
Let's put the numbers side by side. TanStack Query v5 core: ~13 kB gzipped. SWR v2: ~4.4 kB gzipped. Apollo Client v3.11 with GraphQL: ~32 kB gzipped. These aren't marketing numbers — they're what bundlephobia reports, and they roughly match real-world measurements after tree shaking.
DevX is harder to quantify. TanStack Query's devtools show every query, its status, data, and error state in a floating panel. It's become the standard I measure other tools against. SWR has basic devtools that are less polished. Apollo's devtools browser extension is excellent for GraphQL introspection but doesn't help if you're not running Apollo.
TypeScript support is strong across all three as of late 2026. TanStack Query v5's inference is the tightest — the return type of useQuery narrows correctly based on your select function without extra type assertions. SWR v2 improved its generics significantly. Apollo's types come from codegen (usually graphql-code-generator), which adds a build step but produces excellent types for large schemas. If bundle size is your constraint, this comparison pairs well with the broader Vite vs Next.js analysis for understanding your overall build footprint.
When to Pick Each One — A Practical Decision Tree
You're on GraphQL? Start with Apollo. It's not the only option — TanStack Query works fine with GraphQL via a custom fetcher, and URQL is a lighter alternative — but Apollo's normalized cache and ecosystem maturity are hard to beat for teams already invested in the GraphQL toolchain.
You're on REST and your app is read-heavy with simple mutations? SWR. It's small, it's fast to learn, and the revalidation model (stale-while-revalidate) is exactly what most dashboards and content sites need. You'll ship faster and have less library code to maintain.
You're on REST with complex mutations, offline support, dependent queries, infinite scrolling, or anything where cache state gets complicated? TanStack Query. The extra 9 kB over SWR buys you a lot of capability, and the query key model scales better as your app grows. What sounds like it matters? The enabled option on useQuery deserves special mention — enabled: !!userId means the query simply doesn't run until you have a userId, which eliminates awkward loading state checks in dependent query chains.
And sometimes you don't need any of these. A single useFetch hook backed by the native Fetch API handles a surprising number of cases. Don't reach for a library until you've actually hit the limitation.
Mixing Libraries and Handling the Edge Cases
You can mix these libraries. It's not common, but if your app has a GraphQL API for some resources and REST for others, running Apollo alongside TanStack Query is a legitimate pattern. They don't conflict at runtime. The cost is cognitive: your team needs to know when to use which hook, and you're paying both bundle costs.
Suspense support is worth mentioning. TanStack Query v5 has first-class useSuspenseQuery that works correctly with React 19's Suspense boundaries. SWR has suspense: true option but requires careful error boundary setup. Apollo's Suspense support stabilized in v3.8. If you're building with React 19 Suspense mode (which pairs nicely with server components in Next.js 15+), TanStack Query's implementation is the most reliable right now.
Error handling patterns differ too. TanStack Query puts errors in the return value (const { data, error, isError } = useQuery(...)). SWR does the same. Apollo returns { data, loading, error }, which is the same shape but the loading boolean means something slightly different — it's true on every network request, not just the initial one, which trips people up. Always check networkStatus if you need to distinguish initial load from background refresh in Apollo.
FAQ
Yes, and it works well. You write a custom fetcher that sends GraphQL requests via fetch or axios, use the operation name or variables as part of your query key, and TanStack Query handles caching and refetching normally. What you lose is Apollo's normalized cache — TanStack Query stores responses as-is, so updating one object doesn't automatically update it everywhere it appears. For smaller GraphQL APIs that tradeoff is often fine.
Yes, with caveats. SWR v2 is used in production at significant scale. The limitation isn't stability — it's capability. As mutation complexity grows (optimistic updates with rollback, complex dependent queries, cache invalidation across multiple endpoints), you'll find yourself writing logic that TanStack Query provides out of the box. Many teams start with SWR and migrate to TanStack Query as their app matures.
Apollo relies on cache normalization and fetch policies (cache-first, network-only, cache-and-network, etc.) to control staleness. TanStack Query uses staleTime and gcTime as time-based controls. The Apollo model is more object-oriented — a write to one query updates all queries containing that object. TanStack Query is more explicit — you invalidate by query key. Apollo's approach is automatic but harder to reason about; TanStack Query's is manual but predictable.
Technically yes, via @apollo/link-rest. But it's genuinely awkward. You're using a library designed around schema introspection and typed queries to fetch arbitrary REST endpoints, and the ergonomics show. If you're on REST, use SWR or TanStack Query. Apollo's value is almost entirely GraphQL-specific.
TanStack Query v5's useSuspenseQuery is the most reliable implementation as of late 2026. It throws a Promise on loading (triggering Suspense) and an Error on failure (triggering an Error Boundary) with correct behavior on concurrent renders. SWR's suspense mode works but has some edge cases with concurrent mode. Apollo's Suspense support stabilized in v3.8 and works correctly, but only within Apollo's own ApolloProvider context.
Apollo Client v3.11 ships around 32 kB gzipped including the GraphQL parser. TanStack Query v5 core is about 13 kB gzipped. SWR v2 is about 4.4 kB gzipped. In a typical Next.js app where your main bundle is already 80-150 kB, Apollo's extra weight matters less than it does in a Vite SPA where you're aggressively optimizing first load. Measure your actual Lighthouse scores before making bundle size the deciding factor.