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.

Evicting a root field is what releases the entities The ROOT_QUERY orders field references three pages of fifty entities each. Evicting a single entity leaves the rest referenced and reclaims nothing measurable. Evicting the root field detaches all one hundred and fifty, and the following gc call removes every one that no other field references. One root field anchors every page it ever returned ROOT_QUERY orders({ status: "open" }) 150 references page 1 · Order:1001…1050 page 2 · Order:1051…1100 page 3 · Order:1101…1150 evict one entity 149 still anchored · ~0 freed evict the field, then gc() 150 detached and swept gc() is a reachability sweep, not an expiry: an entity referenced by any surviving field is kept regardless of age. That is what makes normalisation efficient — and what makes “just evict the old ones” harder than it sounds. Fetching 40 pages and displaying one leaves 2 000 entities anchored by a single field entry.

Step-by-Step Fix

  1. Measure what the cache holds. Command: JSON.stringify(cache.extract()).length for 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.
  2. 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.
  3. Evict the field on section exit. Verification: cache.extract().ROOT_QUERY no longer contains it.
  4. 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.
  5. Re-measure. Verification: the InMemoryCache retained size falls by roughly entities × 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);
          },
        },
      },
    },
  },
});
Entity count over forty pages With no eviction the entity count climbs linearly to two thousand. A merge policy that keeps a two hundred entry window holds it flat at two hundred after the first four pages. Eviction on section exit lets it climb within the section and return to near zero on leaving. Forty pages of fifty orders, three cache policies 0 1 000 2 000 pages loaded (0 → 40) no eviction → 2 000 entities windowed merge → 200 evict on section exit The amber policy peaks higher but returns to baseline; the green one never peaks at all.

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.

The round-trip assertion Cache key count is measured before entering a section at four hundred and twelve, at its peak inside the section at two thousand four hundred and eighteen, and after leaving at four hundred and thirty-one. The test asserts the final value is within five per cent of the first. One deterministic test that catches every missing eviction before entering 412 keys peak inside 2 418 keys after leaving 431 keys within 5% of the start — pass The peak is allowed to be large; only the return matters. A test written on the peak would fail every time someone added a legitimate query, which is exactly the kind of assertion teams end up deleting. Run it once per major section and the whole class of missing-eviction bugs is covered.

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.