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.
Diagnostic Workflow
- 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. - Count entries against plausible cardinality. Command: in the console,
queryClient.getQueryCache().getAll().lengthorObject.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. - 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. - 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.
- 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)
);
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 |
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.
Related
- Redux Store Memory Growth and Normalized State — entity maps that only ever grow
- TanStack Query Cache Garbage Collection Settings — gcTime, staleTime and a hard cap
- Apollo Client Cache Eviction and Memory Growth — evict, gc and root-query anchoring
- Framework-Specific Memory Optimization — how component teardown interacts with the store
- Pinia and Vuex Store Memory Retention — the same problem in the Vue ecosystem