React Query vs SWR for Server State
Both TanStack Query v5 and SWR v2 solve the same problem: keeping server-owned data fresh in a React tree without hand-rolling loading flags, cache keys, and refetch timers. Choosing between them is a decision about how much cache machinery you want to own versus delegate, and it sits squarely inside Client vs Server State Boundaries — the discipline of keeping server snapshots out of your client-state store. If your comparison is really about whether server state belongs in a reducer at all, read the sibling analysis of React Query vs Redux for server state first; this page assumes you have already decided you want a dedicated async cache and are picking which one.
The two libraries share a stale-while-revalidate heart but diverge sharply on surface area. SWR gives you a lean key + fetcher primitive and gets out of the way. TanStack Query gives you a query cache with explicit invalidation, a mutation lifecycle, and a devtools panel. The rest of this page walks the axes that actually change day-to-day code.
Mental Models Compared
SWR’s model is a hook that maps a stable key to a fetcher and revalidates on triggers (focus, reconnect, interval). The cache is a flat Map from key to value; you rarely touch it directly. TanStack Query’s model is a QueryClient holding a normalized-by-key query cache, where each query tracks status, fetchStatus, staleTime, and gcTime, and where you invalidate imperatively with queryClient.invalidateQueries. SWR revalidates; TanStack Query invalidates then refetches. That one-word difference is the whole philosophy: SWR pulls fresh data on ambient signals, while TanStack Query lets you declare, from a mutation, exactly which cached keys are now wrong.
Fetching, Side by Side
The read path is where the two feel most alike. A basic query is a one-liner in either library.
// TanStack Query v5
import { useQuery } from '@tanstack/react-query';
interface User {
id: string;
name: string;
}
function Profile({ userId }: { userId: string }) {
const { data, isPending, isError } = useQuery({
queryKey: ['user', userId],
queryFn: async (): Promise<User> => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed');
return res.json();
},
staleTime: 60_000,
gcTime: 5 * 60_000,
});
if (isPending) return <p>Loading…</p>;
if (isError) return <p>Error</p>;
return <p>{data.name}</p>;
}
Cache Behavior Analysis. The tuple ['user', userId] is hashed deterministically into a stable cache key; two components mounting the same key share one query and one network request. staleTime: 60_000 means the cached value is served without a background refetch for 60 seconds, while gcTime controls how long the data survives after the last observer unmounts before garbage collection.
// SWR v2
import useSWR from 'swr';
interface User {
id: string;
name: string;
}
const fetcher = (url: string) =>
fetch(url).then((r) => {
if (!r.ok) throw new Error('Failed');
return r.json() as Promise<User>;
});
function Profile({ userId }: { userId: string }) {
const { data, isLoading, error } = useSWR(`/api/users/${userId}`, fetcher, {
dedupingInterval: 60_000,
revalidateOnFocus: true,
});
if (isLoading) return <p>Loading…</p>;
if (error) return <p>Error</p>;
return <p>{data?.name}</p>;
}
Cache Behavior Analysis. SWR’s key is the string /api/users/${userId}; identical keys are deduplicated within dedupingInterval, SWR’s rough analogue to staleTime. There is no gcTime equivalent — SWR retains cache entries for the lifetime of the SWRConfig provider unless you evict them via mutate(key, undefined), so long-lived apps lean on the provider cache rather than timed garbage collection.
- TanStack Query separates
staleTime(when to refetch) fromgcTime(when to evict); SWR folds freshness intodedupingIntervaland never auto-evicts. - SWR keys are typically the URL string, making cache keys and requests one and the same; TanStack Query keys are arbitrary serializable tuples decoupled from the request.
- Both default
refetchOnWindowFocus/revalidateOnFocustotrue— the single most common source of “why did it refetch” surprises.
Mutations, Side by Side
This is the axis with the widest gap. TanStack Query’s useMutation is a full lifecycle with onMutate, onError, and onSettled hooks purpose-built for optimistic updates and rollback. SWR’s mutation story is mutate (bound or global) plus the useSWRMutation hook for remote writes.
// TanStack Query v5 — optimistic update with rollback
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useRename(userId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) =>
fetch(`/api/users/${userId}`, { method: 'PATCH', body: JSON.stringify({ name }) }),
onMutate: async (name) => {
await qc.cancelQueries({ queryKey: ['user', userId] });
const previous = qc.getQueryData(['user', userId]);
qc.setQueryData(['user', userId], (old: any) => ({ ...old, name }));
return { previous };
},
onError: (_err, _name, ctx) => {
qc.setQueryData(['user', userId], ctx?.previous);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['user', userId] });
},
});
}
Cache Behavior Analysis. onMutate runs before the request and returns a context snapshot; cancelQueries stops any in-flight refetch from clobbering the optimistic write. If the request throws, onError restores the snapshot; onSettled invalidates the key so the server’s authoritative value replaces the guess. This rollback contract is first-class API surface, not something you assemble.
// SWR v2 — optimistic update via mutate
import useSWRMutation from 'swr/mutation';
import { useSWRConfig } from 'swr';
async function patchName(url: string, { arg }: { arg: string }) {
await fetch(url, { method: 'PATCH', body: JSON.stringify({ name: arg }) });
}
function useRename(userId: string) {
const { mutate } = useSWRConfig();
const key = `/api/users/${userId}`;
return useSWRMutation(key, patchName, {
onSuccess: () => {
mutate(key); // revalidate after write
},
});
}
Cache Behavior Analysis. SWR expresses optimism through mutate(key, optimisticData, { rollbackOnError: true }) rather than lifecycle hooks — you pass the optimistic value and a rollback flag directly to mutate. useSWRMutation deliberately does not revalidate on its own, so you call mutate(key) in onSuccess to trigger the confirming refetch. The rollback exists, but you wire it per-call instead of declaring it once on the mutation.
- TanStack Query centralizes rollback in
onMutate/onError; SWR passesrollbackOnErrorandoptimisticDatainline to eachmutate. - SWR’s global
mutatecan update or revalidate any key from anywhere, including with a matcher function; TanStack Query’s cross-key invalidation isinvalidateQuerieswith a partialqueryKeyprefix. - Neither auto-invalidates related lists after a write — you name the affected keys yourself in both.
Infinite & Paginated Data
Both ship dedicated infinite hooks. TanStack Query’s useInfiniteQuery requires initialPageParam and getNextPageParam and exposes maxPages to cap retained pages. SWR’s useSWRInfinite derives each page key from a getKey(index, previousPageData) function and appends to a data array of pages. TanStack Query’s page params are richer; SWR’s index-based keying is simpler but makes bidirectional pagination awkward. Whichever you pick, the same interval-tuning tradeoffs from optimizing SWR revalidation intervals apply — an aggressive refreshInterval on a deep infinite list refetches every loaded page and can flood the network.
Normalization Stance
Neither library normalizes entities. Both store data document-style — the whole response lives under its query key or SWR key, and the same user object fetched by two different keys is two independent copies. If a mutation must update that user everywhere, you fan out writes yourself. This is the deliberate design that separates them from Apollo Client, and it is exactly why choosing normalization depth is a separate decision covered in normalization principles for the UI cache. For document-style caches, keep entities shallow and let invalidation, not shared references, be the sync mechanism.
When to Pick Each
Pick SWR when the app is read-heavy, keys map cleanly to URLs, and you want the smallest possible runtime — dashboards, content sites, and CRUD screens with simple writes. Pick TanStack Query when you have complex optimistic mutations, need the devtools to reason about invalidation, want retry and networkMode control, or run infinite lists with maxPages caps. The bundle-size gap rarely decides it; the mutation and tooling gap usually does.
Common Pitfalls and Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Data refetches constantly on tab switch | Both libraries default refetchOnWindowFocus / revalidateOnFocus to true |
Set staleTime (TanStack) or dedupingInterval/revalidateOnFocus: false (SWR); do not fight it with client state |
| Optimistic update sticks after a failed write | SWR mutate called without rollbackOnError, or TanStack onError never restores the snapshot |
Pass { rollbackOnError: true, optimisticData } in SWR; return { previous } from onMutate and restore it in onError in TanStack Query |
| Same entity shows stale in one component, fresh in another | Document-style storage keeps two copies under two keys; a write updated only one | Invalidate every affected key (invalidateQueries prefix / SWR matcher mutate), or lift the entity to a single shared key |
Frequently Asked Questions
Does SWR have a devtools panel comparable to TanStack Query DevTools?
No first-party devtools ship with SWR v2. You inspect its cache through useSWRConfig().cache (a Map) or community tools like swr-devtools. TanStack Query ships @tanstack/react-query-devtools with a live view of every query’s status, fetchStatus, staleTime, gcTime, and observer count. For teams debugging invalidation timing, that panel is often the deciding factor on its own.
Can I normalize entities across queries in either library?
Not automatically. Both store responses document-style keyed by the query key or SWR key, so one entity fetched under two keys is two copies. To share a single record you write it yourself — queryClient.setQueryData in TanStack Query, or a global mutate in SWR. If you need __typename:id normalization out of the box, Apollo Client is the correct tool, not either of these.
Which library has the smaller bundle footprint?
SWR v2 is smaller — roughly 4-5 kB gzipped for the core versus roughly 12-13 kB for @tanstack/react-query. The gap narrows once you add useSWRInfinite and useSWRMutation. Decide on capability, not kilobytes: the extra weight in TanStack Query buys the mutation lifecycle, retry/networkMode control, and devtools, which usually pay for themselves in a non-trivial app.
Related
- Client vs Server State Boundaries — the parent reference on why server snapshots belong in an async cache rather than your client-state store, framing this whole comparison.
- React Query vs Redux for Server State — the companion decision for teams weighing a dedicated async cache against a reducer-based store.
- Optimizing SWR Revalidation Intervals — how to tune
refreshIntervaland focus revalidation so background refetches stay cheap on paginated lists. - State Architecture & Cache Fundamentals — the foundational pillar tying cache-layer design, normalization, and storage models together across libraries.