GraphQL Caching
Why normalized caches exist, when you actually need one, and a common anti-pattern to avoid
The Problem
React Query caches by query key. Two queries that both include the same user are two separate cache entries:
In a REST app with a handful of queries, this is manageable. In a GraphQL app where every page composes different slices of the same entities — users, posts, comments — it becomes a maintenance problem. A mutation that updates a user needs to know every query key that might have fetched that user, across the entire codebase.
Query Invalidation: Good Enough for Most Cases
Before reaching for a normalized cache, check whether invalidation solves your problem. It usually does.
When this is enough
Signs that invalidation is getting painful
- Mutations need to invalidate 6+ distinct query keys
- You've shipped bugs where a mutation updated the database but the UI showed stale data
- Developers regularly ask "which queries do I need to invalidate after this mutation?"
- Real-time updates (WebSocket events) need to update the UI without a full refetch
Normalized Cache: How Apollo Client Solves This
A normalized cache stores each entity exactly once, keyed by type and ID. Every query that includes User:42 references the same object in the cache. When a mutation updates that object, every query that references it reflects the change automatically — no manual invalidation required.
What normalization buys you
User:42 propagates to every component that displays that user — the user list, the detail page, the sidebar, the comment author — without any coordination code. This is the core value of Apollo and URQL over React Query for GraphQL.Apollo vs URQL
| Apollo Client | URQL + graphcache | |
|---|---|---|
| Bundle size | ~45kB gzipped | ~15kB core + ~10kB exchange |
| Normalized cache | InMemoryCache (built-in) | @urql/exchange-graphcache (opt-in) |
| Devtools | Apollo DevTools (Chrome extension) | urql DevTools (Chrome extension) |
| Pagination | relayStylePagination / offsetLimitPagination | Custom resolvers in graphcache |
| Learning curve | Higher — policies, reactive variables, cache APIs | Lower — more composable, smaller surface |
| Use when | Large team, complex cache, existing Apollo ecosystem | Starting fresh, smaller app, want lighter footprint |
The Anti-Pattern: React Query + Custom Normalized Store
Some teams using GraphQL with React Query notice the invalidation maintenance problem and build a solution: a custom normalized entity store (often with Jotai or Zustand) layered on top of React Query. Every query response gets piped into the store; components read from the store instead of from React Query directly.
This pattern solves the consistency problem — but creates several worse ones.
What you've actually built
InMemoryCache is the result of years of work on correctness, garbage collection, pagination, and cache policies. A custom normalization layer built on top of React Query skips all of that and adds a new failure mode: the cache and the store can drift out of sync.If you're in this situation
The migration path depends on how deep the custom store goes:
- Still early: Migrate to Apollo Client or URQL. The normalized cache is the feature you need; these libraries provide it correctly.
- Custom store is deep: Remove the normalization layer, have components read directly from
useQuery, and use broad invalidation on mutations. Accept the trade-off: potential stale data is usually better than two sources of truth. - Mutation frequency is the issue: Add optimistic updates in React Query (
onMutate+ rollback) for the mutations that feel slow, rather than building a normalized store.
Decision
| Situation | Recommended approach |
|---|---|
| REST API, few queries per entity | React Query with targeted invalidation |
| GraphQL, mutations are infrequent | React Query with broad prefix invalidation (e.g. invalidate all ['user'] queries) |
| GraphQL, same entity appears in many queries | Apollo Client or URQL + graphcache |
| GraphQL, real-time updates via WebSocket | Apollo Client (subscriptions built-in) or URQL |
| Need instant UI on mutation, regardless of network | Optimistic updates with useMutation + onMutate rollback |
| React Query + custom Jotai/Zustand normalization store | Migrate to Apollo/URQL or remove the custom store — this is two sources of truth |