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.
Table of Contents
- 1. Normalization: __typename and id as the foundation
- 2. Configuring typePolicies and keyFields
- 3. cache.readQuery and cache.writeQuery
- 4. cache.modify: targeted field updates without a refetch
- 5. Optimistic responses for instant UI feedback
- 6. Cache invalidation after mutations
- 7. Pagination in the cache: relayStylePagination and merge
- 8. Common pitfalls in the Apollo Client Cache
- 9. Update strategies compared side by side
- 10. Summary
- 11. FAQ
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.