awesome-everything RU
↑ Back to the climb

Frontend Architecture

Client-side cache: TanStack Query, SWR, and stale-while-revalidate

Crux How TanStack Query and SWR implement shared caches with stale-while-revalidate semantics, optimistic updates, and cache invalidation strategies for post-mount interactive fetching.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at middle altitude — in the sky
◷ 12 min

A user opens a product page, navigates away, and comes back 10 seconds later. With plain useEffect, they wait for the full fetch again. With TanStack Query, the page shows the cached result instantly while a background refresh quietly checks for updates. Same data, radically different experience.

The stale-while-revalidate pattern

Stale-while-revalidate is a cache freshness strategy from RFC 5861: serve cached data immediately even if it might be stale, then re-fetch in the background. The user sees content without waiting; the UI updates when fresh data arrives if it changed.

Both TanStack Query and SWR implement this:

// TanStack Query
const { data, isLoading } = useQuery({
  queryKey: ['product', id],
  queryFn: () => fetchProduct(id),
  staleTime: 30_000,   // data is "fresh" for 30s — no background refetch
  gcTime: 300_000,     // unused cache entries live for 5min before GC
});

First visit: isLoading=true, fetch fires, data arrives, isLoading=false.
Second visit (within gcTime): data returns instantly from cache. Background fetch updates it if staleTime has elapsed.

The queryKey is the cache key. ['product', id] is a separate cache entry from ['product', otherId]. Arrays of primitives work; TanStack Query serialises them deterministically.

Single-flight deduplication

When multiple components mount at the same time and all call useQuery(['products']), TanStack Query fires only one network request. All components share the result. This is called single-flight de-duplication — it collapses N identical in-flight requests into one, avoiding both wasted bandwidth and cache races.

// 10 <ProductCard> components on the page, each calling:
useQuery({ queryKey: ['products'], queryFn: fetchProducts });
// → exactly ONE network request fires

SWR does the same. This makes library-based caching dramatically safer than rolling your own useState + useEffect per component.

Optimistic updates

For actions that should feel instant — Like, Bookmark, Mark as Read — apply the change locally before the server confirms it, then roll back on error.

const mutation = useMutation({
  mutationFn: (postId: string) => likePost(postId),
  onMutate: async (postId) => {
    // 1. Cancel any outgoing refetches to avoid overwriting optimistic update
    await queryClient.cancelQueries({ queryKey: ['post', postId] });
    // 2. Snapshot the current value
    const previous = queryClient.getQueryData(['post', postId]);
    // 3. Optimistically update
    queryClient.setQueryData(['post', postId], (old) => ({ ...old, liked: true, likeCount: old.likeCount + 1 }));
    return { previous };
  },
  onError: (err, postId, context) => {
    // Roll back to snapshot on failure
    queryClient.setQueryData(['post', postId], context.previous);
  },
  onSettled: (postId) => {
    // Invalidate to get canonical server state
    queryClient.invalidateQueries({ queryKey: ['post', postId] });
  },
});

The pattern: apply the expected server response shape locally, let the real response replace it. If the shapes match, the replacement is a no-op and the user sees no flicker.

Optimistic update lifecycle
T=0
User clicks Like → onMutate: snapshot + setQueryData(liked:true) → UI updates instantly
T=200ms
Server responds OK → onSettled: invalidate → background refetch confirms
On error
Server returns 429 → onError: setQueryData(snapshot) → UI rolls back

Cache invalidation strategies

When a mutation succeeds, stale queries must be refreshed. Three approaches:

invalidateQueries — mark queries stale, trigger refetch on next observer. Safest; guaranteed canonical server data.

queryClient.invalidateQueries({ queryKey: ['posts', userId] });

setQueryData — write the mutation response directly into the cache. Cheapest; no extra network call. Only correct when the mutation response includes the complete new state.

queryClient.setQueryData(['post', id], serverResponse);

refetchQueries — force immediate fetch. Use when you need fresh data now and cannot trust the mutation response.

Most production code uses both: setQueryData for the directly-mutated entity (if the API returns it), invalidateQueries for listing queries that reference it.

Why this works

TanStack Query v5 renamed cacheTime to gcTime to clarify what it controls — not how long data is cached (that’s staleTime) but how long unused entries live before garbage collection. If staleTime is 0 and gcTime is 300s, the cache entry lives for 5min but is re-fetched on every access. If staleTime is 60s, no re-fetch happens for 60s after the last successful fetch.

TanStack Query vs SWR
TanStack Query v5 size
~16 KB gzipped
SWR size
~5 KB gzipped
Default staleTime (both)
0ms (always stale)
Default gcTime (TanStack Query)
5 minutes
RFC 5861 (stale-while-revalidate)
HTTP analog
Quiz

A useQuery returns isLoading=false and data. The user navigates away and back to the same page. What is the default UX?

Quiz

Why does TanStack Query implement single-flight de-duplication?

Quiz

An optimistic Like update works, but the like count un-likes itself a moment later. Most likely cause?

Recall before you leave
  1. 01
    What does staleTime control in TanStack Query?
  2. 02
    What is the canonical optimistic update pattern in TanStack Query?
  3. 03
    When should you use invalidateQueries vs setQueryData after a mutation?
Recap

TanStack Query and SWR manage a shared cache keyed by queryKey arrays with stale-while-revalidate semantics: staleTime controls freshness (default 0), gcTime controls entry lifetime (default 5min). Multiple components calling the same query key get one network request via single-flight deduplication. Optimistic updates apply the expected mutation result locally using setQueryData with a snapshot for rollback; the onMutate snapshot must mirror the complete server response shape or the UI flickers when the real data arrives. After mutations, invalidateQueries ensures listing caches reflect changes; setQueryData handles direct entity cache writes when the mutation response is available.

Connected lessons
appears again in178
Continue the climb ↑LCP, prefetch, and race conditions in interactive fetching
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.