Apollo Client Cache Normalization: One Entity Graph for List, Detail, and Mutations
Learn how Apollo Client's InMemoryCache normalisation and type policies let list, detail, and mutation flows share one entity graph, with a concrete paginated feed example and a checklist for safe rollout.
23 Sept 2025, 20:29 UTC

The problem: duplicated data, stale UI
When a GraphQL client fetches a list of posts and later a detail view for a single post, the default behaviour of many caches is to store each response as a separate tree. If the list selects title and author while the detail view also asks for body, the client ends up with two copies of the same Post entity. A mutation that updates the title then has to invalidate or refetch both trees manually, otherwise one screen shows the new title and the other still shows the old one.
Apollo Client's InMemoryCache solves this by normalising every response into a flat entity store keyed by __typename plus an identifier. The same Post:123 object lives once, and every query that touches it reads the same reference. The engineering decision is not "use a cache" but "configure the cache so that list, detail, and mutation flows share one entity graph".
How normalisation works
- Each object returned from the server must carry
__typename(added automatically by Apollo Server and most GraphQL gateways). - The cache looks for an
idfield (or_id) on that type. If the field is missing or you need a composite key, you declarekeyFieldsin a type policy. - During a write, the cache extracts every object that has an identity, stores it under
TypeName:Key, and replaces the original tree nodes with a reference{ \"__ref\": \"Post:123\" }. - Reads follow the references, so any query that selects overlapping fields receives the same merged data.
Type policies: the control surface
Type policies live in the InMemoryCache constructor. The three knobs you will touch most often are:
- keyFields – defines the identity key for a type (e.g.
[\"id\"]or[\"tenantId\", \"itemId\"]). - merge – combines incoming paginated results with what's already in the cache. It runs on every write, so it must be pure and total.
- keyArgs – decides which field arguments become part of the cache key.
falsemeans "ignore all arguments", useful for a feed that never filters.
Worked example: a paginated feed with a detail view
Assume a schema with Query.feed(offset, limit): [Post!]! and Query.post(id: ID!): Post. The Post type has id, title, body, author. The goal: the feed shows a growing list as the user scrolls, the detail screen shows the full post, and a mutation updatePost instantly updates both screens.
import { InMemoryCache, ApolloClient, gql } from '@apollo/client';
const cache = new InMemoryCache({
typePolicies: {
Post: {
// Identity – every Post has a globally unique `id`
keyFields: ['id'],
},
Query: {
fields: {
// Feed pagination – concatenate pages, ignore offset/limit in the key
feed: {
keyArgs: false,
merge(existing = [], incoming) {
// `incoming` is the array returned by the current page
// Return a new array to keep the function pure
return [...existing, ...incoming];
},
},
},
},
},
});
const client = new ApolloClient({ cache, /* link */ });
Key points in the snippet:
Post.keyFields: ['id']guarantees a singlePost:<id>entry regardless of which fields a query selects.feed.keyArgs: falsetells the cache that the feed result set is the same logical list no matter the pagination arguments. Use this only when the backend truly returns a superset (e.g. a chronological feed with no filters).- The
mergefunction concatenates pages. It receives the current cached array (existing) and the new page (incoming) and must return a brand-new array; mutatingexistingwould break later reads.
Verifying the behaviour
- Run the feed query, then the detail query for a post that appears in the feed.
- Open Apollo Client Devtools → Cache → inspect the store. You should see exactly one entry like
Post: \"abc-123\"with all fields merged. - Execute
updatePostmutation. Both the feed list item and the detail view should reflect the new title without anyrefetchQueries. - Call
fetchMoreon the feed. Themergefunction runs; logexisting.lengthandincoming.lengthin development to confirm no duplicate IDs appear.
If the detail view still shows stale data, check that every fragment used by the detail query includes __typename and that the mutation's response also returns the updated Post object with the same id.
Trade-offs and limitations
- Identity collisions: If two tenants share the same numeric
id,keyFields: ['id']will merge them incorrectly. Use a composite key like['tenantId', 'id']or a namespaced UUID. - Aggressive
keyArgs: false: When a feed supports filters (category, author), ignoring arguments returns the wrong page for a filtered view. RestrictkeyArgsto the arguments that truly do not affect the result set. - Cache growth: Normalisation stores every entity forever unless you configure eviction or garbage collection. For very large datasets consider
cache.evictor a persistence layer with size limits. - Version drift: Apollo Client 3.x changed the signature of
merge(now receives an options object) and addedreadhelpers. The example above targets the 3.8+ API; verify the exact version you ship before copying.
Actionable checklist for a safe rollout
- Default every type to
keyFields: ['id'](or a composite key) and enforce__typenamein all fragments via a lint rule. - Add a single
mergeper paginated field; keep the function pure and test it with a small script that logsexistingandincoming. - Run the two-screen verification (list + detail) in CI using
cache.extract()to assert a single normalized entry per entity. - Document any
keyArgs: falsedecisions with a comment explaining why arguments are irrelevant. - Schedule a cache-size review after the first production month; enable eviction if the store exceeds your memory budget.
With these policies in place, the client-side entity graph becomes the single source of truth for every screen that touches a Post. Mutations, pagination, and navigation all read and write the same normalised objects, eliminating the classic "list shows X, detail shows Y" inconsistency without a fleet of refetchQueries.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.