normalizing the GraphQL cache correctly
Apollo Client automatically normalizes GraphQL responses into a flat object graph, but without clean type policies and thoughtful cache updates, stale views appear quickly. This article shows how InMemoryCache really works, how custom cache keys are defined with keyFields, and how mutations, pagination and debugging play together in practice.
Table of contents
- 1. Why cache normalization matters with Apollo Client
- 2. InMemoryCache: how Apollo Client normalizes
- 3. Type policies: custom cache keys with keyFields
- 4. Fragments: keeping queries consistent and reusable
- 5. Mutations: update function, cache.modify and cache.writeQuery
- 6. optimisticResponse: instant feedback without waiting
- 7. Pagination in the cache: relayStylePagination and field policies
- 8. Debugging: Apollo DevTools, cache.gc() and common failure patterns
- 9. Apollo Client compared to other data layer solutions
- 10. Summary
- 11. FAQ
1. Why cache normalization matters with Apollo Client
Apollo Client is more than a fetch wrapper for GraphQL, because it breaks down every response into a normalized object graph and keeps that graph consistent across all components. Someone editing a user in a list sees the change without an extra refetch in the detail view too, provided the cache was normalized correctly. This exact effect is what distinguishes Apollo Client from a simple fetch and store approach, and it is the real reason teams accept the added complexity.
The catch is that normalization only works if Apollo can identify every object uniquely. If an ID is missing from a query result, or a custom ID is configured incorrectly, several objects that are actually identical fall apart in the GraphQL cache, and components show conflicting data. The result is bugs that only reproduce sporadically, because they depend on the order in which queries run. The following sections show how to systematically avoid these traps, from the basics of normalization to pagination and debugging.
2. InMemoryCache: how Apollo Client normalizes
InMemoryCache is the default cache implementation of Apollo Client and works on a simple but powerful principle: every object with a __typename and an id (or _id) is stored as its own entry in the cache, referenced through a key in the format Typename:id. A query that returns an array of posts with nested authors is therefore not stored as a nested blob, but broken down into individual, flat objects that reference each other.
This flat object graph is why Apollo Client can propagate updates so efficiently. If an author object is updated through a different query or mutation, every component referencing that author anywhere automatically notices the change, no manual refetch required. The precondition is that the query actually requests the id field. A common beginner mistake is leaving id out of a query because it is not needed in the UI, which completely disables normalization for that object and turns it into an unidentifiable, embedded object.
// apollo-client.ts — Basic InMemoryCache setup
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
export const client = new ApolloClient({
link: new HttpLink({ uri: "https://api.mironsoft.de/graphql" }),
cache: new InMemoryCache({
// Apollo Client uses __typename + id by default to build cache keys
// Custom type policies override this per type (see next section)
typePolicies: {
Query: {
fields: {
// Merge function for a paginated field, avoids overwriting on refetch
products: {
keyArgs: ["category", "sortBy"],
},
},
},
},
}),
defaultOptions: {
watchQuery: { fetchPolicy: "cache-and-network" },
},
});
3. Type policies: custom cache keys with keyFields
Not every API provides a simple numeric id. Some types are identified by composite keys, for example an order line through orderId and lineNumber together. For these cases, Apollo Client offers keyFields in type policies, which let you define exactly which fields together form a type's unique key. Without this configuration, Apollo would either use an incorrect default ID or treat the object as not normalizable, leading to duplicate cache entries for the same logical object.
Another important tool is read and merge functions at the field level. A read function can, for example, deliver a computed value such as a formatted price directly from the cache, without the server having to send that value. A merge function decides how new data is merged with already existing field values, which is especially crucial for arrays and pagination. Whoever configures these policies deliberately prevents the most common source of errors in the GraphQL cache: a refetch unintentionally overwriting an already loaded and correctly sorted list.
// cache-policies.ts — Custom keyFields for composite identifiers
import { InMemoryCache } from "@apollo/client";
const cache = new InMemoryCache({
typePolicies: {
OrderLine: {
// Composite key: orderId + lineNumber uniquely identify an OrderLine
keyFields: ["orderId", "lineNumber"],
},
Product: {
fields: {
// Computed field, read directly from cached price data
formattedPrice: {
read(_, { readField }) {
const price = readField("priceCents");
return typeof price === "number"
? `${(price / 100).toFixed(2)} EUR`
: null;
},
},
},
},
User: {
// Users have no numeric id in this API, only a UUID string field "uuid"
keyFields: ["uuid"],
},
},
});
4. Fragments: keeping queries consistent and reusable
GraphQL fragments are the second foundation for a clean GraphQL cache. A fragment bundles the fields a component needs for its rendering into a named, reusable unit. When two different queries include the same fragment for the type Product, Apollo Client guarantees that both queries request exactly the same fields and thus fill the same cache entry consistently. If fields a component actually needs are missing from a query, so called partial cache hits occur, which force Apollo into a silent network refetch.
Fragment colocation, meaning defining the fragment directly next to the component that uses it, prevents data requirements and rendering logic from drifting apart. If a component changes and needs an extra field, the fragment is extended right where it is used, without having to search through a central, unwieldy query file. This practice scales considerably better than monolithic queries and is one of the main reasons Apollo Client stays maintainable in large React codebases.
5. Mutations: update function, cache.modify and cache.writeQuery
A mutation in Apollo Client automatically updates the cache entry of the returned object by default, provided the response contains the same fields as an existing query. For anything beyond that, such as removing an item from a list or updating a counter, an explicit update function is needed. This function gets access to the cache and can use cache.modify to change individual fields precisely, without refetching the whole affected query.
cache.modify is more precise than a full refetch, because it only changes the affected field value and automatically re-renders every component reading that value. For more complex cases where a completely new object needs to be inserted into an existing list, cache.writeQuery or cache.writeFragment is used. Whoever ignores these tools and instead triggers a full refetch of the parent list on every mutation gives away the central performance advantage of Apollo Client and produces unnecessary network load.
// DeleteTodoButton.tsx — Cache update after a mutation without a full refetch
import { useMutation, gql } from "@apollo/client";
const DELETE_TODO = gql`
mutation DeleteTodo($id: ID!) {
deleteTodo(id: $id) {
id
}
}
`;
function DeleteTodoButton({ todoId }: { todoId: string }) {
const [deleteTodo] = useMutation(DELETE_TODO, {
update(cache, { data }) {
if (!data?.deleteTodo) return;
cache.modify({
fields: {
todos(existingRefs = [], { readField }) {
// Remove the deleted todo's reference from the cached list
return existingRefs.filter(
(ref) => readField("id", ref) !== data.deleteTodo.id
);
},
},
});
// Explicitly evict the object itself to free memory
cache.evict({ id: cache.identify({ __typename: "Todo", id: todoId }) });
cache.gc();
},
});
return <button onClick={() => deleteTodo({ variables: { id: todoId } })}>Delete</button>;
}
6. optimisticResponse: instant feedback without waiting
optimisticResponse is the built-in answer of Apollo Client to the problem that users would otherwise have to wait for every mutation until the server responds. Instead of updating the UI only after the server responds, a preliminary result is immediately written into the cache, shaped exactly like the real response. As soon as the server actually responds, Apollo Client automatically replaces the optimistic result with the real response, without the component having to handle that switch manually.
The decisive difference to React's built in useOptimistic hook is that Apollo integrates this mechanism directly into the global cache. If an optimistically updated object changes, every component in the tree sees the new state immediately, not just the component that triggered the mutation. If the mutation fails, Apollo automatically rolls back the optimistic change and restores the previous cache state, making manual rollback handling unnecessary.
7. Pagination in the cache: relayStylePagination and field policies
Pagination is where most teams first come into deep contact with field policies, because a naive configuration overwrites an already loaded list on every refetch instead of appending new pages. Apollo Client provides the helper function relayStylePagination for this, which supplies a correct merge function for Relay compatible connections with edges and pageInfo, correctly appending new pages to existing entries instead of replacing them.
For offset based pagination without Relay conventions, a custom merge function can be written that determines the correct position in the array based on offset and limit arguments. It is important to set keyArgs correctly so Apollo treats filters such as different sort orders or categories as separate cache entries instead of accidentally mixing them. Without this configuration, inconsistent lists appear as soon as users switch between filters.
// pagination-policy.ts — Relay-style pagination with correct merge behavior
import { InMemoryCache } from "@apollo/client";
import { relayStylePagination } from "@apollo/client/utilities";
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Handles edges/pageInfo merging automatically, keyed by filter args
articles: relayStylePagination(["category"]),
},
},
},
});
// Component side: fetchMore appends the next page via the merge function above
function ArticleList() {
const { data, fetchMore } = useQuery(GET_ARTICLES, {
variables: { category: "react", first: 20 },
});
const loadMore = () => {
fetchMore({
variables: { after: data?.articles.pageInfo.endCursor },
});
};
return null; // rendering omitted for brevity
}
8. Debugging: Apollo DevTools, cache.gc() and common failure patterns
The Apollo Client DevTools browser extension shows the entire normalized cache as a searchable tree and is the first tool for any debugging problem around Apollo Client. The built in explorer lets you test queries directly against the running cache without changing code, and you immediately see whether an expected field is actually normalized or sitting embedded, unreferenced, in the cache.
A typical failure pattern is growing memory usage from orphaned cache entries that are no longer referenced after a cache.evict() call, but remain in memory without cache.gc(). cache.gc() removes every object no active query references anymore, and should be run after every manual evict call. A second common problem is conflicting displays between two components, which usually trace back to missing id fields or incorrectly configured keyFields, see section three.
9. Apollo Client compared to other data layer solutions
Apollo Client is not the only way to consume GraphQL in React, and the choice depends heavily on team size, bundle size requirements and the desired control over the cache. The following table compares the most important alternatives, each with a focus on normalization and cache control.
| Solution | Normalization | Bundle size | Use case |
|---|---|---|---|
| Apollo Client | Automatic, configurable through type policies | Medium to large | Large teams, complex data models |
| Relay | Strict, enforced at compile time | Medium | Very large codebases, high discipline |
| urql | Optional, swappable exchanges | Small | Lightweight applications |
| TanStack Query + fetch | None, cache per query key | Small | REST heavy or mixed APIs |
| graphql-request + custom cache | Manual | Minimal | Scripts, simple integrations |
For teams already heavily invested in GraphQL and maintaining several connected views with overlapping data, Apollo Client remains the solution with the widest feature set for cache control. Relay enforces even stricter discipline, but demands a compiler step and a steeper learning curve. Teams using GraphQL only occasionally alongside mostly REST endpoints often fare better with TanStack Query and a slim GraphQL client, since the normalization overhead disappears.
Mironsoft
React, GraphQL and data layer architecture from a single source
Apollo Client that reliably delivers consistent data?
We configure type policies, pagination strategies and mutation updates for your GraphQL cache, and fix conflicting views caused by faulty normalization.
Cache audit
Analysis of existing type policies and finding missing keyFields
Pagination setup
relayStylePagination and custom merge functions for lists and infinite scroll
Migration
Moving from manual refetches to cache.modify and optimisticResponse
10. Summary
Apollo Client normalizes GraphQL responses into a flat object graph, provided every object is uniquely identifiable through __typename and id. Type policies with keyFields solve the case of composite keys, while read and merge functions cleanly control computed fields and pagination. Fragments keep queries consistent across components and prevent partial cache hits that lead to silent refetches.
Mutations should use update functions with cache.modify instead of refetching entire parent lists, and optimisticResponse delivers instant UI feedback with automatic rollback on failure. For pagination, relayStylePagination is the standard path for Relay compatible APIs, while everything else needs a custom merge function with correctly set keyArgs. The Apollo DevTools and cache.gc() round out the toolset for the everyday practice of working with Apollo Client.
Apollo Client cache normalization, the essentials at a glance
Normalization
Every object needs a __typename and id, otherwise it is stored as an embedded, unreferenceable object.
Type policies
keyFields for composite keys, read/merge for computed fields and pagination.
Mutations
update with cache.modify instead of a full refetch, optimisticResponse for instant feedback.
Debugging
Apollo DevTools for inspecting the cache, cache.gc() after every manual evict.