Skip to content

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:

typescript
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:

typescript
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:

typescript
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:

graphql
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:

json
{
  "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 } }:

  1. Look up User:1 in the entity cache
  2. Read the name field
  3. Resolve posts references
  4. Look up Post:10 and Post:11
  5. Read title from each post
  6. Return complete data

Cache Policies ​

Control when the cache makes network requests:

cache-first (default) ​

Return cached data if available, otherwise fetch from network:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

graphql
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 ​