Stop Mixing cache.modify with refetchQueries
Apollo gives you two ways to keep the cache fresh after a mutation. Using both at the same time is an anti-pattern — and the cleanup is usually a net deletion.
• 4 min readIf you’ve worked in an Apollo codebase for more than a year, you’ve probably seen mutations that look like this:
const [archiveCandidate] = useMutation(ARCHIVE_CANDIDATE, {
update(cache, { data }) {
cache.modify({
id: cache.identify({ __typename: "Candidate", id }),
fields: { archived: () => true },
});
},
refetchQueries: ["CandidateList", "CandidateCount"],
});
Both update and refetchQueries. Belt and braces. It “works” — the UI updates and the data is correct.
It’s also wrong, and over the last quarter we ripped this pattern out of about eight mutations in our codebase. The net was around 130 lines deleted and a measurable drop in post-mutation network requests.
Why it’s wrong
Apollo gives you two distinct strategies for keeping the cache consistent after a mutation:
- Optimistic / surgical cache updates —
update,cache.modify,cache.writeFragment. You tell Apollo exactly what changed. Cheap, instant, no network round trip. - Refetch —
refetchQueries. You tell Apollo “go ask the server again.” Slower, but bulletproof for cases where you can’t compute the new state on the client.
Pick one per mutation. They solve the same problem with different trade-offs.
When you do both:
- You pay for the surgical update and a network round trip
- The refetch overwrites whatever the surgical update wrote, so the surgical update was busywork
- You’re shipping two sources of truth for the same invariant. Which one does the next engineer trust?
The decision rule
We wrote this down as an ADR to stop relitigating it in every code review:
If you can compute the post-mutation state on the client, use
update/cache.modify. If you can’t (server-computed fields, aggregations, ordering you don’t control), userefetchQueries. Never both.
The forcing function is the “can you compute it” question. Most of the time you can, and the surgical update is a few lines. The cases where you genuinely can’t — counts, server-side rankings, cross-entity invariants — are the cases where refetchQueries earns its keep.
What we found while cleaning up
A few patterns kept showing up:
Redundant org-ID threading. Mutations were passing organizationId purely so refetchQueries could re-issue scoped queries. Once we dropped the refetch, the prop disappeared from three or four layers of components.
Subscription-backed queries getting refetched anyway. If you have a subscription keeping a query live, refetching on mutation is doubly redundant — the subscription will deliver the update. We had this in several places.
“Just in case” refetches. The most common one. Someone added refetchQueries because they weren’t sure the cache update was complete, and it stuck around forever. In most cases the cache update was fine.
The smell
If you’re tempted to add refetchQueries to a mutation that already has update, stop and ask: why don’t I trust my own cache update? Usually one of:
- You’re not sure which queries observe this entity (learn
cache.identify, it’s worth the hour) - You’re worried about a derived field somewhere (write a fragment for it, or accept the refetch instead of the surgical update)
- You’re cargo-culting from another mutation that does both
None of those are reasons to keep both. They’re reasons to pick the right one and delete the other.
Takeaway
Apollo cache strategy is one of those things every team rediscovers the hard way. The fix isn’t more cleverness — it’s picking a rule, writing it down, and letting code review enforce it.
In our case, the rule was a net deletion. That’s usually the sign you’re on the right track.