What this usually means
Apollo Client's normalized cache is working correctly for queries, but the mutation response doesn't match the cache keys (id fields) expected by the InMemoryCache. By default, Apollo uses the __typename and id (or _id) fields to normalize objects. If your mutation returns an object without a valid id, or if the id field is named differently (e.g., 'userId'), Apollo cannot merge it into the existing normalized object. This means the cache keeps the old reference. The fix involves either ensuring the mutation returns the correct id, configuring typePolicies with a keyFields definition, or manually updating the cache using update functions or cache.modify.
The first ten minutes — establish facts before touching code.
- 1Run the mutation in Apollo DevTools and inspect the network response — does the returned object include an 'id' field (or whatever your keyFields expects)?
- 2Open Apollo DevTools cache viewer, find the affected type (e.g., 'User'), and check if there are two entries (old and new) with different IDs or one entry with stale data
- 3Log the mutation result in your code: `console.log('mutation result', data)` — verify the object structure, especially __typename and id
- 4Try adding `fetchPolicy: 'network-only'` to the query that is stale — if that fixes it, the cache is not being invalidated properly
- 5Check your InMemoryCache configuration: do you have typePolicies with keyFields for the affected type? Is there a custom merge function that might be swallowing updates?
- 6Look for any cache redirect or possibleTypes configuration that might confuse the normalization
The specific files, logs, configs, and dashboards that usually own this bug.
- searchsrc/apollo.ts or wherever you create the ApolloClient and InMemoryCache instance
- searchThe mutation component file — check the mutation variables and the returned selection set
- searchThe query component that shows stale data — check its fetchPolicy and cache usage
- searchApollo DevTools (Chrome extension) Cache tab and Query/Mutation tabs
- searchGraphQL schema — verify the return types have an id field or a field marked as @key
- searchAny custom typePolicies or field policies defined for the affected type (e.g., 'User', 'Todo')
- searchThe network tab — compare the mutation response JSON with the query response JSON for the same object
Practical causes, not theory. These are the things you will actually find.
- warningMutation response object lacks an 'id' field (or the expected keyFields) so Apollo creates a new cache entry instead of merging
- warningkeyFields in typePolicies doesn't match the actual id field returned by the mutation (e.g., expects 'id' but returns '_id')
- warningThe mutation returns a different shape than the query (e.g., nested differently) causing cache merge to fail
- warningCustom merge function in typePolicies is not merging arrays correctly (e.g., overwriting instead of appending)
- warningCache redirect or possibleTypes misconfiguration causing the cache to miss the update
- warningUsing `@client` directives or local state that overrides the server data
- warningOptimistic update not rolled back properly or conflicting with the real server response
Concrete fix directions. Pick the one that matches your root cause.
- buildEnsure mutation returns the 'id' field (or the correct keyFields) in its selection set. Example: `mutation UpdateUser($id: ID!) { updateUser(id: $id) { id name email } }`
- buildConfigure typePolicies with keyFields if your type uses a non-standard id field: `typePolicies: { User: { keyFields: ['userId'] } }`
- buildUse the `update` function in useMutation to manually write the mutation result to the cache: `update(cache, { data }) { cache.writeQuery({ ... }) }`
- buildUse `cache.modify` to directly update the specific cached object after the mutation: `cache.modify({ id: cache.identify(existingObject), fields: { name: newName } })`
- buildSet `fetchPolicy: 'cache-and-network'` or `nextFetchPolicy: 'cache-first'` on the query to force a refresh after the mutation
- buildAdd a `refetchQueries` option to the mutation to refetch queries that depend on the updated data
A fix you cannot prove is a guess. Close the loop.
- verifiedAfter the fix, run the mutation again and immediately check Apollo DevTools cache viewer — the affected object should now have the new values
- verifiedNavigate away from the page and come back (without refresh) — the data should still be updated
- verifiedCheck that the query component re-renders with the new data without any manual refetch
- verifiedWrite a simple test: before mutation, read the object from cache via readFragment; after mutation, read again and assert equality
- verifiedRemove any temporary fetchPolicy overrides (like network-only) and verify the cache update alone works
- verifiedCheck for any console warnings about cache normalization (Apollo logs warnings for missing ids)
Things that make this bug worse or harder to find.
- warningAssuming the mutation response automatically updates the cache — it only updates if the returned object can be normalized into the same cache entry
- warningUsing `refetchQueries` as a crutch instead of fixing the cache merge; it works but defeats the purpose of normalized caching
- warningForgetting to return the 'id' field in the mutation selection set, even if you don't need it in the UI
- warningOverwriting arrays in custom merge functions without considering pagination (e.g., using `[...existing, ...incoming]` may cause duplicates)
- warningMixing different id fields across queries and mutations (e.g., using 'id' in one and '_id' in another)
- warningApplying `keyFields` incorrectly — if a type has a composite key, you need an array of fields, not just one
User Profile Not Updating After Edit Mutation
Timeline
- 10:00Deployed a new 'Edit Profile' feature. User reports that after saving, the profile page still shows old name.
- 10:15Check network tab: mutation returns correct new name and email, status 200. Query on profile page returns old data from cache.
- 10:30Open Apollo DevTools Cache tab. Find 'User' type — there are two entries: one with id='1' and one with id='user:1'. The query reads from 'User:1', but the mutation created 'User:user:1'.
- 10:45Inspect mutation response JSON: it has `_id: '1'` but no `id`. The cache expects `id` as keyFields. InMemoryCache default uses 'id' and '__typename'.
- 11:00Check ApolloClient config: typePolicies not defined for User. The default keyFields is ['id']. Since mutation returns '_id', it creates a new cache entry with a generated id.
- 11:15Fix: add typePolicies: { User: { keyFields: ['_id'] } } to InMemoryCache. Also ensure mutation query returns '_id' field.
- 11:30Test again: mutation runs, cache now updates the same entry. Profile shows new data immediately.
- 11:45Verify other components reading User type (e.g., header) also update correctly. No stale data.
I had just wrapped up the 'Edit Profile' feature. The UI looked good in local dev. But after deploying to staging, a tester said: 'I changed my name to John, but it still shows Jane.' My first thought was a backend bug — maybe the database wasn't updated? But the network response clearly showed the new name. The UI just wasn't reflecting it.
I opened Apollo DevTools. The Cache tab was my salvation. I saw that the User type had two entries: one with id '1' and another with id 'User:1'. That's when I realized the mutation returned `_id` while the cache expected `id`. Because our MongoDB uses `_id`, but the GraphQL schema exposed it as `_id` (not renamed to `id`). The cache couldn't match them, so it created a new entry. The query was still reading the old entry.
The fix was straightforward: configure keyFields for the User type in InMemoryCache to use '_id'. I also had to ensure the mutation returned the '_id' field explicitly. After that, the cache entry updated correctly, and the UI reflected changes instantly. The lesson: never assume the cache key matches your backend id field. Always verify the __typename and id fields in network responses, and configure typePolicies accordingly.
Root cause
Missing keyFields configuration in InMemoryCache. The mutation returned '_id' but cache expected 'id', causing a duplicate cache entry.
The fix
Added `typePolicies: { User: { keyFields: ['_id'] } }` to InMemoryCache and ensured the mutation query includes '_id'.
The lesson
Always check the cache key fields when dealing with non-standard id fields. Configure typePolicies upfront to avoid duplicate cache entries.
Apollo Client's InMemoryCache normalizes every object it receives by combining the `__typename` and a unique identifier (by default, the `id` field). This produces a cache key like `User:1`. When a mutation returns an object, Apollo attempts to find an existing cache entry with the same key. If the key matches, the existing entry is updated. If not, a new entry is created.
The problem arises when the mutation returns an object that lacks the expected identifier. For example, if your schema uses `_id` instead of `id`, the cache cannot match it to the existing entry. This results in two entries for the same logical object: one created by the query (with key `User:1` if the query returned `id`) and one created by the mutation (with a key like `User:$ROOT_MUTATION.updateUser` if no `id` is returned). The UI components read from the old query entry, so they never see the mutation's update.
Apollo DevTools is the most powerful tool for this debugging. After running the mutation, open the Cache tab and find the type you're updating (e.g., 'User'). Look for multiple entries with similar data. If you see two entries with different IDs (like `User:1` and `User:user:1` or a generated ID), that's a cache key mismatch.
Click on each entry to see its fields. The stale entry will have the old data, the new entry will have the mutation result. The new entry's ID will likely be something like `User:ROOT_MUTATION.updateUser` or a hash, indicating Apollo couldn't find a matching key. This confirms the root cause.
The most robust fix is to configure `keyFields` in the `typePolicies` of your InMemoryCache. This tells Apollo which field(s) to use as the unique identifier for that type. For example, if your User type uses `_id`, set `typePolicies: { User: { keyFields: ['_id'] } }`. If the identifier is composite (e.g., a combination of `userId` and `workspaceId`), use an array: `keyFields: ['userId', 'workspaceId']`.
Important: Ensure that every query and mutation that returns this type includes the keyFields in its selection set. Even if you don't need the ID in the UI, include it for cache normalization. Otherwise, Apollo will still generate a fallback key.
If you cannot change the backend to return the correct ID (e.g., legacy API), you can manually update the cache after the mutation. The `update` callback in `useMutation` gives you access to the cache. Use `cache.modify` to directly change the fields of the existing cached object. For example: `cache.modify({ id: cache.identify(existingObject), fields: { name: (cachedName) => newName } })`.
Alternatively, you can use `cache.writeQuery` or `cache.writeFragment` to overwrite the entire cached object. This approach is more explicit but requires you to know the exact shape of the cached data. It's useful when the mutation response doesn't include all fields of the object.
If you have custom merge functions for array fields (e.g., `items`), they can also cause cache update failures. By default, Apollo replaces arrays. If your merge function tries to merge arrays incorrectly (e.g., always appending without deduplication), you might see stale data or duplicates.
A common mistake is using `[...existing, ...incoming]` without checking for existing items. This can cause duplicates on subsequent updates. Instead, use a merge function that merges based on IDs: `existing.map(item => incoming.find(i => i.id === item.id) ?? item)`. Always test with multiple updates.
Frequently asked questions
Why does cache update work in some components but not others?
This usually happens when different components read different cache entries. For example, a list component might read a query that returns an array of objects, while the detail component reads a single object by ID. If the mutation updates the single object but the list query hasn't been refetched, the list may show stale data. Ensure that the mutation either updates both cache entries (via cache.modify or refetchQueries) or that the list query uses a fetchPolicy that checks for updates.
Do I need to include the id field in the mutation selection set even if I don't use it in the UI?
Yes, absolutely. If you omit the id field, Apollo cannot normalize the returned object into the existing cache entry. It will create a new entry with a generated key, and your UI will not update. Always include the id (or your custom keyFields) in the mutation selection set.
What if my backend uses a different field name for the ID in different operations?
This is a common issue when the GraphQL schema is inconsistent. For example, a query might return `id` but a mutation returns `userId`. The best fix is to standardize the schema to use the same field name everywhere. If that's not possible, you can set `keyFields` to an array of possible fields? No, keyFields expects exact field names. You'll need to alias the field in the mutation query: `mutation { updateUser(id: $id) { userId: id ... } }`. Or, use a manual cache update.
Does using `refetchQueries` fix the cache update issue permanently?
`refetchQueries` is a workaround, not a fix. It forces a network request to refetch the affected queries, bypassing the cache. While it makes the UI show correct data, it defeats the purpose of normalized caching and adds extra network requests. The proper fix is to ensure the mutation result merges into the cache correctly.
How do I debug cache updates in production?
In production, you can't use Apollo DevTools. Instead, add logging in your mutation's `onCompleted` callback: log the result and then use `client.readQuery` or `client.readFragment` to read the cached data immediately. If the cache is stale, you'll see the old data. Also, monitor the network tab for duplicate requests or unexpected cache misses.