Apollo Cache Not Updating After Mutation: Fix It
Write mutation results into the cache — Apollo's normalized cache only updates entries with matching __typename, id, and fields..
20+ years shipping production backend systems. Drawn from code that ran under real load.
- ✓An Apollo Client app with a mutation that leaves stale UI
- ✓Apollo DevTools installed for cache inspection
- ✓Comfort reading GraphQL selections and fragments
- Apollo normalizes by __typename plus id — missing ids or mismatched fields mean silent stale UI
- Return the updated object's id and changed fields from the mutation for free updates
- Use cache.modify or update for shapes the mutation doesn't return
- refetchQueries is the blunt fallback — correct, but a network round trip per fix
- Check DevTools cache entries first: stale entries mean a merge failure, never a render bug
Think of Apollo's cache as a filing cabinet sorted by ID cards (__typename plus id). A mutation that returns no ID is an update memo with no name on it — the clerk can't file it, so the folder keeps the old page. Return the ID with changed fields (a labeled memo), and the cabinet updates itself. No ID, no update — the memo goes nowhere and the folder keeps yesterday's page.
You run a mutation, the server confirms success — but the UI still shows the old data until a manual refresh. The like count stays, the renamed item keeps its old name, the deleted row lingers. The mutation worked; the screen just doesn't know.
Apollo Client's normalized cache updates the UI from writes it understands. When the mutation response carries the object's __typename, id, and the changed fields, matching cached entries update free — no extra code. When any piece is missing (no id, different fields, custom key unconfigured), the write lands nowhere and the UI goes stale.
This guide makes cache updates deterministic. You'll check identity (__typename plus id or keyFields), shape mutation responses for automatic updates, write targeted updates with cache.modify, and choose wisely between cache writes and refetchQueries. Stale UI becomes a solved problem, not a recurring mystery. The debugging order never changes: identity first, fields second, shapes third — DevTools confirms each step.
Confirm Identity: __typename Plus id or keyFields
Normalization keys every object as __typename:id. Queries store references (Post:42) instead of copies, so one entry update refreshes all screens showing that post. If the mutation response omits id — or the type uses a custom key like slug without keyFields config — the response object has no address in the table and merges nowhere.
Check both sides. The schema must expose the key field on the mutated type, the mutation must select it, and the client cache must know non-id keys via typePolicies keyFields (Post: ['slug']). Apollo DevTools shows entries by key — a response object with no matching entry key is identity failure, diagnosed in seconds.
Custom keys multiply the risk: every query, mutation, and fragment must use the same key field consistently. Audit generated code too — codegen fragments that omit the key field break identity as surely as hand-written ones. One mutation returning slug while queries key on id splits the object into two entries that never merge. Standardize on id wherever the backend allows; configure keyFields once per type and test every mutation against the inspector. Pin DevTools to the staging build version; mismatched inspector builds misread cache keys.
Return Updated Fields for Automatic Merges
Identity alone isn't enough — the merge writes only the fields the mutation returns. A like mutation returning id without likesCount merges an entry with nothing new for the counter component, and the screen stays stale despite perfect identity. The rule: select every field the UI reads for the touched objects, plus their keys.
Design mutations response-first: start from the screens the mutation affects, list their fields, and make the payload a superset. Fragments shared between the UI query and the mutation guarantee overlap — define a PostCard fragment once, use it in both places, and drift becomes impossible. Lint for fragment reuse in review; duplicated field lists drift within months. The incident's envelope change broke exactly this overlap by dropping fields UI fragments required.
Verify in DevTools: run the mutation, watch the entry's fields change, and confirm watching components re-render with no extra code. Name the shared fragments after their UI owners (PostCard) so future developers extend both usages together. Automatic merges are the cheapest updates — zero client logic, zero round trips. When they work, stop; hand-written cache code for already-automatic cases is pure maintenance burden.
Write Targeted Updates With cache.modify
When the response shape can't carry the fields (wrappers, nested paths, computed results), write the entry directly. The update function receives the cache, identifies the entry (cache.identify for __typename:id references), and modifies named fields. Because normalization shares entries, one modify refreshes every query referencing the object — the same broadcast automatic merges get.
Use fragments to read exact field names: cache.readFragment on the entry first during development confirms names and current values. Modifier functions receive the existing value — increment counters from it rather than trusting response math, which races under rapid taps. Deleted objects get cache.evict plus garbage collection, not null writes that linger as zombie entries.
Keep update functions beside their mutations and unit-test them against a populated cache: seed entries, run the modifier, assert entry fields and query results. Cover rapid-fire sequences in tests — two quick likes must net exactly plus two, proving the modifier reads fresh cached values. Untested modifiers rot as schemas evolve — a renamed field silently stops matching, and staleness returns wearing a new disguise. Pair each modifier with its inverse test (like then unlike nets zero); symmetry proves correctness.
Fix List Membership Explicitly
Object merges update entries, not list memberships. Creating an item doesn't append it to cached feeds; deleting one doesn't remove it from cached lists. The new entry exists in the table while every list query still references the old member set — the item is saved but invisible, or deleted but lingering.
Modify the parent list field: cache.modify on the query root or parent entry, with the list field's modifier appending the new reference (cache.writeFragment result) or filtering out the removed id. New items must carry every field their card reads, or the appended row renders hollow. Deletions should also evict the entry so detail screens don't serve the ghost from cache.
Paginated lists need care: appending to page 1 shifts cursors, so prefer refetching the first page or inserting per your pagination policy. Optimistic list inserts must carry the same complete fields — a hollow optimistic row flashes broken UI before the real write lands. Test membership changes against the actual list queries (all pages, filters) — a fix verified on one filter while another caches the stale set ships half the bug. Test deletion flows against detail screens too; evicted entries must clear gracefully, not crash.
Choose Between Cache Writes and refetchQueries
refetchQueries re-runs named queries after the mutation — always correct (fresh server data), always costly (a round trip per mutation, thundering on rapid actions). Use it when shapes are too tangled to write safely, when server-side side effects touch many queries (re-ranked feeds, permission changes), or as the deliberate fallback while proper writes are built.
Cache writes (automatic merges, modify) cost no network and feel instant, but demand correct identity and field knowledge. Prefer them for the hot paths — likes, renames, toggles — where latency matters and shapes are stable. awaitRefetchQueries sequences the rare cases needing both: write optimistically now, confirm with refetch after.
Decide per mutation, not per app. Hot interactive mutations get cache writes with snapshot tests; complex multi-query side effects get refetchQueries with documented rationale. Measure the round-trip cost before dismissing refetch — on fast internal networks it can beat a day of modifier debugging. Review the split quarterly — today's tangled shape often simplifies into tomorrow's clean merge as the schema matures. Record the decision and its cost basis per mutation; revisit with data, not memory.
Lock Updates With Cache Snapshot Tests
Stale-cache bugs recur because payload shapes drift silently — the incident's envelope change passed every server test while breaking all clients. Client CI must assert the contract: each mutation returns key fields for touched objects, and cache snapshots update without refetch. Mock the mutation response, run it against a seeded cache, and assert entry fields plus rendered output.
Test the negative shapes too: wrapper payloads route through update functions, deletions evict entries and list references, paginated inserts respect cursor policy. Each test names the mutation and the queries it must refresh — when a backend change breaks one, the failure points at the exact contract breach.
Add the payload-shape check to the backend contract tests as well: mutations touching Post must select id plus the card fragment fields. Run these tests against staging after every backend deploy — payload drift usually ships server-side while blame lands client-side. Two-sided assertions (server selects, client merges) turn the Tuesday-envelope surprise from a 4-day incident into a red build in 4 minutes. Alert the owning team on contract failures, not just the committer; payload shapes span team boundaries.
Likes Froze for 120,000 Users After a Schema Tweak
- Mutation payloads are UI contracts: dropping an id field breaks the screen while the server stays correct. Review payload shapes like API breaks, not refactors.
- Stale-after-success means identity or field mismatch first. The render tree was innocent through two reverts — the cache inspector would have shown the unmerged write in minutes.
- Assert mutation key fields in CI. A check requiring ids on touched objects turns silent UI rot into a build failure.
| File | Command / Code | Purpose |
|---|---|---|
| mutations.graphql | mutation LikePost($id: ID!) { | Return Updated Fields for Automatic Merges |
| useLikePost.js | const LIKE_POST = gql` | Write Targeted Updates With cache.modify |
| useCreatePost.js | const [createPost] = useMutation(CREATE_POST, { | Fix List Membership Explicitly |
| likePost.test.js | test('like mutation updates the cached count without refetch', async () => { | Lock Updates With Cache Snapshot Tests |
Key takeaways
Common mistakes to avoid
5 patternsBlaming React rendering for stale data
refetchQueries for every mutation
Writing null instead of evicting deletes
Inconsistent key fields across operations
Trusting response math under rapid actions
Interview Questions on This Topic
Why doesn't the UI update after a successful Apollo mutation?
Frequently Asked Questions
20+ years shipping production backend systems. Drawn from code that ran under real load.
That's GraphQL. Mark it forged?
5 min read · try the examples if you haven't