Skip to content
C Codeloom
GraphQL

GraphQL Caching with Apollo Client

How Apollo Client's normalized cache works, why entity IDs matter, and the patterns for cache updates, refetches, and consistent UI after mutations.

·4 min read · By Codeloom
Intermediate 8 min read

What you'll learn

  • How Apollo normalizes responses into a flat cache
  • Why typename and id are the cache key foundation
  • Cache policies and field-level merge functions
  • Updating the cache after mutations
  • Choosing between refetch, optimistic, and manual updates

Prerequisites

  • Familiar with HTTP and databases

What and Why

Apollo Client ships with a normalized in-memory cache. Instead of storing each query response as a blob, it breaks responses into entities keyed by __typename:id and stores them in a flat map. The next time any query references the same entity, Apollo can serve it from the cache, merge new fields in, and update every active subscriber automatically.

This matters because GraphQL queries often overlap. A list view fetches User { id name }, then a detail view fetches User { id name email }. Without normalization, the two responses are independent; with normalization, the detail view enriches the entity and the list view updates with no extra code.

Mental Model

Picture the cache as a database with one big table: each row is an entity, keyed by type and ID. Queries are views over that table; they read selected fields and join references. When a mutation writes a field, every view that selected that field re-renders.

The implication: if your server returns entities without IDs, or if __typename is missing, Apollo cannot normalize, and the cache becomes a pile of unrelated query results. Stable IDs are the foundation.

Hands-on Example

A query and its cache shape:

query Feed {
  posts { id title author { id name } }
}

After the response lands, the cache holds:

ROOT_QUERY -> { posts: [ref:Post:1, ref:Post:2] }
Post:1     -> { id, title, author: ref:User:7 }
Post:2     -> { id, title, author: ref:User:9 }
User:7     -> { id, name }
User:9     -> { id, name }
   ROOT_QUERY
    |
 posts[]
  |    |
  v    v
Post:1   Post:2
  |        |
  v        v
User:7   User:9   <-- shared with other queries
Normalized cache: queries reference shared entities

After a likePost mutation, the simplest pattern is to return the updated entity:

mutation Like($id: ID!) {
  likePost(id: $id) { id likeCount }
}

Apollo merges likeCount into Post:1, and every query selecting likeCount on that post updates without a refetch.

For lists, mutations must update the list manually because Apollo cannot know whether a new item belongs:

useMutation(CREATE_POST, {
  update(cache, { data }) {
    cache.modify({
      fields: {
        posts(existing = [], { toReference }) {
          return [toReference(data.createPost), ...existing];
        },
      },
    });
  },
});

For pagination, configure a field policy:

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: { posts: relayStylePagination() },
    },
  },
});

Common Pitfalls

  • Forgetting to select id in a query. Without it Apollo cannot normalize and silently keeps duplicate data.
  • Custom ID fields not registered with keyFields. If your type uses uuid instead of id, Apollo treats every result as a new entity.
  • Mutations that change list membership without an update function. The cache holds the same array references and the UI stays stale.
  • Over-refetching after every mutation. It is simple but wastes bandwidth and creates UI flicker; cache writes are usually better.
  • Optimistic responses missing fields. If the optimistic shape is narrower than the real response, Apollo overwrites with undefined and React throws.
  • Treating the cache as a generic store. It is tied to GraphQL operations; local-only state belongs in reactive variables or a dedicated store.

Practical Tips

Always include id and __typename in selections. Most clients add __typename automatically; do not strip it in transports.

Prefer returning updated entities from mutations over manual cache rewrites. It is the cheapest, safest update path. Reserve cache.modify for collection changes.

Use refetchQueries sparingly. It is correct but blunt; prefer cache writes for hot paths and refetches for rare, complex flows.

For pagination, lean on the official relayStylePagination helper or write a clear merge function. Hand-rolled merge logic is the source of most cache bugs.

Type policies are powerful. Use them for custom keys, derived fields, and pagination, but document each one; they are invisible at the call site.

Inspect the cache during development with Apollo DevTools. Seeing entities and queries side by side teaches more about normalization than any tutorial.

Wrap-up

Apollo’s normalized cache turns overlapping queries into a coherent client-side database. Give every entity a stable ID, return updated entities from mutations, and reach for manual cache writes only for collections and pagination. Once normalization clicks, your UI stays consistent with surprisingly little glue code.