Why Apollo Client's Cache Breaks Offset Pagination (And How to Fix It)
Apollo Client's InMemoryCache normalizes entities by ID, which breaks offset-based pagination when limit/offset aren't part of the cache key. Learn two fixes—fetchPolicy 'network-only' or custom typePolicies with keyArgs—and when each applies.
05 Jan 2026, 22:25 UTC

The Problem: Pages Overwrite Each Other
You fetch the first page of a list with limit: 10, offset: 0. The UI renders correctly. Then you request page two with limit: 10, offset: 10. Suddenly the first page shows the same items as page two, or new items never appear at all. No network errors, no console warnings—just stale data that refuses to update.
This isn't a backend bug. It's Apollo Client's InMemoryCache doing exactly what it was designed to do: normalize entities by their primary key. When your list query doesn't include pagination arguments in the cache key, every page maps to the exact same set of normalized objects. The second response overwrites the first because, as far as the cache knows, they're the same entities.
How Normalization Collides with Offset Pagination
Apollo's cache stores each object once, keyed by __typename and id (or _id). A User with id: "42" exists in exactly one place. This is brilliant for entity-centric data—update a user's name in a mutation, and every query referencing that user sees the change instantly.
But offset pagination breaks this model. Consider a users(limit: 10, offset: 0) query returning users 1–10, then users(limit: 10, offset: 10) returning users 11–20. Both queries ask for a User list. The cache sees the same User objects (by ID) and merges them. If your backend returns overlapping IDs across pages—or worse, the same IDs in a different order—the cache happily overwrites the previous page's data with the new page's data.
The root cause: the cache key for the users field doesn't include limit and offset. Apollo treats users(limit: 10, offset: 0) and users(limit: 10, offset: 10) as the same field with different arguments, but the default typePolicies configuration doesn't know those arguments should produce separate cache entries.
Worked Example: Fixing a Paginated Feed
Suppose you have a Feed component fetching posts with offset pagination. The query looks like this:
const GET_POSTS = gql`
query GetPosts($limit: Int!, $offset: Int!) {
posts(limit: $limit, offset: $offset) {
id
title
body
author {
id
name
}
}
}
`;
With default cache settings, paging from offset 0 to 10 causes the first page to vanish or duplicate. Here are two fixes, each with different trade-offs.
Option 1: fetchPolicy: 'network-only' (Simplest, No Cache Benefit)
const { data, loading, fetchMore } = useQuery(GET_POSTS, {
variables: { limit: 10, offset: 0 },
fetchPolicy: 'network-only',
});
This bypasses the cache entirely for this query. Every pagination request hits the network. You lose the instant UI updates when a mutation modifies a post that's already in the cache, but pagination works correctly. Use this when the list changes frequently and you don't need cross-query normalization for list items.
Option 2: Custom keyArgs in typePolicies (Preserves Cache Benefits)
Configure the cache to treat each limit/offset combination as a distinct list:
const client = new ApolloClient({
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
keyArgs: ['limit', 'offset'],
merge(existing, incoming, { args }) {
if (!existing) return incoming;
// For offset pagination, replace the slice
const merged = [...existing];
for (let i = 0; i < incoming.length; i++) {
merged[args.offset + i] = incoming[i];
}
return merged;
},
},
},
},
},
}),
});
The keyArgs tells Apollo: "cache a separate posts array for each unique limit/offset pair." The merge function stitches pages together into a single logical list if you want infinite scroll, or you can omit it to keep pages separate. With this config, fetchMore works correctly and mutations updating a post's title still propagate to any cached page containing that post.
Trade-off: When Your Backend Lacks Stable IDs
The custom keyArgs approach assumes each post has a stable id. If your API returns objects without IDs (common with legacy REST wrappers or computed views), normalization falls apart. The cache falls back to the object's path in the query, which changes per page—so you get duplicate entries that never update.
In that scenario, you have three choices:
- Add stable IDs to the backend (best long-term fix)
- Use
fetchPolicy: 'network-only'and accept no caching for lists - Implement a custom
dataIdFromObjectthat synthesizes keys from__typename+offset+ array index (fragile; breaks if item order changes)
The third option is a band-aid. If item order isn't guaranteed, synthesized keys will map the same logical item to different cache entries across pages, defeating normalization entirely.
How to Verify the Fix
Open Apollo DevTools (Chrome/Firefox extension) and inspect the cache after loading page one, then page two. With the bug, you'll see a single ROOT_QUERY.posts({"limit":10,"offset":0}) entry that gets overwritten. With keyArgs configured, you'll see two distinct entries: ROOT_QUERY.posts({"limit":10,"offset":0}) and ROOT_QUERY.posts({"limit":10,"offset":10}), each holding its own array of post references.
Next, run a mutation that changes a post's title (e.g., updatePost(id: "42", title: "New Title")). If the post appears on page one, the title should update instantly in the UI without a refetch. That confirms normalization still works for entity fields while pagination stays correct.
Closing: Pick the Right Tool for the Pagination Style
Offset pagination fights Apollo's normalization because the cache key doesn't naturally include pagination arguments. The fix is explicit: either opt out of caching for that query (network-only) or teach the cache that limit and offset create distinct lists (keyArgs + merge).
If you're building new APIs, consider cursor-based pagination (after/before) with Relay-style connections. The keyArgs for cursors is simpler—often just ['after']—and the cache merge logic aligns naturally with how connections work. But for existing offset-based endpoints, the typePolicies configuration above keeps your cache honest without sacrificing the entity updates that make Apollo valuable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.