Apollo Client Cache Eviction and Memory Growth
Apollo’s normalised cache is one of the most memory-efficient designs available — every entity stored once, shared by every query that references it — and also one of the easiest to grow without bound, because efficiency per entity says nothing about the number of entities. This page belongs to state management and client cache memory leaks in Framework-Specific Memory Optimization.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
InMemoryCache is the largest object in the snapshot |
Entities normalised and never evicted | Evict the anchoring root field, then gc() |
evict called but memory unchanged |
gc() never ran, so entities are still stored |
Always follow eviction with cache.gc() |
| Paginated list retains every page ever loaded | Each page’s entities remain reachable | Evict the field on leaving, or cap pages kept |
| Cache grows after refetches of the same query | Refetch replaces references, not entities | Evict stale entities explicitly |
| Memory falls only on full reload | No eviction at any navigation boundary | Evict per section exit and on logout |
Root Cause: Reachability, Not Recency
InMemoryCache stores two kinds of thing. Normalised entities are keyed by type and id — Order:1183 — and hold the fields the queries requested. Root query fields are entries under ROOT_QUERY, one per query-plus-arguments combination, holding references to the entities that query returned.
Removal follows reachability. cache.gc() walks from the roots, marks every entity it can reach, and drops the rest — the same idea as tracing garbage collection, applied to the cache’s own graph. Crucially it is not a timer and not an LRU: an entity referenced by any root field survives, no matter how long ago it was last read.
That is why evict alone appears to do nothing. Evicting Order:1183 removes that entry, but if ROOT_QUERY.orders({...}) still references it, the reference dangles and the next read may refetch. Conversely, evicting the root field detaches a whole page of entities at once, and the following gc() reclaims all of them — provided no other field also references them, which is exactly the property normalisation gives you and exactly what makes the eviction non-obvious.
Pagination is the classic case. Fetching pages one through forty creates forty root-field entries — or one, with a merge policy — each referencing fifty entities. Nothing removes any of them, so the cache holds 2 000 orders while the user looks at 50.
Step-by-Step Fix
- Measure what the cache holds. Command:
JSON.stringify(cache.extract()).lengthfor a rough size, plus a heap snapshot for the true retained figure. Expected: the serialized size understates retention, sometimes by half, because it does not count structure. - List the root fields. Command:
Object.keys(cache.extract().ROOT_QUERY). Expected: one entry per query-and-arguments combination; a long list here is the equivalent of query-key cardinality. - Evict the field on section exit. Verification:
cache.extract().ROOT_QUERYno longer contains it. - Run
gc()and read the return value. Verification: the returned array of dropped ids is non-empty; an empty array means something else still references the entities. - Re-measure. Verification: the
InMemoryCacheretained size falls by roughlyentities × bytes per entity, and does not creep back on the next visit.
Command & Code Reference
Section-scoped eviction. Calling this on route exit is the single highest-value change for a long-lived Apollo application.
// Detach a whole query's results, then sweep whatever became unreachable.
export function releaseSection(cache, fieldName, args) {
// Evicting the ROOT_QUERY field drops the references that anchor the
// entities; without this, evicting entities one by one achieves nothing
// because the field still points at them.
cache.evict({ id: 'ROOT_QUERY', fieldName, args });
const dropped = cache.gc(); // returns the normalized ids removed
return dropped.length;
}
// In a route guard or an unmount effect:
useEffect(() => () => {
const n = releaseSection(client.cache, 'orders', { status: 'open' });
console.debug(`released ${n} cached entities on leaving orders`);
}, []);
A least-recently-used cap built on top of evict, since the cache has no maximum size of its own.
const MAX_ENTITIES = 3_000;
const seen = new Map(); // normalized id → last access timestamp
export function noteAccess(id, now) {
seen.set(id, now);
}
export function trimCache(cache, now) {
const data = cache.extract();
// Every key that looks like "Type:id" is a normalized entity.
const ids = Object.keys(data).filter((k) => k.includes(':') && k !== 'ROOT_QUERY');
if (ids.length <= MAX_ENTITIES) return 0;
const oldestFirst = ids.sort((a, b) => (seen.get(a) ?? 0) - (seen.get(b) ?? 0));
for (const id of oldestFirst.slice(0, ids.length - MAX_ENTITIES)) {
cache.evict({ id });
seen.delete(id);
}
cache.gc(); // the sweep that actually frees memory
return ids.length - MAX_ENTITIES;
}
A merge policy that keeps a paginated field from accumulating every page it ever saw.
new InMemoryCache({
typePolicies: {
Query: {
fields: {
orders: {
keyArgs: ['status'], // one field entry per status, not per page
merge(existing = [], incoming, { args }) {
// Keep only the most recent window instead of appending forever.
const WINDOW = 200;
const merged = args?.offset === 0 ? [...incoming]
: [...existing, ...incoming];
return merged.slice(-WINDOW);
},
},
},
},
},
});
Verification & Regression Prevention
Verify with the return value of gc() first — it is the cheapest signal available and tells you immediately whether the eviction detached anything. An empty array after evicting a root field means another field still references those entities, which is usually a second query with different arguments returning the same records.
Then confirm in a snapshot, because cache.extract() measures the serialized form and understates the real retained size. For prevention, add an assertion to the route-level tests: navigate into the section, load several pages, navigate out, and assert that Object.keys(cache.extract()) returns to within a small delta of its pre-navigation size. That is a fast, deterministic test that fails the moment someone adds a query without a matching eviction.
FAQ
What is the difference between evict and gc?
evict removes one specific entry — an entity or a field on a root query. gc sweeps the whole cache and removes every normalised entity that is no longer reachable from any root query. Calling evict without gc typically frees nothing measurable, because the entities it detached are still stored; gc is what actually reclaims them.
Does refetching a query replace the old entities?
It replaces the field’s reference list, not the entities. Entities from the previous page remain normalised in the cache and stay reachable if any other field still references them. That is why a paginated list can leave every page it ever fetched in memory even though only one page is displayed.
Can I put a size cap on InMemoryCache?
There is no built-in maximum size. You get typePolicies for controlling how fields merge, and evict plus gc for removal, so a cap has to be built: track which entities you have seen, and evict the least recently used ones on a schedule or at navigation boundaries.
Related
- State Management and Client Cache Memory Leaks — the policy model behind all three cache types
- TanStack Query Cache Garbage Collection Settings — timers and caps in a non-normalised cache
- How Mark-and-Sweep Garbage Collection Works — the reachability model
cache.gc()mirrors