Caching & Revalidation
Every time you fetch data, a fundamental tension exists between two goals: showing fresh data (accuracy) and showing data fast (performance). Caching is the mechanism that lets you have both — store a copy of the response, show it instantly, then fetch an update in the background. Revalidation is the process of replacing stale cache entries with fresh ones.
What Is a Cache?
In the context of React data fetching, a cache is an in-memory store that maps cache keys to previously fetched data. When a component mounts and requests data, the library first checks whether a cache entry exists for that key. If the entry is still fresh, the library returns it immediately — no network request. If the entry is stale (or absent), it fetches fresh data and updates the cache.
Cache Keys
Cache keys uniquely identify a piece of server state. In TanStack Query they are arrays; in SWR they are strings (usually URLs). Every variable that affects the response should be part of the key:
// TanStack Query — array keys
useQuery({ queryKey: ['posts'], queryFn: fetchPosts })
useQuery({ queryKey: ['posts', postId], queryFn: () => fetchPost(postId) })
useQuery({ queryKey: ['posts', postId, 'comments'],queryFn: () => fetchComments(postId) })
useQuery({ queryKey: ['users', { status: 'active', page: 2 }], queryFn: fetchUsers })
// SWR — string keys (typically URLs)
useSWR('/api/posts', fetcher)
useSWR(`/api/posts/${postId}`, fetcher)
useSWR(`/api/users?status=active&page=2`, fetcher)Stale Time vs Cache Time
Setting | Meaning | Default (TanStack Query) |
|---|---|---|
staleTime | How long data is considered fresh. No background refetch during this window. | 0 ms (always stale) |
gcTime (cacheTime) | How long inactive data stays in memory before garbage collection. | 5 minutes |
// User profile — cache for 10 minutes, fresh for 5 minutes
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 1000 * 60 * 5, // 5 minutes fresh
gcTime: 1000 * 60 * 10, // keep in memory for 10 minutes
})
// Exchange rate — always fetch fresh (no staleTime)
useQuery({
queryKey: ['exchangeRate', currency],
queryFn: () => fetchRate(currency),
staleTime: 0, // immediately stale
refetchInterval: 1000 * 30, // poll every 30 seconds
})The Stale-While-Revalidate Strategy
Stale-while-revalidate (SWR) is an HTTP cache-control directive that became a popular React data fetching pattern. It works in two steps:
Immediately serve the cached (stale) data so the user sees something instantly.
Simultaneously fetch fresh data in the background and update the UI when it arrives.
The user never sees a loading spinner for data they have seen before. The transition from stale to fresh is seamless — the UI updates in-place when fresh data arrives.
Cache Invalidation
Cache invalidation tells the library that a cache entry is outdated and should be refetched. There are three main triggers:
On mutation — after a write operation (create/update/delete), invalidate the affected queries.
Time-based — use
staleTimeorrefetchIntervalto expire data automatically.Manual — call
invalidateQueries/mutateexplicitly (e.g. a "Refresh" button).
// TanStack Query: invalidate after mutation
const queryClient = useQueryClient()
const deleteMutation = useMutation({
mutationFn: (id) => fetch(`/api/posts/${id}`, { method: 'DELETE' }),
onSuccess: () => {
// Invalidate ALL queries that start with ['posts']
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
// Manual refetch (e.g. a Refresh button)
queryClient.invalidateQueries({ queryKey: ['posts'] })Optimistic Updates
An optimistic update immediately reflects the expected result of a mutation in the UI — before the server confirms it. If the server returns an error, the UI is rolled back. This makes the app feel instant even on slow connections:
import { useMutation, useQueryClient } from '@tanstack/react-query'
function TodoList() {
const queryClient = useQueryClient()
const toggleTodo = useMutation({
mutationFn: ({ id, done }) =>
fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ done }),
}),
// Called BEFORE the mutationFn fires
onMutate: async ({ id, done }) => {
// 1. Cancel any in-flight queries that would overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 2. Snapshot the current cache value (for rollback)
const previous = queryClient.getQueryData(['todos'])
// 3. Optimistically update the cache
queryClient.setQueryData(['todos'], (old) =>
old.map(todo => todo.id === id ? { ...todo, done } : todo)
)
// 4. Return context for rollback
return { previous }
},
// If the mutation fails, roll back to the snapshot
onError: (_err, _variables, context) => {
queryClient.setQueryData(['todos'], context.previous)
},
// Always refetch after success or error to sync with server truth
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
// ... render todos
}Background Refetching
TanStack Query and SWR automatically trigger background refetches in several situations to keep data fresh without user interaction:
Window focus — user returns to the browser tab after being away.
Network reconnect — device comes back online after going offline.
Component mount — a component mounts and the cached data is stale.
Interval polling —
refetchIntervaltriggers fetches on a timer.
// Disable automatic refetching for static data
useQuery({
queryKey: ['countries'],
queryFn: fetchCountries,
staleTime: Infinity, // never goes stale
refetchOnWindowFocus: false, // don't refetch on focus
refetchOnMount: false, // don't refetch when component mounts
})Cache Prefetching
Prefetching loads data into the cache before a component that needs it mounts — for example, on hover over a link, or during server-side rendering:
// Prefetch on hover so the page loads instantly on click
function PostLink({ postId, title }) {
const queryClient = useQueryClient()
function handleMouseEnter() {
queryClient.prefetchQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
staleTime: 1000 * 60, // only prefetch if older than 1 minute
})
}
return (
<a href={`/posts/${postId}`} onMouseEnter={handleMouseEnter}>
{title}
</a>
)
}