Apollo Client Cache Management: Type Policies, Pagination, and Optimistic UI
Master Apollo Client cache management: normalization, type policies, pagination, optimistic UI, SSR, and debugging best practices.
Image used for representation purposes only.
Why Apollo Client’s Cache Deserves First-Class Attention
Apollo Client’s InMemoryCache is more than a performance booster; it is a consistency engine for your UI. Done well, it turns disparate queries into a unified graph, eliminates redundant requests, powers instant optimistic updates, and keeps paginated lists coherent. Done poorly, it causes flicker, duplication, stale data, and race conditions. This guide distills battle‑tested practices for managing the cache with confidence.
How InMemoryCache Works (Normalization 101)
At the heart of Apollo Client is a normalized store:
- Entities are stored once, keyed by a globally unique identifier derived from __typename plus a primary key (e.g., Post:1).
- Queries decompose into references (like pointers) to those entities.
- When any field of an entity changes, all queries referencing it update reactively.
A minimal setup looks like this:
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
export const client = new ApolloClient({
link: new HttpLink({ uri: "/graphql" }),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
// field policies go here
},
},
},
}),
});
Defining Entity Identity with keyFields
Normalization hinges on unique IDs. Apollo uses __typename + keyFields (often id). If your schema uses uuid or slug, set it explicitly.
const cache = new InMemoryCache({
typePolicies: {
User: { keyFields: ["id"] },
Post: { keyFields: ["id"] },
// Composite keys:
CartItem: { keyFields: ["productId", "variantId"] },
// If the server returns no stable key, opt out (not recommended):
// SomeType: { keyFields: false }
},
});
Tips:
- Prefer stable server-provided IDs. Avoid client-generated keys unless you control both sides.
- For federated graphs, ensure each subgraph preserves consistent keyFields for shared types.
Field Policies: read, merge, keyArgs
Field policies customize cache behavior per field (on Query or on types):
- keyArgs: declares which arguments differentiate cache entries (default: all). Use false for cursor-based pagination fields to merge pages.
- merge: defines how incoming results combine with existing cached results.
- read: custom read logic; useful for derived fields or redirects.
Example: Cursor Pagination on Query.posts
import { InMemoryCache } from "@apollo/client";
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
keyArgs: ["filter", "sort"], // args that uniquely define the list identity
merge(existing = { edges: [], pageInfo: {} }, incoming, { args }) {
// Append or prepend based on args?.after/before
if (args?.after) {
return {
edges: [...existing.edges, ...incoming.edges],
pageInfo: incoming.pageInfo,
};
}
if (args?.before) {
return {
edges: [...incoming.edges, ...existing.edges],
pageInfo: incoming.pageInfo,
};
}
// No cursor (fresh list)
return incoming;
},
},
},
},
},
});
For typical cases, use Apollo helpers:
import { relayStylePagination, offsetLimitPagination } from "@apollo/client/utilities";
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
feed: relayStylePagination(["filter", "sort"]),
products: offsetLimitPagination(["categoryId", "search"]),
},
},
},
});
Updating the Cache After Mutations
You have four main tools. Choose the least invasive that keeps lists consistent.
- update function (recommended for list maintenance)
const [createPost] = useMutation(CREATE_POST, {
update(cache, { data }) {
const newPost = data?.createPost;
if (!newPost) return;
// Insert into Query.posts for the default filter
cache.modify({
fields: {
posts(existing = { edges: [] }) {
return {
...existing,
edges: [{ __ref: cache.identify(newPost) }, ...existing.edges],
};
},
},
});
},
});
- cache.modify (surgical edits)
cache.modify({
id: cache.identify({ __typename: "Post", id: postId }),
fields: {
likeCount(cached = 0) { return cached + 1; },
},
});
- writeQuery / writeFragment (overwrite snapshots)
cache.writeFragment({
id: cache.identify({ __typename: "Post", id: post.id }),
fragment: gql`
fragment PostLikes on Post { id likeCount }
`,
data: { id: post.id, likeCount: post.likeCount + 1 },
});
- refetchQueries (simplest, but network-bound)
Use when rules are complex or server-side lists must be the source of truth. Combine with awaitRefetchQueries for correctness.
Optimistic UI Without Tears
Optimistic updates make the UI respond instantly, then reconcile with the server’s response.
const [toggleLike] = useMutation(TOGGLE_LIKE, {
optimisticResponse: ({ postId, like }) => ({
toggleLike: {
__typename: "Post",
id: postId,
likeCount: like ? 1 : -1, // applied via update below
likedByMe: like,
},
}),
update(cache, { data }) {
const p = data?.toggleLike;
if (!p) return;
cache.modify({
id: cache.identify({ __typename: "Post", id: p.id }),
fields: {
likedByMe() { return p.likedByMe; },
likeCount(cached = 0) { return Math.max(0, cached + (p.likedByMe ? 1 : -1)); },
},
});
},
});
Tips:
- Always include __typename and stable ids in optimistic objects.
- Keep optimistic math idempotent to handle retries or race conditions.
Reactive Variables and Local-Only Fields
Reactive variables let you store client-only state that integrates with the cache without a schema server roundtrip.
import { makeVar, InMemoryCache, gql, useQuery } from "@apollo/client";
export const themeVar = makeVar<"light" | "dark">("light");
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
theme: {
read() { return themeVar(); },
},
},
},
},
});
const THEME = gql`query { theme @client }`;
function ThemeToggle() {
const { data } = useQuery(THEME);
// ...
}
Use local resolvers (read functions) to derive values or redirect fields (e.g., map Query.me to a cached User).
Fetch Policies and nextFetchPolicy
- cache-first: default for queries; best for stable data.
- network-only: always hit the server; use for admin dashboards or rapidly changing data.
- cache-and-network: render cached data, then update from network; great for timelines.
- no-cache: bypasses cache for reads; writes still update normalized entities unless configured otherwise.
- cache-only: fail if not present; useful in controlled UI flows.
Set a different policy after the first fetch:
useQuery(GET_FEED, {
fetchPolicy: "cache-first",
nextFetchPolicy: "cache-and-network",
});
Other knobs:
- returnPartialData: true to render incomplete but usable results.
- notifyOnNetworkStatusChange: true for finer loading states during pagination.
Eviction, Garbage Collection, and Reset
- cache.evict: remove an entity or a specific field.
- cache.gc: run garbage collection to drop unreachable entities.
- client.clearStore / client.resetStore: clear or refetch after auth changes.
// Remove a single Post from lists and store
cache.evict({ id: cache.identify({ __typename: "Post", id }) });
cache.gc();
Evict by field to drop list caches but keep entities:
cache.evict({ id: "ROOT_QUERY", fieldName: "posts" });
Interfaces, Unions, and possibleTypes
For interfaces/unions, provide a possibleTypes map so Apollo can normalize to concrete types.
import { InMemoryCache, IntrospectionFragmentMatcher } from "@apollo/client";
const cache = new InMemoryCache({
possibleTypes: {
Node: ["User", "Post", "Comment"],
Media: ["Image", "Video"],
},
});
Also ensure each concrete type has keyFields defined.
Handling Duplicates and Split Schemas
Symptoms: duplicate list items or overwrites when different queries return the same entity with different shapes.
- Always request id and __typename for list items.
- Align keyFields with server IDs across subgraphs.
- In merge functions, deduplicate by reference key:
function dedupe(refs: any[]) {
const seen = new Set<string>();
return refs.filter(ref => {
const key = ref.__ref ?? ref.id; // for safety
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
SSR and Hydration (Next.js/Remix)
For server rendering, create a new ApolloClient per request, prefetch queries, extract the cache, and hydrate on the client.
// server.ts
export async function render() {
const client = makeApollo();
await getDataFromTree(<App client={client} />);
const initialState = client.extract();
return { html, initialState };
}
// client.tsx
const client = makeApollo({ initialState });
Guidelines:
- Use the same typePolicies on server and client to avoid hydration mismatches.
- Prefer cache-and-network after hydration via nextFetchPolicy to refresh data without jank.
Persisting the Cache
Persist between reloads to speed up startup, then revalidate.
import { persistCache, LocalStorageWrapper } from "apollo3-cache-persist";
const cache = new InMemoryCache({ /* typePolicies... */ });
await persistCache({ cache, storage: new LocalStorageWrapper(window.localStorage) });
const client = new ApolloClient({ cache, link });
Best practices:
- Version your persistence and purge on schema changes.
- Avoid persisting extremely volatile fields (evict before persist or mark with custom policies).
Debugging the Cache
- Apollo Client DevTools: inspect the normalized store and watched queries.
- cache.readQuery / readFragment: verify what the UI actually sees.
- Enable verbose logging around merges and updates during development.
const snapshot = client.cache.extract(true); // canonical snapshot
console.debug("Cache snapshot", snapshot);
Testing Cache Behavior
Use MockedProvider for component tests and create a real InMemoryCache instance with your typePolicies for integration tests.
import { MockedProvider } from "@apollo/client/testing";
render(
<MockedProvider mocks={[/* ... */]} cache={new InMemoryCache({ typePolicies })}>
<MyComponent />
</MockedProvider>
);
Test cases to include:
- Pagination merge correctness and deduplication
- Mutation updates for list insertion/removal
- Optimistic resolution and rollback on error
Anti‑Patterns and Gotchas
- Omitting id/__typename from list queries leads to duplication and stale views.
- Overusing refetchQueries increases latency and can thrash the UI. Prefer cache.modify/update when feasible.
- keyFields: false disables normalization and breaks shared identity—use only for true leaf/value objects.
- Complex merge functions that ignore args can accidentally cross-contaminate lists; define keyArgs precisely.
- Forgetting possibleTypes for interfaces/unions causes cache misses.
- Mutations returning partial objects without ids can’t be normalized—ask the server to include ids.
A Reference TypePolicy Blueprint
export const typePolicies: InMemoryCacheConfig["typePolicies"] = {
Query: {
fields: {
feed: relayStylePagination(["filter", "sort"]),
productSearch: offsetLimitPagination(["query", "categoryId"]),
me: {
read(existing) {
// Redirect to a known User id if desired
return existing;
},
},
},
},
User: { keyFields: ["id"] },
Post: {
keyFields: ["id"],
fields: {
// Ensure comments maintain stable pagination per post
comments: relayStylePagination(),
},
},
Comment: { keyFields: ["id"] },
};
Practical Checklist
- Always request id and __typename in lists and fragments.
- Define keyFields for all entities; avoid keyFields: false.
- Use pagination helpers (relayStylePagination/offsetLimitPagination) where possible.
- Prefer update/cache.modify for post‑mutation consistency; use refetchQueries sparingly.
- Implement optimisticResponse with complete ids and typenames.
- Provide possibleTypes for interfaces/unions.
- Evict strategically and run cache.gc after removals.
- Align SSR and client caches; hydrate with the same typePolicies.
- Persist cache with versioning; revalidate via nextFetchPolicy.
Conclusion
Treat Apollo’s cache as a first‑class data layer. With solid typePolicies, precise list merging, disciplined updates, and thoughtful fetch policies, your UI becomes fast, resilient, and consistent—even under rapid changes and pagination. Start with identity (keyFields), layer on field policies, and lean on helpers and tooling to keep your cache both simple and correct.
Related Posts
Apollo vs Relay: Choosing the Right GraphQL Client for React
Apollo vs Relay: strengths, trade‑offs, and when to choose each for React GraphQL apps, from caching and pagination to SSR, typing, and developer UX.
Build a GraphQL API and React Client: An End‑to‑End Tutorial
Learn GraphQL with React and Apollo Client by building a full stack app with queries, mutations, caching, and pagination—step by step.
GraphQL Type Generation Codegen: A Practical, End-to-End Guide
Generate GraphQL types for clients and servers with codegen. Learn configs, patterns, and pitfalls for safer APIs and faster delivery.