State Management and Client Cache Memory Leaks

A component leak costs kilobytes; a state-management leak costs the whole session, because the store is created once and never unmounts. This guide within Framework-Specific Memory Optimization covers the three shapes that dominate real heaps — a normalised entity store, a query cache and a client-side GraphQL cache — and what bounding each one actually requires. The mechanics are the same as the Pinia and Vuex store retention problem, but the libraries differ enough that the fixes do not transfer.

Conceptual Grounding: A Store Is a Cache Without an Eviction Policy

Every client-side store is a module-scope singleton. It is created on first import, referenced by the root of the application, and reachable from a garbage-collection root for as long as the tab lives. Nothing about that is a bug — it is the entire point of a store — but it means the store has exactly the retention characteristics of a cache with no maximum size and no expiry, unless you give it both.

Three growth patterns account for almost all of it. The first is entity accumulation: every fetched record is normalised into a lookup keyed by id, and nothing ever deletes an id. The second is key cardinality: a query cache keyed by a serialised argument object creates a fresh entry whenever any argument changes, so a filter panel with six controls can generate thousands of distinct keys in one session. The third is derived duplication: a selector result, a sorted copy or a rendered fragment stored back into the state, doubling the memory of whatever it was derived from and often capturing DOM nodes as well.

What the store root keeps alive The application root references the store, which holds three growing structures: an entity map of forty thousand records, a query cache of eight hundred and forty keys, and a derived list that duplicates part of the entity map. Component instances come and go beneath it without affecting any of the three. Components mount and unmount; none of the three boxes below ever shrinks app root → store a GC root for the whole tab entities: Map 40 000 records × 1.2 KB 48 MB retained no delete action exists queryCache: Map 840 distinct keys 19 MB retained keys include a filter object visibleRows: Array derived copy of 12 000 rows 14 MB retained recomputable in 3 ms Total 81 MB, of which roughly 14 MB is pure duplication and most of the rest is data the user will never look at again. None of it is unreachable, so no collector will ever touch it — this is a policy problem, not a garbage-collection problem.

Diagnostic Workflow

  1. Find the store root in a snapshot. DevTools path: Memory → Heap snapshot → Summary → filter by your store’s constructor name (QueryClient, InMemoryCache, or your own). Expected: one instance; its retained size is the number that matters, and it is frequently 10–100× its shallow size.
  2. Count entries against plausible cardinality. Command: in the console, queryClient.getQueryCache().getAll().length or Object.keys(store.getState().entities.byId).length. Expected: a number in the same order of magnitude as what the user actually visited. Ten thousand entries after twenty page views means nothing evicts.
  3. Identify the key generator. Expected: a query key containing an object, a Date, or a search string produces unbounded cardinality; a key of ['user', id] does not.
  4. Separate cache from derived state. DevTools path: Containment view → walk down from the store. Expected: anything that is a copy, a sort or a rendered fragment of data already present elsewhere should not be there.
  5. Apply the library’s own eviction, then re-measure. Expected: retained size falls to roughly cap × bytes-per-entry, and — importantly — stays there across a second long session rather than growing again more slowly.

Code Patterns & Signatures

Bounding a TanStack Query cache. The default keeps unused entries for five minutes; on a long-lived dashboard the cap matters more than the timer.

import { QueryClient } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // How long an entry survives with NO mounted observer.
      gcTime: 5 * 60_000,        // 5 minutes; lower it for large payloads
      staleTime: 30_000,         // avoids refetch storms, unrelated to memory
    },
  },
});

// Keys must have bounded cardinality. This one does not:
//   ['orders', { search, from, to, page, sort }]  → thousands of keys
// This one does — the filter is serialised into a small, stable id:
const key = ['orders', filterId, page];

// A hard cap for dashboards that outlive gcTime:
setInterval(() => {
  const cache = queryClient.getQueryCache();
  const idle = cache.getAll()
    .filter((q) => q.getObserversCount() === 0)
    .sort((a, b) => a.state.dataUpdatedAt - b.state.dataUpdatedAt);
  // Keep the 200 most recently used idle entries, drop the rest.
  for (const q of idle.slice(0, Math.max(0, idle.length - 200))) {
    cache.remove(q);
  }
}, 60_000);

Evicting from an Apollo normalised cache. Nothing is released until gc() runs, and gc() only collects entities that are no longer referenced by any root query.

// Drop a single entity and then sweep anything it was the last reference to.
function forgetOrder(cache, id) {
  cache.evict({ id: cache.identify({ __typename: 'Order', id }) });
  // evict() removes the entry; gc() removes everything now unreachable
  // from the remaining root queries and returns the ids it dropped.
  const removed = cache.gc();
  return removed.length;
}

// On leaving a section, drop the root query that anchors its entities:
function leaveOrdersSection(cache) {
  cache.evict({ id: 'ROOT_QUERY', fieldName: 'orders' });
  cache.gc();                     // now the Order entities become collectable
}

Keeping derived data out of the store entirely. The selector recomputes on read and retains nothing.

import { createSelector } from 'reselect';

// The store holds ids and entities only — never a sorted or filtered copy.
const selectVisibleOrders = createSelector(
  [(s) => s.orders.byId, (s) => s.orders.allIds, (s) => s.filters.status],
  (byId, allIds, status) =>
    // Recomputed only when one of the three inputs changes; the result is
    // held by the component that reads it and dies with that component.
    allIds.map((id) => byId[id]).filter((o) => o.status === status)
);
Entry count over a 30-minute session, three policies With no eviction the entry count climbs steadily to eight hundred and forty. With a five minute expiry it oscillates between one hundred and three hundred as bursts of activity outrun the timer. With a hard cap of two hundred it rises once and then stays flat for the rest of the session. A timer bounds the average; only a cap bounds the worst case 0 450 900 session time (0 → 30 min) no eviction → 840 entries gcTime only → 100–300 hard cap of 200 → flat The amber line is what most applications ship: fine on average, and still capable of a 300-entry spike at the worst moment.

Sizing a Cache Against a Real Device Budget

A cache policy is only meaningful next to a number, and the number is not “how much memory does the machine have”. A mid-range Android phone gives a browser tab a working budget in the region of 256 MB before the operating system starts reclaiming, and the tab has to fit the framework, the rendered DOM, decoded images and the JavaScript heap into that. Treating a hundred megabytes of it as a data cache is a decision, not an accident, and it should be made explicitly.

Work backwards. Decide the share of the budget the cache is allowed — 15 to 25 per cent is a defensible starting point for a data-heavy application — and divide by the measured bytes per entry. If a normalised order entity costs 1.2 KB retained and the cache is allowed 40 MB, the cap is roughly 33 000 entries, which is far more than any session will produce and tells you the cap is not the binding constraint. If the same budget is being spent on cached HTML fragments at 40 KB each, the cap is 1 000, which a busy session absolutely will reach — and that is the case where the policy has to be enforced rather than merely declared.

Measuring bytes per entry is the part teams skip. Take a heap snapshot with the cache populated to a known count, read the retained size of the cache root, and divide. Do it again at a larger count to check the figure is linear; a per-entry cost that grows with the entry count means entries are sharing structures in a way that will surprise you later, usually because a normalised entity references a large array held by another entity.

The last step is to decide what happens at the cap, and the honest answer is usually least-recently-used eviction. Time-based expiry alone is simpler to implement and bounds the average case, but it cannot bound the worst case, because a burst of activity can create entries faster than the timer removes them — which is exactly the moment when the device is already under pressure. Combining the two, an expiry for staleness and a cap for memory, gives one policy for freshness and one for footprint, and lets each be tuned without breaking the other.

Symptom-to-Fix Reference Table

Symptom Root Cause Immediate Action Measurable Impact
Heap grows steadily on a dashboard left open all day Query cache with no cap, keys of unbounded cardinality Add a hard cap on idle entries and stabilise the key Flat heap after the cap is reached
Snapshot shows one huge InMemoryCache object Apollo normalised entities never evicted cache.evict the root field on section exit, then cache.gc() Retained size falls by the size of that section’s data
Store retains detached DOM nodes Rendered fragments or element refs stored in state Store identifiers only; render from them Detached node count drops to zero
Two structures hold the same rows Derived collection written back into the store Replace with a memoised selector Removes the full duplicate — often 10–20 MB
Memory falls only on full reload Nothing in the store is ever removed Add a reset on logout and on leaving a major section Baseline returns between sections
Cache entry count grows faster than page views Key includes a timestamp, object literal or free-text search Hash the filter into a stable short id Key count drops to the number of real filter combinations
Where eviction belongs in a navigation On entering a section the cache is populated. During use it grows to its working size. On leaving, a single eviction call drops the section's entities and returns the cache to its baseline. Without that call the section's data joins everything the session has accumulated so far. One call, at one moment, decides whether the session accumulates enter section cache: 412 entries baseline use it cache: 2 418 entries working size — fine leave section evict + collect cache: 431 entries …or do nothing cache: 2 418 and rising for the rest of the session The middle box is not a bug — a section is allowed to hold its own working set while the user is in it. The bug is that nothing distinguishes “in use” from “last used forty minutes ago” unless you write that call. Route guards and unmount effects are the natural homes for it: both fire exactly once, on the way out. Assert the return to baseline in a navigation test and the whole class of missing-eviction bugs is covered.

Edge Cases & Gotchas

Normalisation hides the growth. A normalised cache deduplicates entities, so the entry count looks reasonable while the retained size climbs — one entity referenced by twenty queries is stored once but kept alive by all twenty. Always read retained size on the cache root rather than counting entries.

Devtools extensions keep their own copy. The Redux DevTools extension retains an action history including a full state snapshot per action by default. A session that looks like a store leak in development can be entirely the extension’s history; confirm by measuring with the extension disabled before changing any application code.

Persisted state amplifies the problem. A store synchronised to localStorage or IndexedDB serialises the whole thing on every write. Beyond a few megabytes this becomes a main-thread cost as well as a memory one, and the serialised copy transiently doubles the memory during each write.

Server-side rendering makes an unbounded store a cross-request leak. The same module-scope singleton on the server is shared by every request, so an unbounded client-side cache pattern becomes a per-request retention bug with data-isolation consequences as well. Create the store per request on the server.

gcTime does not apply while an observer is mounted. A component that stays mounted — a background poller, a hidden tab — keeps its query entry alive indefinitely regardless of the timer, which is correct behaviour and a common surprise when a “five-minute” policy visibly retains data for hours.

One last habit is worth building: whenever a new cache is introduced, write its cap, its eviction rule and its measured bytes per entry into the same file as the cache itself. A cache whose bound is documented next to its definition survives refactors; a bound that lives only in a dashboard or in someone’s memory is removed by the first person who does not know it was deliberate.

FAQ

Why does my query cache keep data after the component unmounts?

That is the design: the cache keeps unused entries so a remount is instant. TanStack Query holds them for gcTime, five minutes by default, and removes them only when no observer is mounted. Entries accumulate whenever new query keys are generated faster than old ones expire — typically when a key includes a timestamp, a filter object or anything else with unbounded cardinality.

Is putting large lists in Redux a memory problem?

It is if nothing removes them. A Redux store is a module-scope object that lives for the tab’s lifetime, so every entity ever normalised into it stays until an action removes it. Storing 40 000 rows at roughly 1.2 KB each costs about 48 MB permanently, which on a mid-range phone is a meaningful share of the whole budget.

Does Apollo’s normalised cache leak?

It does not leak in the sense of unreachable objects, but it grows without bound by default. Normalisation deduplicates entities, which helps, yet nothing is evicted until you call cache.evict and cache.gc. On a long session over many detail pages the normalised store routinely becomes the largest single object in the heap.

Should I store derived data in the store?

No. A derived collection duplicates the memory of its source and adds a second thing to invalidate. Compute it in a selector or a computed value, which costs CPU on read and nothing on the heap — and never store rendered markup or DOM nodes, which additionally pin whole detached subtrees.