Detecting Cache Memory Leaks in React Query
A query cache is supposed to grow. The difficulty is telling the difference between a cache that has grown to hold a session’s working set — correct, and the reason you installed it — and one that will keep growing until the tab is killed. This page is the diagnostic procedure for that distinction, sitting under Cache Memory Management alongside Tuning gcTime to Control Cache Memory, which covers what to change once you know what is leaking, and Limiting Cache Size With LRU Eviction, which is the answer when the working set itself is simply too big.
Diagnostic Checklist
Prerequisites
- Access to the running application with DevTools, ideally on a build with readable names.
- The cache inspection helper from Cache Observability & Debugging, or a willingness to paste one into the console.
- A reproducible interaction loop — a navigation cycle you can repeat twenty times.
Step 1 — Separate Count Growth From Size Growth
The first measurement decides which half of the diagram you are in. Sample all three numbers on a timer during your repeatable interaction loop.
import type { QueryClient } from '@tanstack/react-query';
export interface CacheSample { at: number; entries: number; observers: number; bytes: number }
export function sampleCache(client: QueryClient): CacheSample {
const queries = client.getQueryCache().getAll();
return {
at: Math.round(performance.now()),
entries: queries.length,
observers: queries.reduce((sum, q) => sum + q.getObserversCount(), 0),
// Serialized length is not heap bytes, but it tracks the same direction.
bytes: queries.reduce(
(sum, q) => sum + (q.state.data ? JSON.stringify(q.state.data).length : 0),
0,
),
};
}
export function recordSamples(client: QueryClient, everyMs = 5_000) {
const samples: CacheSample[] = [];
const timer = setInterval(() => samples.push(sampleCache(client)), everyMs);
return { samples, stop: () => clearInterval(timer) };
}
Cache Behavior Analysis: getAll() returns live Query objects without registering an observer, so sampling cannot itself keep an entry alive or reset a gcTime timer — an important property, because a naive probe that subscribed would create the very leak it was looking for. The bytes figure deliberately uses serialized length rather than any heap API: it is comparable across samples, it needs no instrumentation, and it ranks entries correctly, which is all a triage step requires. Run this against a loop of at least twenty navigation cycles; a leak that adds one entry per cycle is invisible in three.
Step 2 — Detect Unstable Keys by Counting Hashes per Resource
An unstable key produces many hashes for one logical resource. Grouping the cache by key prefix makes the ratio obvious.
export function findUnstableKeys(client: QueryClient, threshold = 8) {
const byPrefix = new Map<string, Set<string>>();
for (const query of client.getQueryCache().getAll()) {
const prefix = (query.queryKey as unknown[]).slice(0, 2).join('/');
const hashes = byPrefix.get(prefix) ?? new Set<string>();
hashes.add(query.queryHash);
byPrefix.set(prefix, hashes);
}
return [...byPrefix.entries()]
.map(([prefix, hashes]) => ({ prefix, distinctHashes: hashes.size }))
// More distinct addresses than a screen could plausibly need means the key
// is being rebuilt rather than reused.
.filter((row) => row.distinctHashes >= threshold)
.sort((a, b) => b.distinctHashes - a.distinctHashes);
}
Cache Behavior Analysis: Slicing the key to its first two elements groups ['todos','list',{…}] variants together while keeping ['todos','detail',id] separate, which is the granularity that distinguishes “many filter combinations” from “a new object every render”. A resource with a genuinely large parameter space will show a high count too, so the confirming signal is the hit rate: real parameter variety produces repeat hits as users return to previous filters, while an unstable key produces a hit rate near zero because no address is ever visited twice. The fix is not memoization at the call site but normalization inside the key factory, as described in Cache Key Serialization & Hashing.
Step 3 — Find Observers That Outlived Their Components
gcTime only starts counting when the observer count reaches zero. A subscription that is never torn down freezes an entry in memory permanently.
export function findOrphanedObservers(client: QueryClient) {
return client
.getQueryCache()
.getAll()
.filter((query) => {
const hasObservers = query.getObserversCount() > 0;
// Nothing has read this entry in over ten minutes, yet something is
// still subscribed to it. No screen behaves like that.
const idleMs = Date.now() - (query.state.dataUpdatedAt || 0);
return hasObservers && idleMs > 10 * 60_000;
})
.map((query) => ({
hash: query.queryHash,
observers: query.getObserversCount(),
idleMinutes: Math.round((Date.now() - query.state.dataUpdatedAt) / 60_000),
}));
}
// The usual culprit: a manual subscription with no teardown.
useEffect(() => {
const unsubscribe = queryClient.getQueryCache().subscribe(handleCacheEvent);
// Returning the unsubscribe is the whole fix. Forgetting it means every
// mount adds a listener that keeps its closure — and its captured data — alive.
return unsubscribe;
}, [queryClient]);
Cache Behavior Analysis: Each useQuery creates a QueryObserver that unsubscribes on unmount, so React-driven observers rarely leak; the leaks come from manual subscribe calls, from observers created inside event handlers, and from QueryObserver instances constructed directly in non-React code. Because an entry with observers is never garbage collected regardless of gcTime, one leaked observer on a large response holds that entire response tree for the life of the tab. Rising observers in the Step 1 samples while the user navigates away from screens is the specific signature — a correct application’s observer count falls back toward its baseline after every navigation.
Step 4 — Confirm With a Heap Snapshot
Cache instrumentation cannot see retention that happens outside the cache. When entry count and size are flat but the heap is not, the retainer path in a snapshot is the only authority.
- Load the application and complete one interaction cycle to warm everything up.
- Take a heap snapshot and label it “baseline”.
- Repeat the interaction cycle ten times.
- Take a second snapshot and compare against the baseline, filtering to objects allocated between them.
- Select the largest retained object and read its retainer path from the bottom up.
Cache Behavior Analysis: A retainer path ending in QueryCache → Map → Query → state → data is genuine cache retention and the earlier steps apply. A path ending in a closure, a useRef, an event listener or a module-level array is retention your application owns, and no amount of gcTime tuning will free it — the classic case is a component that copies query data into a ref for a comparison and never clears it, which keeps the old response alive after the cache has already replaced it. Because structural sharing means an updated response reuses unchanged sub-objects, a single retained old response can pin a surprisingly large fraction of every subsequent response too.
Edge Cases & Gotchas
Infinite queries look like one entry. useInfiniteQuery stores every page inside a single cache entry’s data.pages array, so entry count stays flat while size grows without limit. Cap it with maxPages, which discards pages from the far end as new ones load:
useInfiniteQuery({
queryKey: ['activity', 'feed'],
queryFn: fetchActivityPage,
initialPageParam: null,
getNextPageParam: (last) => last.nextCursor,
// Keep a sliding window rather than the whole scroll history.
maxPages: 5,
});
Development mode doubles observers. React strict mode mounts, unmounts and remounts effects, so observer counts during development can be transiently double. Measure in a production build before concluding you have a leak.
DevTools itself subscribes. The DevTools panel registers observers to display entries. Close it before sampling, or subtract its contribution from your counts.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Entry count grows linearly with renders | A non-primitive value is rebuilt into the key each render | Group by key prefix and count distinct hashes; normalize inside the key factory |
Entries persist far beyond gcTime |
An observer was never unsubscribed, so the timer never started | Return the unsubscribe function from every useEffect that subscribes |
| One entry accounts for most of the cache | An infinite query accumulates pages without a bound | Set maxPages on the infinite query |
| Heap grows while the cache stays flat | A closure or ref outside the cache retains old response objects | Take two heap snapshots and read the retainer path |
| Memory looks fine locally, grows in production | Sessions are short in development and long in production | Reproduce with a scripted twenty-cycle navigation loop |
Frequently Asked Questions
Is a growing query cache always a leak?
No, and treating every increase as a bug leads to gcTime values so aggressive that the cache stops being useful. A cache that grows during the first minutes of a session and then plateaus is behaving exactly as designed: it has loaded the working set and is now serving from it. What identifies a leak is the absence of a plateau, or growth that outpaces the number of distinct screens and parameter combinations the user has actually visited. Sampling over a repeatable twenty-cycle loop makes the distinction unambiguous, because a correct cache produces the same plateau on cycle twenty as on cycle ten.
Why does gcTime not collect my unmounted queries?
Because the gcTime timer starts when the last observer unsubscribes, not when a component unmounts. If anything else is still subscribed — a manual subscribe call whose teardown was forgotten, an observer constructed outside React, or an open DevTools panel — the count never reaches zero and the timer never starts. This is why the observer count is a separate measurement from the entry count: gcTime is not being ignored, it is never being armed.
Does clearing the cache fix a leak?
It frees the memory once and destroys the evidence. If the underlying cause is a leaked observer, the subscriptions survive the clear and the entries repopulate; if it is an unstable key, clearing resets the counter and growth resumes at exactly the same rate, so the next report arrives an hour later. Clearing is a legitimate emergency mitigation for a shipped incident, but take the samples and the heap snapshot first — after a clear, the cheap diagnostics in Steps 1 to 3 all read normal.
Related
- Cache Memory Management — the parent guide on sizing a cache and the lifecycle that governs eviction.
- Tuning gcTime to Control Cache Memory — what to change once the diagnosis points at retention time rather than a leak.
- Limiting Cache Size With LRU Eviction — the answer when the working set itself is legitimately too large to hold.
- Cache Observability & Debugging — the instrumentation these diagnostics are built on.