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.
Triage by growth signature A two-by-two grid. Rising entry count with stable entry size indicates unstable query keys. Rising entry count with rising observer count indicates leaked subscriptions. Stable entry count with rising entry size indicates an unbounded infinite query. Stable count and size with rising heap indicates retention outside the cache, such as a closure holding a response. Which number is moving tells you which bug it is entry count ↑ · size flat unstable query key a new hash per render; hit rate near zero confirm: distinct hashes per key prefix count ↑ · observers ↑ leaked subscription gcTime never starts; nothing is collected confirm: observers after unmount count flat · size ↑ unbounded infinite query one entry accumulating pages confirm: pages.length on that entry cache flat · heap ↑ retention outside the cache a closure or ref pinning old data confirm: heap snapshot retainer path
Measure entry count, entry size and observer count separately. Collapsing them into "the cache is big" is what makes these bugs hard.

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.

Three growth signatures over twenty navigation cycles Line chart of cache entry count across twenty cycles for three applications. A healthy cache rises to 34 entries and plateaus. An unstable-key leak rises linearly to 240. A leaked-observer application rises to 118 and keeps climbing. Three growth signatures over twenty navigation cycles healthy — plateaus leaked observers unstable key 0 60 120 180 240 cache entries 0 4 8 12 16 20 navigation cycle
A plateau is the signature of a correct cache. Both failure modes are straight lines; only their slope differs.

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.

  1. Load the application and complete one interaction cycle to warm everything up.
  2. Take a heap snapshot and label it “baseline”.
  3. Repeat the interaction cycle ten times.
  4. Take a second snapshot and compare against the baseline, filtering to objects allocated between them.
  5. 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.

The order to run these checks in Timeline of the diagnostic sequence: sample the three counters, group by prefix to find unstable keys, check observer counts after navigation, inspect the largest entry, and only then take a heap snapshot. The order to run these checks in step 1 Sample three counters entries, observers, bytes step 2 Group by key prefix finds unstable keys step 3 Count observers after unmount finds leaked subscriptions step 4 Sort entries by size finds unbounded infinite queries step 5 Heap snapshot only for retention outside the cache
The heap snapshot is last on purpose. Three of the four causes are visible from the cache itself, in seconds, without leaving the console.

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.