Implementing Tag-Based Invalidation in Apollo Client
Apollo Client’s InMemoryCache stores every GraphQL response as a normalized entity graph keyed by __typename + a deterministic ID field. When a mutation resolves, the default behaviour only updates fields that appear verbatim in the mutation response — leaving every other query that touches the same entity blissfully unaware. The symptom is familiar: a user edits a record, the mutation succeeds, and the list view still shows the old value. This page walks through the complete workflow for wiring declarative, entity-scoped invalidation inside Tag-Based Invalidation Systems so that Apollo evicts or rewrites exactly the right normalized entries after each mutation.
For the broader context of when to use tag-based eviction versus polling or event-driven revalidation, see the Cache Invalidation & Server Synchronization overview. Engineers working in non-GraphQL stacks may find the sibling recipe Optimizing SWR Revalidation Intervals a useful comparison point.
Prerequisites
Before implementing the patterns below, confirm the following conditions hold in your project:
- Apollo Client v3.x installed (
@apollo/client≥ 3.0). Thecache.identify,cache.evict,cache.gc, andcache.modifyAPIs do not exist in v2. - Deterministic entity IDs: Every type that needs selective invalidation must carry a stable
idor customkeyFieldsconfiguration. Without this, Apollo cannot construct a normalized cache key and falls back to storing data under the root query, which defeats field-level eviction. - TypeScript recommended. The
cache.modifyfieldsargument is fully typed when you enableInMemoryCacheConfig’spossibleTypesoption. - Apollo DevTools browser extension installed for the inspection workflow in Step 1.
Step-by-step Implementation
Step 1 — Diagnose stale cache states with DevTools
Before writing any eviction logic, confirm that Apollo’s normalization is actually storing the entity you expect to evict.
- Open Apollo DevTools in the browser → Cache tab → enable Show Cache.
- Execute the stale query and locate the entry by
__typename:idcomposite key (e.g.,User:123). - Run the mutation. In the Network tab, confirm the mutation response includes the
idfield that matches Apollo’skeyFieldsfor that type. - Run
client.cache.extract()in the browser console and search for the composite key. If the key is absent or the fields are unchanged, the normalization is misconfigured — fixtypePolicies.keyFieldsbefore proceeding.
Cache Behavior Analysis. cache.extract() returns a snapshot of the entire normalized store as a plain object. Each key is the composite cache ID Apollo computed at write time. If the mutation response omits the id field, Apollo cannot identify the object and writes it under a synthetic root key, bypassing the normalized entity entirely — eviction against User:123 will have no effect.
Step 2 — Configure typePolicies and keyArgs
Correct typePolicies is the foundation. Without it, cache.identify returns undefined and every eviction call silently no-ops.
import { ApolloClient, InMemoryCache } from '@apollo/client';
const client = new ApolloClient({
uri: '/graphql',
cache: new InMemoryCache({
typePolicies: {
// Identify User entities by their `id` field (the default, shown explicitly).
User: {
keyFields: ['id'],
},
// For types whose primary key is not `id`, name it here.
TenantMembership: {
keyFields: ['userId', 'tenantId'],
},
Query: {
fields: {
// keyArgs prevents Apollo from treating different filter combinations
// as separate cache entries for the same logical query.
users: {
keyArgs: ['filter', ['role', 'status']],
merge: (existing = [], incoming) => [...existing, ...incoming],
},
},
},
},
}),
});
Cache Behavior Analysis. keyFields controls the composite cache key Apollo writes into InMemoryCache. With keyFields: ['id'], a User with id: "123" is always stored as User:123, regardless of which query fetched it. The keyArgs setting on Query.users means that users(filter: { role: "ADMIN" }) and users(filter: { role: "EDITOR" }) each get their own cache slot — evicting one does not disturb the other.
Trade-offs:
- Compound
keyFields(e.g.,['userId', 'tenantId']) increase the composite key length but are the only correct choice when no single field is unique. - Narrowing
keyArgstoo aggressively merges genuinely different result sets; widening it too broadly creates separate cache slots that will never share data even when the server returns the same records.
Step 3 — Evict the normalized entity after a mutation
Replace a growing refetchQueries array with a targeted update callback that evicts the specific entity.
import { useMutation, gql } from '@apollo/client';
const UPDATE_USER = gql`
mutation UpdateUser($id: ID!, $name: String!) {
updateUser(id: $id, name: $name) {
id
name
email
}
}
`;
export function useUpdateUser() {
return useMutation(UPDATE_USER, {
update(cache, { data }) {
if (!data?.updateUser) return;
// cache.identify derives the composite key from __typename + keyFields.
// Result: "User:123"
const cacheId = cache.identify(data.updateUser);
// Evict removes the entire normalized entry for this entity.
cache.evict({ id: cacheId });
// gc sweeps any objects now unreachable from root — e.g. field references
// that pointed to the evicted User entry.
cache.gc();
},
});
}
Cache Behavior Analysis. cache.evict({ id: 'User:123' }) deletes the User:123 slot from InMemoryCache’s data map. Every active query that reads through that slot detects a cache miss on the next render cycle and re-executes against the network. cache.gc() then removes any dangling Reference objects — for example, if a Post entity had a author field pointing to User:123, that reference becomes a ghost after eviction. Skipping cache.gc() does not cause incorrect data, but the store gradually accumulates unreachable objects that inflate memory usage.
Trade-offs:
- Full entity eviction forces a loading state on every component subscribed to that entity. When you already have the complete new field values in the mutation response, prefer
cache.modify(Step 4) to avoid the loading flash. cache.evictwithoutcache.gc()is safe but leaves orphaned references; callgc()in the same microtask unless profiling shows it is expensive for your store size.
Step 4 — Field-level rewrite with cache.modify
When the mutation response contains all updated fields, write them directly instead of evicting. The component re-renders with fresh data immediately — no loading state, no network round-trip.
import { useMutation, gql } from '@apollo/client';
const ARCHIVE_USER = gql`
mutation ArchiveUser($id: ID!) {
archiveUser(id: $id) {
id
status
archivedAt
}
}
`;
export function useArchiveUser() {
return useMutation(ARCHIVE_USER, {
update(cache, { data }) {
if (!data?.archiveUser) return;
cache.modify({
id: cache.identify(data.archiveUser),
fields: {
// Each field function receives the existing value as first argument.
status: () => data.archiveUser.status,
archivedAt: () => data.archiveUser.archivedAt,
},
});
},
});
}
Cache Behavior Analysis. cache.modify performs a surgical write directly into the normalized store without removing the entity. Apollo broadcasts a diff to every subscribed useQuery that reads the modified fields, triggering a synchronous re-render with the new data. No network request is issued because the entity still exists in the cache — only its field values changed. This is the preferred path when you can guarantee the mutation response includes every field the UI displays.
Trade-offs:
cache.modifycannot add new fields that were never fetched for an entity. If your query asks forlastLoginAtbut the mutation response does not return it, you cannot write it viamodify— you must either include it in the mutation selection set or follow up with anevict.- Modifying a field that is part of a paginated list (e.g., removing an archived user from
users(filter: { status: ACTIVE })) requires an additionalreadField+ filter step inside thefieldshandler, or a separateevicton the list’s root field.
Step 5 — Handle concurrent mutations with optimisticResponse
Rapid successive mutations — bulk status updates, autosaving form fields — can leave the UI in a partial state if writes race without an optimistic baseline.
import { useMutation, gql } from '@apollo/client';
const SAVE_DRAFT = gql`
mutation SaveDraft($id: ID!, $content: String!) {
saveDraft(id: $id, content: $content) {
id
status
content
updatedAt
}
}
`;
export function useSaveDraft() {
return useMutation(SAVE_DRAFT, {
// Apollo applies this immediately to the cache before the network request
// completes. It tracks the write as "optimistic" so it can be reverted.
optimisticResponse: ({ id, content }) => ({
saveDraft: {
__typename: 'Draft',
id,
status: 'SAVING',
content,
updatedAt: new Date().toISOString(),
},
}),
update(cache, { data }) {
if (!data?.saveDraft) return;
cache.modify({
id: cache.identify(data.saveDraft),
fields: {
status: () => data.saveDraft.status,
content: () => data.saveDraft.content,
updatedAt: () => data.saveDraft.updatedAt,
},
});
},
onError(_error, { variables }) {
// Apollo automatically reverts the optimisticResponse write on failure.
// Add any application-level side effects here (e.g. toast notification).
console.warn(`Draft ${variables.id} save failed; cache reverted.`);
},
});
}
Cache Behavior Analysis. When optimisticResponse is present, Apollo immediately applies the predicted response to the normalized cache and re-renders all subscribed components. It also stores a rollback snapshot of the pre-mutation cache state. When the real server response arrives, Apollo replaces the optimistic entry with the confirmed data. If the mutation fails, Apollo applies the rollback snapshot in full, restoring every field to its pre-mutation value — including any fields modified in the update callback during the optimistic pass.
Trade-offs:
optimisticResponsemust mirror the exact shape returned by the server, including__typenameon every nested object. A shape mismatch causes Apollo to write the optimistic data under a different cache key, creating a ghost entity that survives the rollback.- For concurrent mutations on the same entity, the last server response to settle wins. If mutation ordering matters, serialize the calls using a queue and validate
cache.extract()state between writes in your test suite.
SVG: Apollo Cache Invalidation Flow
The diagram below shows how a mutation write propagates through Apollo’s InMemoryCache — from the optimisticResponse application through to the final server-confirmed write or rollback.
Edge Cases & Gotchas
1. cache.identify returns undefined for non-normalized objects
If a mutation response type lacks a keyFields entry in typePolicies, Apollo stores it as an embedded object rather than a normalized entity. cache.identify returns undefined, and any subsequent cache.evict call silently no-ops.
Resolution: Add the type to typePolicies with explicit keyFields. If the backend cannot guarantee a stable ID, use keyFields: false to force all instances to be stored by reference at their parent — then evict the parent entity instead.
// typePolicies: { SearchResult: { keyFields: false } }
// Evict the owning Query field instead:
cache.evict({ id: 'ROOT_QUERY', fieldName: 'searchResults', args: { query: term } });
cache.gc();
2. List fields are not automatically updated after entity eviction
Evicting User:123 removes the entity from the store but does not remove the Reference to it from any users(...) list field. On the next render, Apollo resolves the dangling reference and returns null for that list slot (with a console warning), rather than the clean filtered list you probably want.
Resolution: Either evict the root list field as well, or use cache.modify with a readField filter to splice the evicted reference out of the list.
cache.modify({
id: 'ROOT_QUERY',
fields: {
users(existingRefs: Reference[], { readField }) {
return existingRefs.filter(ref => readField('id', ref) !== deletedId);
},
},
});
3. optimisticResponse shape mismatch creates ghost entities
If optimisticResponse returns a nested object without __typename, Apollo stores it as an embedded object. When the real server response arrives with __typename present, Apollo writes a second, normalized entry — and the optimistic ghost persists until the next gc() sweep.
Resolution: Always include __typename on every object in optimisticResponse, mirroring the exact shape the server returns. Enable @apollo/client’s __DEV__ warnings — they surface shape mismatches during development before they reach production.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| UI remains stale after successful mutation | update callback is absent, or cache.identify returns undefined because the type lacks keyFields. |
Log cache.identify(data.updateUser) — if undefined, add the type to typePolicies. Add an update callback that calls cache.evict or cache.modify. |
| Network waterfall on every mutation | refetchQueries targets entire query names, forcing all subscribed queries to re-fetch even when the data is available in the mutation response. |
Replace refetchQueries with a targeted update callback using cache.modify. Use refetchQueries only for queries whose data cannot be derived from the mutation response. |
Dangling null entries in list after entity deletion |
cache.evict removes the entity but not the Reference in parent list fields, leaving an unresolvable pointer. |
Follow the eviction with a cache.modify on the list field that filters out the stale reference using readField('id', ref) !== deletedId. |
Frequently Asked Questions
How do I verify which cache entries are active in production without DevTools?
Inject JSON.stringify(client.cache.extract()) into your error-monitoring middleware on mutation settle. Filter the output by __typename and entity ID to map active normalization keys. Keep this behind a feature flag or debug header — the full extract can be several megabytes on data-heavy pages.
Can targeted eviction replace refetchQueries entirely?
For most use cases, yes. cache.evict + cache.gc() and cache.modify provide declarative, entity-scoped invalidation that avoids the network entirely when the mutation response already contains the new field values. Reserve refetchQueries for cases where the mutation affects data your client does not yet hold — for example, a mutation that increments a server-side aggregate counter that was never included in any prior response.
What happens to cached data if eviction fires but the subsequent network fetch fails?
The entity is permanently removed from the local cache. Apollo issues a network fetch on the next read, shows a loading state, and then surfaces the error via useQuery’s error field. Configure errorPolicy: 'all' on the query to receive both data and errors simultaneously if the server returns a partial response. Implement a fallback UI state to avoid blocking user interaction while the re-fetch is in flight.
Related
- Tag-Based Invalidation Systems — the parent page covering the architectural patterns and framework adapter strategies this recipe implements.
- Cache Invalidation & Server Synchronization — the full pillar covering invalidation strategies across Apollo, React Query, SWR, and RTK Query.
- Mutation Sync & Rollback — covers the rollback and conflict-resolution patterns that complement the optimistic write workflow above.
- Optimizing SWR Revalidation Intervals — a sibling recipe for interval-based background revalidation in SWR, useful when comparing Apollo’s push model against SWR’s pull model.