renderpx

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:

tsx
Loading...

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.

tsx
Loading...

When this is enough

For most apps: mutations are infrequent, the network is fast enough that a refetch is invisible, and the set of affected queries per mutation is small (2–4 keys). Invalidation is simple, debuggable, and requires no additional infrastructure. Start here.

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.

tsx
Loading...

What normalization buys you

Consistency across queries is automatic. A mutation that updates 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 ClientURQL + graphcache
Bundle size~45kB gzipped~15kB core + ~10kB exchange
Normalized cacheInMemoryCache (built-in)@urql/exchange-graphcache (opt-in)
DevtoolsApollo DevTools (Chrome extension)urql DevTools (Chrome extension)
PaginationrelayStylePagination / offsetLimitPaginationCustom resolvers in graphcache
Learning curveHigher — policies, reactive variables, cache APIsLower — more composable, smaller surface
Use whenLarge team, complex cache, existing Apollo ecosystemStarting 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.

tsx
Loading...

What you've actually built

You've rebuilt Apollo Client, worse. Apollo's 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

SituationRecommended approach
REST API, few queries per entityReact Query with targeted invalidation
GraphQL, mutations are infrequentReact Query with broad prefix invalidation (e.g. invalidate all ['user'] queries)
GraphQL, same entity appears in many queriesApollo Client or URQL + graphcache
GraphQL, real-time updates via WebSocketApollo Client (subscriptions built-in) or URQL
Need instant UI on mutation, regardless of networkOptimistic updates with useMutation + onMutate rollback
React Query + custom Jotai/Zustand normalization storeMigrate to Apollo/URQL or remove the custom store — this is two sources of truth

The rule of thumb

If you're using React Query with a REST API, invalidation is almost always enough. If you're using GraphQL and you find yourself maintaining a list of query keys to invalidate after every mutation, that's the signal to switch to a GraphQL client with a proper normalized cache.

Related