Home › Web Platform › Apollo Cache Not Updating After Mutation: Fix It
Intermediate 5 min · September 23, 2026

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..

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Drawn from code that ran under real load.

Follow
✓ Production
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 23 min
  • ✓An Apollo Client app with a mutation that leaves stale UI
  • ✓Apollo DevTools installed for cache inspection
  • ✓Comfort reading GraphQL selections and fragments
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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
✦ Definition~90s read
What is Apollo Client Cache Not Updating After Mutation?

Apollo Client normalizes query results into a flat table: each object with a __typename and an id (or custom keyFields) becomes one entry referenced by every query that fetched it. Updates to that entry re-render all watching components. Mutations write through the same mechanism — the mutation's response objects merge into the table by identity, and any active query referencing those entries refreshes instantly.

★
Think of Apollo's cache as a filing cabinet sorted by ID cards (__typename plus id).

Staleness has three standard causes. Missing identity: the mutation returns objects without id (or the type lacks keyFields config), so the cache can't match them to existing entries. Field mismatch: the mutation returns id but not the changed fields the UI query reads, so entries merge with nothing new for those components.

Shape divergence: the mutation returns a differently-shaped payload (a success wrapper, a nested path) that never overlaps cached queries.

The fixes mirror the causes: configure identity for every cached type, return id plus updated fields from mutations for automatic merges, use update/cache.modify to hand-write entries the response doesn't cover, and reserve refetchQueries for cases where re-querying is genuinely cheaper than reasoning about writes. DevTools' cache inspector verifies each fix by showing entries before and after.

Plain-English First

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.

📊 Production Insight
DevTools cache keys are the truth: response objects without matching entry keys merge nowhere. Check keys before suspecting render code.
🎯 Key Takeaway
Every cached object needs __typename plus a known key in both queries and mutations.

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.

mutations.graphqlGRAPHQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# BEFORE (stale): identity without the watched field
mutation LikePost($id: ID!) {
  likePost(id: $id) {
    success
    post { id }
  }
}

# AFTER (automatic merge): id + every field the card reads
mutation LikePost($id: ID!) {
  likePost(id: $id) {
    success
    post {
      __typename
      id
      likesCount
      likedByMe
    }
  }
}
📊 Production Insight
Shared fragments between UI queries and mutations make field drift structurally impossible — one fragment, two usages, zero staleness.
🎯 Key Takeaway
Select id plus every UI-read field in mutations — merges update watching components free.

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.

useLikePost.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { gql, useMutation } from '@apollo/client';

const LIKE_POST = gql`
  mutation LikePost($id: ID!) {
    likePost(id: $id) { success }
  }
`;

// Targeted write: response carries no fields, so update the entry directly
const [likePost] = useMutation(LIKE_POST, {
  update(cache, _result, { variables }) {
    const postRef = cache.identify({ __typename: 'Post', id: variables.id });
    cache.modify({
      id: postRef,
      fields: {
        likesCount: (count = 0) => count + 1,
        likedByMe: () => true,
      },
    });
  },
});
Try it live
📊 Production Insight
Increment from the cached value inside modifiers — response-derived counts race under rapid taps while cache-relative updates stay correct.
🎯 Key Takeaway
cache.modify writes entries the response doesn't cover — one write refreshes all watchers.

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.

useCreatePost.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const [createPost] = useMutation(CREATE_POST, {
  update(cache, { data }) {
    // Append the new post's reference to the cached feed list
    cache.modify({
      fields: {
        feed(existing = []) {
          const newRef = cache.writeFragment({
            data: data.createPost.post,
            fragment: gql`
              fragment NewPost on Post {
                __typename
                id
                title
                likesCount
              }
            `,
          });
          return [newRef, ...existing];
        },
      },
    });
  },
});
Try it live
🔥Entries Update, Lists Don't
Merges refresh object contents everywhere but never change which items a list holds. Membership changes always need an explicit list update or refetch.
📊 Production Insight
New rows must include every card-read field or they render hollow in the list. Fragments shared with the card guarantee completeness.
🎯 Key Takeaway
Append or filter list references explicitly — merges never change membership.

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.

📊 Production Insight
Hot paths (likes, toggles) deserve cache writes for instant feel; sprawling side effects deserve refetch for correctness. Price each mutation deliberately.
🎯 Key Takeaway
Writes for speed on hot paths, refetch for correctness on tangled effects — choose per mutation.

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.

likePost.test.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
test('like mutation updates the cached count without refetch', async () => {
  const cache = seedCacheWithPost({ id: '42', likesCount: 7 });
  await runMutation(LIKE_POST_MUTATION, { id: '42' }, cache);

  const entry = cache.extract()['Post:42'];
  expect(entry.likesCount).toBe(8); // merged, no network
  expect(entry.likedByMe).toBe(true);
});

test('every mutation selects key fields of touched objects', () => {
  for (const doc of allMutationDocs()) {
    expect(doc).toSelectTypenameAndId(); // custom matcher on the AST
  }
});
Try it live
📊 Production Insight
Two-sided contract tests (server selects keys, client merges without refetch) catch envelope changes on both repos before either deploys.
🎯 Key Takeaway
Snapshot-test cache updates per mutation — payload drift then fails builds, not users.
● Production incidentPOST-MORTEMseverity: high

Likes Froze for 120,000 Users After a Schema Tweak

Symptom
After a Tuesday backend deploy, tapping like did nothing visible for roughly 120,000 daily app users — counts froze, hearts stayed hollow — though refreshes revealed the likes had saved. Support logged 1,150 confused reports over 4 days. The server recorded all 380,000 likes correctly; only the optimistic-plus-mutation UI path went stale.
Assumption
The frontend team blamed the new optimistic-response code shipped Monday and reverted it twice with no effect. Then they blamed a React Native rendering bug and spent a day on list memoization. Nobody diffed the mutation response because the server returned 200 with correct data.
Root cause
Tuesday's backend change wrapped the like mutation's payload in a success envelope that dropped the Post id field. Without id in the response, Apollo couldn't match the result to the cached Post entry, so no merge happened and the UI kept stale counts. Every one of the 380,000 likes wrote to the server and vanished from the screen — identity loss, not a render bug.
Fix
They restored id plus likesCount in the mutation payload, and automatic cache merges resumed with zero client changes. They added a client CI check asserting every mutation returns __typename plus the key fields of objects it touches, and a cache-snapshot test that likes update without refetch. Reports dropped to zero on the next deploy.
Key lesson
  • 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.
Production debug guideFive steps — confirm identity, match fields, write or refetch.5 entries
Symptom · 01
UI ignores successful mutations until manual refresh
→
Fix
Open Apollo DevTools cache inspector and find the object's entry before and after the mutation. If the entry's fields don't change, check the mutation response for __typename plus id — a missing id means the write can't match any entry. Restore the id (and keyFields config for custom keys) and re-test.
Symptom · 02
Identity present but the UI still shows old values
→
Fix
Compare the mutation's returned field list against the fields the UI query reads. If the mutation returns id only while the screen reads likesCount, the merge adds nothing the component watches. Add the changed fields to the mutation selection and confirm the entry updates in DevTools.
Symptom · 03
Mutation returns a wrapper or nested shape
→
Fix
Add an update function using cache.modify with the entry's id (toString of __typename:id via cache.identify) to write the changed fields directly. Read the fragment for the UI's fields first so names match exactly. Verify in DevTools that the entry — and all watching queries — refresh without a network round trip.
Symptom · 04
Lists don't add or remove the mutated item
→
Fix
Update the list field explicitly: cache.modify on the parent (e.g. ROOT_QUERY feed) with a read function appending the new reference or filtering the removed one. New items need both identity and all fields their card reads. Check the list query re-renders with correct membership.
Symptom · 05
Writes are too tangled to reason about safely
→
Fix
Use refetchQueries with the affected query names (or awaitRefetchQueries for sequencing) as the deliberate fallback. Confirm fresh network responses and updated UI. Log the case for later simplification — refetch is correct but costs a round trip every mutation, so revisit when the shapes stabilize.
Stale Cache Causes Compared
Root CauseHow to ConfirmFixPrevention
Missing identity in responseNo matching entry key in DevTools cacheReturn id; configure keyFields for custom keysCI asserts key fields on every mutation
Mutation omits watched fieldsEntry exists but stale fields unchangedSelect all UI-read fields; share fragmentsDesign payloads response-first from screens
Wrapper or nested shapesData arrives where no query looksupdate with cache.modify on the entrySnapshot-test wrapper mutations specifically
Unchanged list membershipEntry fresh but lists hold old membersModify list refs or refetchQueriesTest creates/deletes against list queries
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
mutations.graphqlmutation LikePost($id: ID!) {Return Updated Fields for Automatic Merges
useLikePost.jsconst LIKE_POST = gql`Write Targeted Updates With cache.modify
useCreatePost.jsconst [createPost] = useMutation(CREATE_POST, {Fix List Membership Explicitly
likePost.test.jstest('like mutation updates the cached count without refetch', async () => {Lock Updates With Cache Snapshot Tests

Key takeaways

1
Stale-after-success is a cache-merge failure, not a render bug.
2
Identity (__typename plus key) lets responses match entries.
3
Mutations must return every field the UI reads for touched objects.
4
cache.modify covers shapes responses can't express.
5
List membership needs explicit reference updates or refetch.
6
Snapshot-test merges per mutation to lock the contract.

Common mistakes to avoid

5 patterns
×

Blaming React rendering for stale data

Symptom
Days spent on memoization and re-render tracing while the cache entry itself never updates — the UI faithfully renders stale state.
Fix
Check DevTools cache entries first. If the entry is stale, it's a cache-write problem, never a render problem.
×

refetchQueries for every mutation

Symptom
Correct but sluggish UI with doubled backend load — every tap costs a round trip, and rapid actions queue thundering refetches.
Fix
Reserve refetch for tangled side effects. Hot paths get identity plus fields for free merges or targeted modify writes.
×

Writing null instead of evicting deletes

Symptom
Deleted items linger as zombie entries — detail screens serve ghosts from cache long after the server forgot them.
Fix
Evict deleted entries with cache.evict, remove list references, and run garbage collection.
×

Inconsistent key fields across operations

Symptom
Queries key on id while a mutation returns slug — the object splits into twin entries that never merge, half the screens stale.
Fix
Standardize one key per type, configure keyFields once, and assert it in CI across all operations.
×

Trusting response math under rapid actions

Symptom
Double-tapped likes count once or thrice — response-derived counts race while cache-relative increments stay exact.
Fix
Increment from the cached value inside modifiers; never compute new state from response numbers alone.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why doesn't the UI update after a successful Apollo mutation?
Q02JUNIOR
How do automatic cache updates work?
Q03SENIOR
When do you use cache.modify instead?
Q04SENIOR
Why do created or deleted items miss from lists?
Q05SENIOR
Cache writes versus refetchQueries — how do you choose?
Q01 of 05JUNIOR

Why doesn't the UI update after a successful Apollo mutation?

ANSWER
The response didn't merge into the normalized cache — usually missing id/keyFields identity or omitted watched fields. The server is correct; the cache entry never changed, so watching components never re-rendered. I'd inspect the entry in DevTools first.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Do I always need an update function?
02
Is refetchQueries bad practice?
03
What are keyFields for?
04
Why do deleted items still show?
05
How do optimistic responses fit in?
06
Can two queries disagree about one object?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Drawn from code that ran under real load.

Follow
✓ Verified
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
🔥

That's GraphQL. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
GraphQL Query Depth Attack and Complexity Limits
4 / 4 · GraphQL