Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Sep 8, 2026·14 min read

The Normalized Cache: Your Client's Replica of the Server Graph

Apollo Client's InMemoryCache is not a speed optimization — it is a flat-key replica of the server's data graph. Every cache inconsistency in production is a symptom of treating it as the former.

The Normalized Cache: Your Client's Replica of the Server Graph

Here is a production bug that takes most teams two days to debug. A useMutation call succeeds — the server returns HTTP 200, the mutation result has the updated data — and nothing on the screen changes. The component's useQuery is still showing the old value. No error, no loading indicator. Just stale data, silently wrong.
The fix, once you understand it, is one cache.modify call. But to understand why cache.modify is the fix — and why the intuitive fix (refetch()) is wrong in this scenario — you need to understand what the Apollo InMemoryCache actually is. Not "a cache that stores query results." That description is exactly what makes the bug happen.
A GraphQL client is not a data fetcher — it is a local replica of your server's data graph. The InMemoryCache is that replica: a flat, normalized object store where every entity lives at a __typename:id key, and every query is a read from that store. This article makes that model precise.

1. The Replica Model

The word "cache" is misleading. A cache stores responses and replays them. The InMemoryCache does something different: it normalizes every response into a flat entity store, and then reconstructs the response shape on every read.

1.1 What Normalization Means

Given this query response:
json
{
  "data": {
    "user": {
      "__typename": "User",
      "id": "1",
      "name": "Alice",
      "orders": [
        {
          "__typename": "Order",
          "id": "42",
          "total": 99.00
        }
      ]
    }
  }
}
The InMemoryCache does not store this nested object. It extracts every entity and stores each one at a flat key:
User:1    → { __typename: "User", id: "1", name: "Alice", orders: ["Order:42"] }
Order:42  → { __typename: "Order", id: "42", total: 99.00 }
When useQuery reads this data back, it reconstructs the nested shape from these flat keys on the fly.
Raw nested GraphQL response being normalized into a flat __typename:id keyed store — User:1 and Order:42 as separate entities
Raw nested GraphQL response being normalized into a flat __typename:id keyed store — User:1 and Order:42 as separate entities

1.2 Why This Is the Replica Model

If a different query also fetches User:1 — say, a sidebar that shows the current user's name — that query reads from the same User:1 entry in the store. There is exactly one copy of every entity, no matter how many queries have fetched it. When User:1.name changes (via mutation or subscription), every component that has read User:1.name re-renders automatically.
This is what "local replica" means: the store is a single authoritative copy of every entity the client has ever fetched, exactly as a replica database maintains a synchronized copy of its primary.
Mental Model Check
Every useQuery call is a read from the replica store, not a network request. The network request happens to populate the store. Once an entity is in the store, all queries for that entity read from the same flat entry — they share data, not copies of data.

2. Choosing Your Client

The normalized cache is Apollo Client's core differentiator. Before configuring it, verify it's the right tool:
ClientCache ModelBest For
Apollo Client 3.xNormalized InMemoryCacheGraph-shaped APIs, optimistic UI, fragment colocation
Relay v15+Normalized (Rust compiler, strictest)Maximum type safety, Relay-spec servers, large teams
TanStack Query + fetchResponse-level (not normalized)REST APIs, simple GraphQL, non-graph data shapes
Crucial Requirement
TanStack Query is not inferior to Apollo Client — it is the correct choice when your data is not a graph. If entities don't reference each other by ID, normalization adds complexity without benefit. Use TanStack Query for flat lists, paginated feeds, and REST-shaped APIs.
Relay v15's Rust compiler generates per-fragment TypeScript types with zero runtime overhead and enforces fragment colocation at the compiler level. For teams with strict type-safety requirements and a Relay-spec server, it is the gold standard. Apollo Client is the pragmatic choice for teams that need normalization without Relay's constraints.

3. keyFields: The Silent Cache-Miss Root Cause

Apollo's normalization model works by default when every entity has an id: ID! field. When schemas use non-standard identifiers, the default breaks silently.

3.1 The Default Assumption

By default, InMemoryCache identifies entities by __typename + id. For User:1, it looks for __typename: "User" and id: "1". If both are present in the response, normalization works automatically.

3.2 Non-Standard Identifiers

Many schemas use uuid, _id (MongoDB), or composite keys:
typescript
// ❌ Schema returns uuid instead of id — Apollo can't find the key
type Product {
  uuid: ID!
  title: String!
}

// Apollo defaults to looking for `id` — not found
// Every Product is stored at a random key: Product:{} (object reference)
// Two queries for the same Product create two separate, unsynchronized cache entries

3.3 Configuring keyFields

Fix this in InMemoryCache type policies:
typescript
const cache = new InMemoryCache({
  typePolicies: {
    Product: {
      keyFields: ['uuid'],  // ✅ Use uuid as the identifier
    },
    // Composite key: both fields together form the identity
    OrderItem: {
      keyFields: ['orderId', 'productId'],
    },
  },
})
Performance / Safety Warning
If keyFields is misconfigured, Apollo silently stores entities at a non-canonical key. Two useQuery calls for the same entity create two unsynchronized entries — mutations update one but not the other. This is the root cause of most "cache inconsistency" bugs.

4. Field Policies: merge and read Functions

Field policies let you control how the cache handles specific fields — particularly useful for pagination, where Apollo can't automatically know how to combine paginated results.

4.1 The Pagination Problem

typescript
// First page fetch:
query { products(first: 10, after: null) { edges { node { id title } } pageInfo { endCursor } } }

// Second page fetch (fetchMore):
query { products(first: 10, after: "cursor123") { edges { node { id title } } pageInfo { endCursor } } }
By default, Apollo treats these as two separate field results and overwrites the first with the second. The correct behavior is to merge them.

4.2 Cursor-Based Pagination with fetchMore

typescript
const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        products: {
          // keyArgs: which arguments make this a different field (cursor changes, so exclude it)
          keyArgs: ['category'],

          merge(existing, incoming, { args }) {
            const existingEdges = existing?.edges ?? []
            const incomingEdges = incoming.edges

            return {
              ...incoming,
              edges: [...existingEdges, ...incomingEdges],
            }
          },
        },
      },
    },
  },
})
Then fetch the next page from a component:
typescript
function ProductList() {
  const { data, fetchMore } = useQuery(GET_PRODUCTS, {
    variables: { first: 10, category: 'electronics' },
  })

  const loadMore = () => {
    fetchMore({
      variables: {
        first: 10,
        after: data.products.pageInfo.endCursor,
      },
    })
    // ✅ The merge function in the type policy accumulates pages in the cache
  }

  return (
    <>
      {data?.products.edges.map(({ node }) => <ProductCard key={node.id} product={node} />)}
      <button onClick={loadMore}>Load more</button>
    </>
  )
}
Pro Tip & Optimization
The read function lets you transform cached data before it's returned to a component — for example, sorting a cached list by a field without a network request. Use read for derived views of existing cache data.

5. Cache Invalidation: The Three Operations

This is where the opening bug is finally explained. After a mutation updates User:1.name on the server, the client's replica has stale data. There are three operations to update it — and only one is correct for most mutations.

5.1 cache.modify — Surgical Field Update (Correct Default)

typescript
const [updateUser] = useMutation(UPDATE_USER, {
  onCompleted({ updateUser }) {
    cache.modify({
      id: cache.identify(updateUser),  // resolves to "User:1"
      fields: {
        name: () => updateUser.name,
        // Other fields are UNTOUCHED — orders, avatarUrl, etc. stay as-is
      },
    })
  },
})
cache.modify writes to one field in one entity's cache entry. It is surgical, atomic, and correct. All other components reading User:1.name re-render automatically.
updateQuery (deprecated, overwrites entire entity) vs cache.modify (surgical field-level update — correct)
updateQuery (deprecated, overwrites entire entity) vs cache.modify (surgical field-level update — correct)

5.2 cache.evict — Delete a Node

Use cache.evict when an entity is deleted server-side:
typescript
const [deleteUser] = useMutation(DELETE_USER, {
  onCompleted({ deleteUser }) {
    cache.evict({ id: cache.identify({ __typename: 'User', id: deleteUser.id }) })
    cache.gc()  // ← Always run GC after eviction to remove dangling references
  },
})

5.3 refetchQueries — When the Surface Is Too Broad

typescript
const [placeOrder] = useMutation(PLACE_ORDER, {
  // ✅ Use refetchQueries when a mutation touches many entities unpredictably
  refetchQueries: [{ query: GET_USER_ORDERS }],
})
refetchQueries fires a new network request for the listed queries after the mutation completes. It is not surgical — it re-fetches the entire query result. Use it when the mutation's side effects are too broad to enumerate precisely (e.g., placing an order affects inventory, recommendations, and the user's order history).
Performance / Safety Warning
Never use updateQuery (it overwrites the entire cache entry and is deprecated in Apollo Client 3.x) or cache.writeQuery (it bypasses normalization and creates unsynchronized copies of entities). The correct post-mutation operations are cache.modify, cache.evict, or refetchQueries.

6. Cache GC: Preventing SPA Memory Leaks

Evicted entities don't disappear from the store immediately. They become unreachable — no query root points to them — but they remain in memory until garbage collection runs.
typescript
// After every eviction:
cache.evict({ id: cache.identify(deletedUser) })
cache.gc()  // Removes all unreachable entries

// In a long-lived SPA, call gc() on route transitions:
useEffect(() => {
  return () => {
    cache.gc()
  }
}, [pathname])
The retain function prevents a node from being garbage-collected even when no active query references it — useful for entities that should stay in the replica across route changes:
typescript
// Keep User:1 in the replica even when the profile page unmounts
const releaseUser = cache.retain(cache.identify({ __typename: 'User', id: '1' }))

// Later, allow GC to collect it:
releaseUser()

Summary

ConceptRule
Cache as replicaThe normalized cache is a flat object store, not a hierarchical response tree — every useQuery call reconstructs the hierarchy from __typename:id keys on each read.
Mutation updatesCache inconsistency after mutations always means the replica diverged from the server graph — the fix is surgical (cache.modify), never a full re-fetch unless the mutation touches an unpredictable surface.
keyFieldskeyFields must be configured whenever a schema uses a non-standard identifier — the default id field assumption is the silent cause of most Apollo cache-miss bugs.
PaginationfetchMore + cursor-based field policy merge is the canonical pagination pattern; updateQuery is deprecated and will not correctly handle concurrent mutations.
Cache GCCall cache.gc() on route transitions in long-lived SPAs — without it, evicted nodes accumulate as unreachable memory over the session lifetime.

What's Next

In Part 3, we configure the Apollo Client itself — the ApolloLink chain that sits between your components and the HTTP layer. Auth, retry, error interception, and logging all plug into this chain, and the order they're wired in is a correctness constraint, not a preference.
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#GraphQL#Apollo Client#InMemoryCache#Caching#Normalized Cache#React#TypeScript
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.