Apollo Client fetchMore Duplicates or Drops Pages: Diagnosing Cache Merge Failures
A diagnostic guide to Apollo Client pagination failures: why fetchMore duplicates or drops pages, how to inspect the normalized cache, and when to escalate.
08 Apr 2026, 08:21 UTC

The symptom: fetchMore that duplicates, drops, or stales a list
You have a cursor- or offset-paginated list. The first page renders. You call fetchMore for page two. One of three things happens: the first page's rows appear twice, the earlier pages are replaced by the newest page, or a mutation that adds an item does not show up until a hard reload. The network tab shows correct responses. The cache is usually the deciding factor, not the transport layer.
This guide assumes a normalized InMemoryCache and either a Relay-style connection (edges/cursor) or an offset/limit list. It assumes Apollo Client 3.x field policies. Apollo Client 2.x used different mechanisms (cacheRedirects, dataIdFromObject), so confirm the installed major version in your lockfile before applying any fix.
Cause and diagnostic table
| Symptom | Likely cause | First check |
|---|---|---|
| Page one duplicated after fetchMore | Merge concatenates without dedupe | Does the merge function compare item identity or cursor? |
| Newest page replaces earlier pages | No merge function; last write wins on the field key | Is there a field policy with a merge for the paginated field? |
| List stale after mutation until reload | Field policy or item identity blocks the update | Are keyFields declared and selected, and is the read path using the cache? |
| Two filters show each other's items | One field key shared across variable sets | What does keyArgs include, and which args actually differ? |
Ordered checks
1. Dump the cache, don't trust the inspector
// Browser console or a test. cache is your InMemoryCache instance.
const snapshot = cache.extract();
// Find the entry for the paginated field. A field with arguments
// appears as a keyed field name on the owning cache ID.
Expected check: after two pages, the stored value for the field should contain the union of unique items, not just page two. Compare before and after fetchMore. The devtools inspector may normalize or hide entries differently from the raw snapshot, so treat the extracted object as the reference.
2. Log the merge function's arguments
merge(existing, incoming, { args, readField }) {
console.log("merge", { existing, incoming, args });
// ...
}
Expected check: existing should be the previously merged array on the second call, and incoming should be the new page. If existing is undefined on every call, the field key is changing between requests, usually because pagination arguments are part of the key.
3. Confirm item identity is declared and selected
If the item type has no keyFields and there is no global identity function, Apollo falls back to a path-based identity. One entity can then appear as several cache entries, or several entities can collapse into one. Declare keyFields on the item type and make sure that field is actually selected in the query; a key field that is not in the response cannot identify anything.
4. Confirm the fetch policy on the read path
no-cache does not write to the normalized cache at all, so a merge function never runs. network-only writes but reads from the network, which can bypass the merged result on a re-render. Align the policy with the intended read path: a list you want to accumulate usually needs a cache-first read plus fetchMore.
5. Confirm which variables form the field key
keyArgs decides which arguments separate cache entries. Pagination arguments such as after, before, offset, and limit should normally be excluded so all pages merge into one field. Meaningful filters should be included so two filters do not share one key. If you omit keyArgs, the default behavior depends on the version and the field; check that version's own documentation rather than assuming.
6. Reproduce with a single fetchMore
Before blaming the server, reduce to one query and one fetchMore with fixed variables. If the cache value is still wrong with two pages, the field policy is the problem. If it is correct in the cache but wrong in the UI, move to the escalation criteria below.
Fixes tied to each finding
No merge function: last write wins
Declare a field policy whose merge combines existing and incoming results instead of overwriting.
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
feed: {
// Exclude pagination args; include only meaningful filters.
keyArgs: ["filter"],
merge(existing, incoming, { readField }) {
const merged = existing ? existing.slice(0) : [];
const seen = new Set(merged.map(item => readField("id", item)));
for (const item of incoming) {
const id = readField("id", item);
if (!seen.has(id)) {
seen.add(id);
merged.push(item);
}
}
return merged;
},
},
},
},
Post: {
keyFields: ["id"],
},
},
});
Placeholders: feed is the paginated field name, filter is the argument that genuinely changes the list, and Post/id are your item type and its stable identifier. This example is illustrative, not tested output. The shorthand merge: true exists only in some 3.x releases; check the installed version's documentation before using it.
Blind concatenation: duplicates
If the merge already concatenates but rows repeat, the pages overlap, a refetch re-delivers page one, or a re-render re-runs the query. Deduplicate by a stable item identifier or by cursor/offset before returning the merged array. If items arrive as plain objects without identity, dedupe on a field that is present in the response.
Missing identity: one entity, several entries
Add keyFields to the item type and select that field in the query. Verify by extracting the cache and checking that the item appears under one cache ID, not several.
Fetch policy or field key mismatch
Switch the list to a cache-first read if the merged result should be reused, and set keyArgs so that pagination arguments do not split the field while real filters do.
Verification that the fix is the deciding factor
- Extract the cache before and after
fetchMoreand compare the paginated field's stored value. Expected: the union of unique items. - Toggle the merge function off and on with the same two pages. If the behavior changes only with the merge, the field policy is the deciding factor.
- Add a temporary test that a mutation adding an item updates the list without a manual refetch. If it does not, the field policy or item identity is still wrong.
- Assert that the rendered list length equals the number of unique items across the pages you fetched.
When to escalate to the API owner
Escalate rather than patch the client when cursors overlap or repeat across pages, when ordering is unstable between requests, or when the same cursor returns different items. No client merge can fully mask a server-side pagination defect. Also escalate if the merged array is correct in an extracted cache snapshot but the UI still duplicates: that points to a component-level key or a second query writing the same field, not to the merge function.
Limitations and version notes
- Apollo Client 2.x does not use field policies; confirm the major version in the lockfile first.
- Cache devtools views can normalize entries differently from the raw contents. Verify against
cache.extract(). - A merge function that returns a new array on every read can cause unnecessary re-renders. Keep merging deterministic and stable where possible.
- This guide assumes a normalized cache. If the list is intentionally unnormalized, the merge behavior and identity rules differ.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.