Caching 
Caching stores query results to eliminate redundant network requests and keep your UI responsive.
The Problem Without Caching 
Without caching, every component makes its own request:
const UserProfile = ({ userId }) => {
  const { data } = useQuery(GetUserQuery, { id: userId });
  return <div>{data.user.name}</div>;
};
const UserAvatar = ({ userId }) => {
  const { data } = useQuery(GetUserQuery, { id: userId });
  return <img src={data.user.avatar} />;
};Both components request the same user. Two network requests for identical data. If ten components display user information, ten requests fire.
Beyond redundancy, consistency becomes a problem. A mutation updates the user:
const { mutate } = useMutation(UpdateUserMutation);
await mutate({ id: userId, name: 'New Name' });The mutation succeeds, but components still display old data. They don't know to refetch. You manually track which queries need updating.
Document-Based Caching 
The simplest caching strategy stores entire query results:
Cache:
  GetUserQuery(id: "1") → { user: { id: "1", name: "Alice", email: "..." } }
  GetUserQuery(id: "2") → { user: { id: "2", name: "Bob", email: "..." } }This eliminates redundant requests. The second component reads from cache instead of making a network request.
But consistency problems remain. Two queries requesting the same user with different fields create separate cache entries:
Query A: user(id: "1") { name }
Query B: user(id: "1") { name, email }
Cache:
  Query A → { user: { name: "Alice" } }
  Query B → { user: { name: "Alice", email: "[email protected]" } }Update the user and both entries need manual invalidation. Document caching doesn't understand that both queries reference the same entity.
Normalized Caching 
Normalized caching stores entities separately from queries. The cache indexes data by entity type and ID:
Entities:
  User:1 → { id: "1", name: "Alice", email: "[email protected]" }
  User:2 → { id: "2", name: "Bob", email: "[email protected]" }
Queries:
  GetUserQuery(id: "1") → ref(User:1)
  GetUserQuery(id: "2") → ref(User:2)Queries store references to entities instead of copies of data. Multiple queries referencing the same entity share one copy in the cache.
Automatic Updates 
When a mutation updates an entity, the cache updates the stored entity. Every query referencing that entity automatically reflects the change:
await mutate({ id: '1', name: 'Alicia' });Cache updates:
User:1 → { id: "1", name: "Alicia", email: "[email protected]" }Every component displaying User:1 re-renders with the new name. No manual invalidation needed.
Partial Data 
Normalized caching handles partial data naturally. Different queries request different fields:
Query A: user(id: "1") { name }
Query B: user(id: "1") { name, email, avatar }The cache merges fields:
User:1 → { id: "1", name: "Alice", email: "[email protected]", avatar: "..." }Query A reads the name field. Query B reads all three. Both reference the same normalized entity.
How Normalization Works 
The cache processes responses in several steps:
Entity Identification 
Each object needs a unique identifier. GraphQL's id or _id fields serve this purpose:
type User {
  id: ID!
  name: String!
}The cache uses User:${id} as the cache key. An object without an id field can't be normalized and is stored inline with its parent.
Denormalization 
When processing a response, the cache extracts entities and creates references:
{
  "user": {
    "id": "1",
    "name": "Alice",
    "posts": [
      { "id": "10", "title": "Hello" },
      { "id": "11", "title": "World" }
    ]
  }
}Becomes:
User:1 → { id: "1", name: "Alice", posts: [ref(Post:10), ref(Post:11)] }
Post:10 → { id: "10", title: "Hello" }
Post:11 → { id: "11", title: "World" }Reading from Cache 
When reading, the cache resolves references recursively. A query requests user(id: "1") { name, posts { title } }:
- Look up User:1in the entity cache
- Read the namefield
- Resolve postsreferences
- Look up Post:10andPost:11
- Read titlefrom each post
- Return complete data
Cache Policies 
Control when the cache makes network requests:
cache-first (default) 
Return cached data if available, otherwise fetch from network:
const { data } = useQuery(GetUserQuery, { id: userId }, { fetchPolicy: 'cache-first' });Best for data that doesn't change frequently. Provides instant results from cache, only hitting the network for cache misses.
network-only 
Always fetch from network, update cache with results:
const { data } = useQuery(GetUserQuery, { id: userId }, { fetchPolicy: 'network-only' });Best for data that must be fresh. Bypasses the cache on initial load but updates it with results.
cache-only 
Only read from cache, never make network requests:
const { data } = useQuery(GetUserQuery, { id: userId }, { fetchPolicy: 'cache-only' });Best for offline scenarios or when you know data is already cached.
cache-and-network 
Return cached data immediately, then fetch from network:
const { data } = useQuery(GetUserQuery, { id: userId }, { fetchPolicy: 'cache-and-network' });Best for data that changes frequently. Shows instant results while ensuring freshness.
Cache Consistency 
Normalized caching maintains consistency automatically in most cases:
Mutations 
Mutations return updated entities. The cache merges changes automatically:
const { mutate } = useMutation(
  graphql(`
    mutation UpdateUserMutation($id: ID!, $name: String!) {
      updateUser(id: $id, input: { name: $name }) {
        id
        name
      }
    }
  `),
);The response contains id and name. The cache updates User:${id} with the new name.
Subscriptions 
Real-time updates flow through the cache:
const { data } = useSubscription(
  graphql(`
    subscription OnUserUpdated($id: ID!) {
      userUpdated(id: $id) {
        id
        name
      }
    }
  `),
  { id: userId },
);Each event updates the normalized entity. All components referencing that user re-render with fresh data.
Refetching 
Explicitly refetch queries when needed:
const { data, refetch } = useQuery(GetUserQuery, { id: userId });
await refetch();Refetching updates the cache with the latest data from the server.
Cache Limitations 
Normalized caching has constraints:
Lists 
The cache can't automatically update lists when entities are added or removed. Adding a new user requires manually updating queries that list users:
const { mutate } = useMutation(CreateUserMutation);
await mutate({ name: 'Charlie' });Existing GetUsersQuery results don't include Charlie. You must refetch or manually update the cache.
Computed Fields 
Fields without stable IDs can't be normalized. Computed fields like fullName are stored inline:
type User {
  id: ID!
  firstName: String!
  lastName: String!
  fullName: String!
}Updating firstName doesn't automatically recompute fullName. The server must return updated fullName in the mutation response.
Pagination 
Paginated data requires careful handling. Different pages of results are separate cache entries:
GetUsersQuery(first: 10, after: null) → [User:1, User:2, ...]
GetUsersQuery(first: 10, after: "cursor10") → [User:11, User:12, ...]Loading more results creates a new cache entry instead of appending to the existing list.
Cache Benefits 
Normalized caching provides several advantages:
- Instant Results - Cached data displays immediately without loading states
- Reduced Network Usage - Fewer requests conserve bandwidth and reduce costs
- Consistent State - Updates propagate automatically to all components
- Optimistic Updates - Update UI immediately, rollback if mutation fails
- Offline Support - Serve cached data when network is unavailable
These benefits compound in large applications where many components display overlapping data.
Next Steps 
- Modern GraphQL - Why GraphQL clients include caching
- Type Safety - How types ensure cache correctness
- Fragments - How fragments work with the cache
- Cache Exchange - Configure and customize caching behavior