frontend-data-fetching-cache-contracts
Frontend data fetching cache contracts
Client data libraries cache reads by key and serve them stale-while-revalidate; the read side breaks when a write does not invalidate the exact key the reader subscribes to, when the key itself is unstable or mis-partitioned, or when fetch timing (waterfalls, focus refetch, pagination growth, fetchPolicy) is left to a default that does not match the data. This lens owns reads and caching; the mutation and rollback that produce the new data are the sibling skill.
Checklist (lead with the trap)
- Invalidate the key the reader actually uses — or the read never refreshes. After a write, a list/detail stays stale unless the exact query key / cache tag it subscribes to is invalidated. TanStack matches fuzzily by key prefix (
['todos']invalidates['todos', {page}]) unlessexact: true; a mismatched key leaves stale data, an over-broad one refetches the world. RTK Query needsprovidesTagson the query andinvalidatesTagson the mutation to line up (plus aLISTid so a newly created row appears). Apollo keys the normalized cache by__typename:id, so a new list item will not show up unless you update the list field or refetch. Whether the mutation fires the invalidation is the sibling skill; whether it targets the reader's key is here. - Build the key from stable, serialized params — not a fresh object each render. Keys hash deterministically and object property order does not matter, but the key must be JSON-serializable and stable across renders. A non-serializable value (Date, class, function) or one that changes identity every render fragments the cache into permanent misses; omitting a param the fetch actually depends on collides two different results into one entry. Array item order in the key is significant.
- Partition the cache key by what selects a different result set — not by what re-slices it. Filter/sort/search params that change which rows come back belong in the key (TanStack key array, Apollo
keyArgs, RTKserializeQueryArgs); params that only re-view the same cached data do not. Wrong grouping means one filter's results bleed into another, or the cache never hits. - Parallelize independent requests; prefetch dependent ones — do not await in series. Sequential
awaits, or a child query that needs the parent's result, render as a waterfall. Flatten by hoisting/restructuring the API, running independent queries together (useQueries/parallel), or prefetching in the parent or router so the second request starts before the child mounts. - Set staleTime/gcTime and focus/reconnect refetch on purpose — defaults differ per library. TanStack defaults
staleTime: 0(refetch on every mount/focus) andgcTime5 min; SWR revalidates on focus and reconnect by default and dedupes within a short window; RTK Query does not refetch on focus/reconnect unless you opt in (setupListeners+refetchOnFocus/refetchOnReconnect) and drops unused data after ~60s. Over-fetching is a refetch storm on a rarely-changing resource; under-fetching isstaleTime: Infinitywith no invalidation, so it never refreshes. Do not carry one library's mental default into another. - Infinite/paginated cache: append vs replace, and cap growth.
useSWRInfinite/useInfiniteQueryaccumulate pages; a merge or setter that overwrites drops earlier pages. Unbounded page arrays balloon memory and slow back-navigation — TanStackmaxPages(with bothgetNextPageParamandgetPreviousPageParam) caps retained pages; on refetch it refetches pages sequentially from the first to avoid stale-cursor duplicates, and if the query is garbage-collected pagination restarts at page one (lost scroll position). Apollo needs a field-policymerge(withkeyArgs) or the list overwrites; RTK Query infinite scroll usesserializeQueryArgs+merge+forceRefetch(orbuild.infiniteQuery). - Apollo fetchPolicy/nextFetchPolicy: do not strand a query off-network or in a refetch loop.
cache-only/cache-firstcan leave a screen showing empty or stale data that never revalidates;cache-and-network/network-onlywithout anextFetchPolicythat demotes tocache-firstrefetches on every render.nextFetchPolicyresets to the initial policy when variables change (reasonvariables-changed). - Fetch the shape the view needs — not more, not one-at-a-time. Selecting whole objects when the screen renders two fields is over-fetch; N+1 per-row detail requests a single list/
useQueriesbatch could cover is under-fetch and usually also a waterfall.
Quick probes
Treat hits as leads; confirm the write->read refresh seam and the key at the call site. Route mechanical key hygiene (unstable keys, missing deps, infinite-query property order) to the authoritative linter @tanstack/eslint-plugin-query (rules exhaustive-deps, no-unstable-deps, infinite-query-property-order) — it catches key mistakes faster than review.