Apollo Client Cache: Normalization and Update Strategies
AI generated
{ }
type
GraphQL · Apollo Client · InMemoryCache · React
Apollo Client Cache: Normalization and Update Strategies
from __typename+id to the optimistic response

The Apollo Client Cache is far more than a plain response store: it normalizes every object by __typename and id into a flat graph, keeps queries consistently in sync, and allows targeted updates without a refetch. Anyone who understands typePolicies, cache.modify, and optimistic responses builds noticeably faster and more consistent React applications.

18 min read typePolicies · cache.modify · optimistic response · pagination Apollo Client 3.x · InMemoryCache

1. Normalization: __typename and id as the foundation

The key difference between the Apollo Client Cache and a naive response cache lies in normalization. Instead of storing every query response as a self-contained, nested JSON object, the Apollo Client Cache breaks each response down into individual entities and stores them flat inside an internal map. By default, the key for each entity is composed of __typename and id, for example Product:42. Apollo adds __typename to every query automatically, so developers never have to write it themselves.

This normalization brings one decisive advantage: if the same entity appears in two completely different queries, say a product list and a product detail page, it still ends up in the Apollo Client Cache only once. When a field on that entity changes through a mutation, every component that references it anywhere automatically sees the updated data. Without normalization, every query would need to be reloaded separately to guarantee consistency. The Apollo Client Cache solves this problem structurally, not through manual bookkeeping.

One important detail: entities without an id field are not normalized by default and are instead stored as an embedded object under the path of the parent query. This commonly affects value objects such as Address or Money, which have no identity of their own. For such types, keyFields: false can be set explicitly so they are never normalized on their own, which avoids confusion during debugging sessions.

2. Configuring typePolicies and keyFields

typePolicies is the central place to adjust the Apollo Client Cache's default behavior per GraphQL type. keyFields lets you define which fields determine a type's cache identity instead of id. This becomes necessary when a backend uses composite keys, for example a combination of sku and storeCode, or when a type provides no id field at all but does expose some other unique field.

A common case in Magento-adjacent headless frontends: the GraphQL type SimpleProduct does return an id, but it is store-view-specific and collides between different language versions. With keyFields: ["sku"] in the Apollo Client Cache, you normalize by SKU instead, which stays stable across store views. Without this adjustment, the same article would be treated as two independent cache entries across two language versions, which can lead to inconsistent cart state.


// apollo-client.ts — configure normalization behavior per type
import { ApolloClient, InMemoryCache } from "@apollo/client";

const cache = new InMemoryCache({
  typePolicies: {
    Product: {
      // Normalize by SKU instead of the store-view-specific id field
      keyFields: ["sku"],
    },
    Money: {
      // Value object without its own identity — always embed, never normalize
      keyFields: false,
    },
    CartItem: {
      // Composite key: same product can appear once per configured option set
      keyFields: ["id", "configuredOptions", ["optionId", "value"]],
    },
    Query: {
      fields: {
        // See section 7 for the merge function behind this field policy
        products: {
          keyArgs: ["categoryId", "sortBy"],
        },
      },
    },
  },
});

export const client = new ApolloClient({
  uri: "/graphql",
  cache,
});

The last block in the example shows keyArgs, which is closely related to keyFields but operates at the field level instead of the type level: it determines which query arguments create a distinct cache row for a paginated or filtered field. Together, both mechanisms form the foundation that lets the Apollo Client Cache go beyond simple ID mapping and adapt to real backend data models.

3. cache.readQuery and cache.writeQuery

Besides automatic reads through useQuery, the Apollo Client Cache offers imperative access via cache.readQuery and cache.writeQuery, which behave exactly like a normal GraphQL query but operate directly against local state without triggering a network request. This is especially useful inside mutation callbacks when you want to read the current state of a list, append an item, and write the result back.

By default, cache.readQuery throws an error if parts of the requested data are missing from the cache. For tolerant reads where missing fields shouldn't be a hard failure, cache.diff exists with the optimistic: true option. In practice, though, readQuery followed by writeQuery is enough for most update scenarios, as long as you make sure the query in question has already run once and exists in the Apollo Client Cache.


// Reading and writing a query directly against the Apollo Client cache
import { gql, useMutation } from "@apollo/client";

const GET_WISHLIST = gql`
  query GetWishlist {
    wishlist {
      id
      items {
        id
        product { sku name }
      }
    }
  }
`;

const ADD_TO_WISHLIST = gql`
  mutation AddToWishlist($sku: String!) {
    addToWishlist(sku: $sku) {
      id
      product { sku name }
    }
  }
`;

function useAddToWishlist() {
  const [addToWishlist] = useMutation(ADD_TO_WISHLIST, {
    update(cache, { data }) {
      const newItem = data.addToWishlist;

      // Read current state — throws if GET_WISHLIST was never fetched
      const existing = cache.readQuery({ query: GET_WISHLIST });
      if (!existing) return;

      // Write the merged result back without a network round trip
      cache.writeQuery({
        query: GET_WISHLIST,
        data: {
          wishlist: {
            ...existing.wishlist,
            items: [...existing.wishlist.items, newItem],
          },
        },
      });
    },
  });

  return addToWishlist;
}

A detail that's easy to miss: cache.writeQuery triggers exactly the same React re-renders as a real network response. Every component observing GET_WISHLIST via useQuery updates immediately, without needing to know anything about the update mechanism itself. That's the real value of imperative access to the Apollo Client Cache: the UI stays synchronized against a single source of truth.

4. cache.modify: targeted field updates without a refetch

For surgical changes to individual fields on an already-normalized entity, cache.modify is the more precise tool compared to readQuery/writeQuery. Instead of reassembling an entire query, cache.modify addresses a specific object through its ID and defines modifier functions per field that receive the previous value and return the new one. This is considerably less error-prone, since you can't accidentally overwrite fields you never intended to touch.

A classic example is bumping a counter after a mutation, say likes or stock quantity. Instead of reloading the entire product list, cache.modify reaches directly into the affected Product object in the Apollo Client Cache and increments just that one field. Removing an item from a referenced list, for example after deleting a comment, works the same way, by having the modifier filter the reference out of the array.


// Targeted field updates in the Apollo Client cache — no refetch needed
import { gql, useMutation } from "@apollo/client";

const TOGGLE_LIKE = gql`
  mutation ToggleLike($productId: ID!) {
    toggleLike(productId: $productId) {
      id
      liked
    }
  }
`;

function useToggleLike() {
  const [toggleLike] = useMutation(TOGGLE_LIKE, {
    update(cache, { data }, { variables }) {
      cache.modify({
        id: cache.identify({ __typename: "Product", id: variables.productId }),
        fields: {
          likeCount(existing = 0) {
            return data.toggleLike.liked ? existing + 1 : existing - 1;
          },
          liked() {
            return data.toggleLike.liked;
          },
        },
      });
    },
  });

  return toggleLike;
}

// Removing an item from a normalized list via cache.modify
function removeCommentFromCache(cache, commentId) {
  cache.modify({
    fields: {
      comments(existingRefs = [], { readField }) {
        return existingRefs.filter(
          (ref) => readField("id", ref) !== commentId
        );
      },
    },
  });
}

cache.identify is the helper that computes the same internal cache key from an object with __typename and id that the Apollo Client Cache uses internally, respecting any configured keyFields. Calling cache.modify without an id targets the ROOT_QUERY object, which is correct for root-level fields like comments in the second example. After any cache.modify call, the DELETE sentinel from @apollo/client can also be used to remove a field entirely instead of merely overwriting it.

5. Optimistic responses for instant UI feedback

Network latency is noticeable on every mutation whenever the UI only reacts after the server response arrives. The Apollo Client Cache solves this UX problem with optimistic responses: you hand the mutation a predicted response that gets written into the cache immediately, before the real server response even arrives. As soon as the actual response comes in, Apollo automatically replaces the optimistic version with the real one, and if the mutation fails, the optimistic state is automatically rolled back.

What matters is that the optimistic response must have exactly the same shape as the real mutation response, including __typename for every contained object, otherwise the Apollo Client Cache cannot normalize it correctly and the values end up in the wrong place. For objects whose final ID is only assigned by the server, for example when creating a new comment, a temporary negative ID or a UUID is used as a placeholder, which then gets replaced by the real ID once the actual response arrives.


// Optimistic response — UI updates instantly, before the server replies
import { gql, useMutation } from "@apollo/client";

const ADD_COMMENT = gql`
  mutation AddComment($postId: ID!, $text: String!) {
    addComment(postId: $postId, text: $text) {
      id
      text
      author { id name }
      createdAt
    }
  }
`;

function useAddComment(currentUser) {
  const [addComment] = useMutation(ADD_COMMENT, {
    optimisticResponse: (variables) => ({
      addComment: {
        __typename: "Comment",
        id: `temp-${Date.now()}`,
        text: variables.text,
        author: {
          __typename: "User",
          id: currentUser.id,
          name: currentUser.name,
        },
        createdAt: new Date().toISOString(),
      },
    }),
    update(cache, { data }, { variables }) {
      cache.modify({
        id: cache.identify({ __typename: "Post", id: variables.postId }),
        fields: {
          comments(existingRefs = [], { toReference }) {
            return [...existingRefs, toReference(data.addComment)];
          },
        },
      });
    },
  });

  return addComment;
}

An important side effect: with optimistic responses, the update function runs twice, once with the optimistic data and a second time with the real server data. The code inside update therefore has to be idempotent and cannot rely on side effects that are only meant to happen once. This is one of the subtler pitfalls when working with the Apollo Client Cache, and one that's easy to miss in code review.

6. Cache invalidation after mutations

After a mutation, the Apollo Client Cache generally offers three strategies for keeping affected data current: refetchQueries, a manual update function, or cache.evict combined with cache.gc. refetchQueries is the simplest to implement, but it costs extra network requests and quickly becomes expensive on complex pages with many active queries. It works best when the server computes complex derived values that can't be reconstructed on the client.

The manual update function, as shown in the earlier sections, avoids extra requests entirely, but requires more code and a precise understanding of the affected cache structure. cache.evict, in turn, removes an entity or a field completely from the Apollo Client Cache, for example after deleting a record. After an evict call, orphaned references are often left behind, which only get cleaned up through an explicit cache.gc() garbage-collection call.


// Three invalidation strategies compared in code
import { gql, useMutation } from "@apollo/client";

const DELETE_PRODUCT = gql`
  mutation DeleteProduct($id: ID!) {
    deleteProduct(id: $id) {
      success
    }
  }
`;

// Strategy A: refetchQueries — simple, costs an extra round trip
const [deleteProductRefetch] = useMutation(DELETE_PRODUCT, {
  refetchQueries: ["GetProductList"],
  awaitRefetchQueries: true,
});

// Strategy B: manual update — no extra request, more code
const [deleteProductManual] = useMutation(DELETE_PRODUCT, {
  update(cache, _result, { variables }) {
    cache.modify({
      fields: {
        products(existingRefs = [], { readField }) {
          return existingRefs.filter(
            (ref) => readField("id", ref) !== variables.id
          );
        },
      },
    });
  },
});

// Strategy C: cache.evict + cache.gc — removes the entity entirely
const [deleteProductEvict] = useMutation(DELETE_PRODUCT, {
  update(cache, _result, { variables }) {
    const id = cache.identify({ __typename: "Product", id: variables.id });
    cache.evict({ id });
    cache.gc();
  },
});

In practice, strategies B and C are often combined: cache.evict removes the object itself from the Apollo Client Cache, while an accompanying cache.modify function strips the reference from every list that's still being actively observed. This combination avoids both unnecessary network requests and orphaned references, which would otherwise cause runtime errors when a component tries to render an already-deleted object.

7. Pagination in the cache: relayStylePagination and merge

Paginated fields are a special case in the Apollo Client Cache, because the same field returns a different result set depending on its arguments, yet still needs to be merged into one coherent overall list. Without dedicated configuration, the Apollo Client Cache overwrites the previous results of the same field on every new request by default, which immediately breaks infinite-scroll implementations: page two would replace page one without a trace.

For offset-based pagination, a custom merge function that combines existing and incoming results is often enough. For Relay-compliant connections with edges, node, and pageInfo, @apollo/client/utilities ships the ready-made helper relayStylePagination, which handles exactly this merging, cursor management included, without you having to write the merge logic yourself.


// Field policies for pagination in the Apollo Client cache
import { InMemoryCache } from "@apollo/client";
import { relayStylePagination } from "@apollo/client/utilities";

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        // Relay-style connection — cursor merging handled automatically
        products: relayStylePagination(["categoryId"]),

        // Offset-based pagination — custom merge function
        reviews: {
          keyArgs: ["productId"],
          merge(existing = [], incoming, { args }) {
            const offset = args?.offset ?? 0;
            const merged = existing ? existing.slice(0) : [];
            for (let i = 0; i < incoming.length; i++) {
              merged[offset + i] = incoming[i];
            }
            return merged;
          },
        },
      },
    },
  },
});

The second argument of relayStylePagination corresponds to keyArgs and determines which arguments create a distinct cache row, while pagination arguments like after or first are deliberately excluded so that every page flows into the same merged list. Without this configuration, the Apollo Client Cache ends up with independent, unmerged lists per argument combination, leading to entries that appear duplicated or seemingly vanish.

8. Common pitfalls in the Apollo Client Cache

The most frequent mistake is a missing or incorrect id in a query. If a component queries a field without also requesting id, the Apollo Client Cache cannot normalize the returned object and instead stores it embedded under the query path. If the same entity is queried elsewhere with id included, two separate copies of the same data end up in the cache and can go stale independently of each other. The rule of thumb: any query that includes an entity with its own id field should always request that id, even when the UI never displays it directly.

A second classic pitfall is duplicate network requests caused by inconsistent fetchPolicy settings. If one component uses network-only while another queries the same query with cache-first, both run independently, even though the Apollo Client Cache could theoretically serve both. Inconsistent variable objects are also a problem: two calls to the same query with variable objects that are content-identical but referenced differently can be treated as distinct cache entries depending on fetchPolicy.

Stale UI after a mutation almost always comes down to neither update nor refetchQueries being configured, combined with a mutation response containing fields that the Apollo Client Cache cannot automatically link to already-loaded lists. Apollo only auto-updates entities that already exist in the cache via id and __typename and whose fields appear one-to-one in the mutation response. New list entries, deleted objects, or changes to derived aggregate values, on the other hand, always require explicit handling.

9. Update strategies compared side by side

The four update strategies covered above differ noticeably in network overhead, complexity, and the situations where each is the right choice within the Apollo Client Cache. The table below summarizes the practically relevant differences.

Approach When to use Network overhead Complexity
cache.modify Changing a single field on a known entity (counter, flag) None Low
update function Inserting new list entries, changing several fields at once None Medium
cache.evict + gc Deleting an object entirely, cleaning up orphaned references None Medium
refetchQueries Server computes complex derived values that can't be reconstructed client-side High Low

As a rule of thumb: cache.modify and the manual update function should be the default choice in the Apollo Client Cache, because they require no extra requests and give the user instant feedback. refetchQueries remains the fallback for cases where server-side calculations are too complex to reproduce on the client, for example discount logic or tax calculations that depend on many factors at once.

Mironsoft

GraphQL frontends, Apollo Client architecture, and headless Magento integrations

An Apollo Client Cache that keeps your UI consistent?

We audit existing Apollo Client setups, remove unnecessary refetches, and build typePolicies, cache.modify strategies, and optimistic responses that make your React frontend noticeably faster.

Cache audit

Analysis of existing typePolicies, keyFields, and pagination field policies for inconsistencies

Update strategies

Replacing refetches with cache.modify and optimistic responses wherever it makes sense

Headless integration

Configuring the Apollo Client Cache cleanly for Magento GraphQL frontends

10. Summary

The Apollo Client Cache is not a simple in-memory store, but a normalized graph that lays entities out flat by __typename and id and keeps them consistent across every observing query. typePolicies with keyFields adapts this normalization to real backend data models, while cache.readQuery, cache.writeQuery, and above all cache.modify enable targeted, network-free updates of individual fields. Optimistic responses close the remaining gap in perceived latency by delivering UI feedback before the server response even arrives.

For cache invalidation after mutations, the rule holds: cache.modify and manual update functions are almost always the more efficient choice over refetchQueries, because they require no extra requests. relayStylePagination and custom merge functions solve the merging of paginated results in a structured way, rather than overwriting the previous page on every request. Anyone who applies these mechanisms deliberately avoids the most common pitfalls: missing id fields, inconsistent fetchPolicy combinations, and stale UI after unhandled mutations.

Apollo Client Cache: the essentials at a glance

Normalization

__typename + id normalize objects into a flat graph. keyFields adapts this to composite or alternative keys.

cache.modify

Targeted field changes on known entities without a refetch, the most efficient way to handle counters, flags, and list updates.

Optimistic responses

Instant UI feedback before the server response arrives. The update function runs twice, so it must be idempotent.

Pagination

relayStylePagination or a custom merge function prevent new pages from overwriting older results.

11. FAQ: Apollo Client Cache

1How does the Apollo Client Cache normalize data?
Every query response is broken down into individual entities and stored flat under a key made of __typename + id, by default something like Product:42. The same entity coming from different queries ends up in the cache only once.
2What happens without an id field in the query?
The object gets stored embedded under the query path instead of being normalized. If another query fetches the same entity with id included, two independent copies exist that can go stale differently.
3What is keyFields used for?
keyFields determines which fields establish cache identity instead of id, for example for composite keys. keyFields: false prevents standalone normalization entirely.
4cache.modify instead of refetchQueries?
Whenever the entity already exists in the cache and only individual fields need to change. Avoids the extra network request and updates every observing component immediately.
5What is an optimistic response?
A predicted mutation response written into the cache immediately, before the real server response arrives. Must have exactly the same shape including __typename, gets replaced on success, rolled back on failure.
6Why does update run twice?
With optimisticResponse enabled, update runs once optimistically and once with real data. The code must be idempotent and free of one-time side effects.
7New pages overwriting old results?
Use a custom merge function in typePolicies or relayStylePagination from @apollo/client/utilities. Without configuration, Apollo overwrites the field on every request.
8Difference between cache.evict and cache.modify?
evict removes an entity or field completely, modify only changes its value. Call cache.gc() after evict to clean up orphaned references.
9Stale data after a successful mutation?
Apollo only updates entities that already exist in the cache and whose fields appear in the response. Always handle new entries and aggregates explicitly through update or cache.modify.
10Duplicate requests from fetchPolicy?
Yes, different fetchPolicy values for the same query in different components run independently of each other. Consistent conventions across the project avoid this.