skip to content

In Apollo Client 4, the console warns 'Cache data may be lost when replacing the stats field of a Post object'; why does it happen, and what is the safe fix?

level: seniorimportance: should knowfreq 30%

answer

  1. an object without identity
  2. two queries, different subfields
  3. replace versus merge
  4. merge true only for owned objects

basics

~20 s

Post.stats is an object with no cache ID, so it is stored inline, and each write replaces it, dropping subfields another query selected. Give it an identity with keyFields, or declare merge: true when stats always belongs to its post.

solid answer

~50 s

`PostStats` has no `id`, so `InMemoryCache` stores it inline under `Post.stats`. When the feed selects `stats { likeCount }` and the post page selects `stats { commentCount }`, each write replaces the whole object, because without a merge function an incoming value simply overwrites the existing one. The feed's `likeCount` disappears, its query becomes incomplete and refetches, and the two views can keep knocking each other's data out. Apollo Client warns about this in development, once per type and field. If stats has a real identity, select it or declare `keyFields` so it becomes an entity. If it is simply part of its post, declare `merge: true` on the `PostStats` type policy or on the `Post.stats` field policy, and the cache merges the fields. Do not use `merge: true` where the field can point to a different object, or you blend two objects' fields.

code

ts · 10 lines
ts
import { InMemoryCache } from "@apollo/client";

export const cache = new InMemoryCache({
  typePolicies: {
    // Counters belong to exactly one post: merge instead of replace.
    PostStats: { merge: true },
    // Authors have identity: normalize them instead of merging blindly.
    Author: { keyFields: ["handle"] },
  },
});

go deeper

for a junior

Recall that objects without an id are stored inside their parent and are replaced, not merged, when a new result writes that field.

for a middle

Explain the replace-by-default rule for inline values, why references are exempt, and how merge: true on a type or field policy changes it.

for a senior

Decide per type whether the fix is identity or merging, spot fields where merge: true would blend two different objects, and trace refetch loops back to this warning.

for a principal

Push for identity on every object that has one in the schema, so client merge rules stay the exception, and treat this warning as a signal worth failing a development build on.

## Where the warning comes from `InMemoryCache` normalizes objects it can identify and stores the rest **inline**, as plain values inside their parent entity. A `PostStats` object with no `id` and no `keyFields` is such a value: it lives in the `stats` field of a `Post:N` entity. When a new result writes that field and the field has no merge function, the default is to **replace** the existing value with the incoming one. For normalized entities this is harmless, because replacing a reference loses nothing. For inline objects it can throw data away. In development builds, the cache checks each such replacement. It warns only when all of these hold: - the existing value is an inline object or array, not a reference; - the incoming value is not structurally equal to it; - at least one field of the existing value is missing from the incoming value. The warning names the field and the parent type, prints both values, and is logged once per `Type.field` pair. Production builds skip the check entirely. ## How it plays out in the feed 1. The feed query selects `stats { likeCount }` for each post and writes `{ likeCount: 12 }` into `Post:7.stats`. 2. The user opens the post page, whose query selects `stats { commentCount }`. Its result replaces `Post:7.stats` with `{ commentCount: 3 }`, and the warning fires. 3. The feed query now reads `Post:7.stats.likeCount`, finds it missing, and its cached result is incomplete. 4. Depending on its fetch policy, the feed goes back to the network, which writes `{ likeCount: 12 }` again and knocks out the post page's `commentCount`. The warning's own text describes the cost: additional, usually avoidable, network requests for data that was already cached. ## Choosing a fix | Situation | Fix | Result | |---|---|---| | stats has its own stable ID | select `id`, or declare `keyFields` | `PostStats` becomes an entity and fields merge per entity | | stats always belongs to exactly one post | `merge: true` on the `PostStats` type policy | inline objects are merged field by field | | only this field needs merging | `merge: true` on the `Post.stats` field policy | same, scoped to one field | | merging needs custom rules | a `merge(existing, incoming, { mergeObjects })` function | you control how values combine | `merge: true` is shorthand for a merge function that calls `mergeObjects(existing, incoming)`, which combines the two objects while still respecting any merge functions defined on their own fields. If the two values carry different `__typename`s, `mergeObjects` keeps the incoming one instead of combining them. A field policy's merge takes precedence over the type policy's merge for that field, so the type-level setting acts as a default wherever the type appears. ## When merge: true is wrong `merge: true` asserts that the existing and incoming objects are **the same logical object**. That holds for data owned by one parent, such as a post's counters or a user's display preferences. It fails when the field can legitimately point to a **different** object: - `Post.author` returned without an `id`, where a moderator reassigns the post to another author; - a `Query.currentViewer` object that changes on sign-in as another user; - any object whose identity lives in fields the query did not select. Merging in those cases blends two objects: the new author's name with the old author's avatar, for example. There, the right fix is identity: select the key fields or declare `keyFields`. ## Arrays are a separate case The same warning can name a list field. Arrays have no `__typename` and no identity of their own, so a list field always needs a custom merge function if replacing it would lose data, even when its items are normalized entities. `merge: true` is not the tool for a list: `mergeObjects` refuses arrays and throws `Cannot automatically merge arrays`. A function that knows how to combine the lists is the tool, and for paginated lists that usually means `keyArgs` with a merge function. ## Checklist when the warning appears 1. Read which type and field it names, and which subfields the existing and incoming values held. 2. Ask whether the object has an identity. If yes, select its key fields everywhere, or declare `keyFields`. 3. If it has no identity but always belongs to its parent, add `merge: true` on the type policy. 4. If it can be replaced by a different object, keep replacement and make sure each view selects the fields it needs. 5. Confirm in `cache.extract()` that the parent's field now holds the union of subfields, or a reference.

  • Why does the warning never fire for `Post.author` when authors are normalized?
    Because `Post.author` then holds a reference such as `{ __ref: "Author:42" }`. Replacing one reference with another loses nothing: the author's fields live in the `Author:42` entity, where writes from different queries are combined field by field. The check skips references for exactly that reason.
  • Where would you declare `merge: true`: on the `PostStats` type policy or on the `Post.stats` field policy?
    On the type policy when every appearance of `PostStats` is owned by its parent, so one line covers `Post.stats`, `Comment.stats` and any future field. On the field policy when only some fields returning the type are safe to merge. A field policy's merge overrides the type policy's for that field, so you can set a type-level default and opt individual fields out with `merge: false`.

saying these in an interview costs you the question

  • The warning is a bug in Apollo Client, so the fix is to silence it.
  • Setting merge: true on every type is a safe default that removes the warning.
  • The warning means the server returned inconsistent data for the same post.
  • Replacing a normalized author reference also loses the author's cached fields.
  • merge: true on a list field appends incoming items to the existing list.