TanStack Query Cache Garbage Collection Settings
A query cache is supposed to hold data after a component unmounts — that is the feature — so its memory behaviour is governed by policy rather than by bugs. This page sits under state management and client cache memory leaks in Framework-Specific Memory Optimization and covers the three settings that actually determine how large it gets.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| Thousands of cache entries after one session | Key contains an object, date or free-text value | Derive a short stable id and key on that |
| Memory grows on a dashboard left open all day | Entries created faster than gcTime removes them |
Add a hard cap on idle entries |
| Large responses stay in memory for minutes | Default gcTime tuned for small payloads |
Lower gcTime per query for heavy endpoints |
Entries survive far longer than gcTime |
A component is still mounted and observing them | Check observer counts before blaming the timer |
| Cache looks small but heap is large | Entries reference big shared structures | Read retained size, not entry count |
Root Cause: Two Timers and One Missing Bound
TanStack Query has two clocks and no ceiling. staleTime decides when data is considered old enough to refetch — a freshness policy that costs network, not memory. gcTime decides how long an entry with no mounted observer is retained before removal — the only memory policy the library provides by default, and it is a timer, not a cap.
A timer bounds the average case. It cannot bound the worst case, because entries are created by user interaction and removed by the clock, and interaction can outrun a clock. A filter panel with a text input keyed into the query produces a new entry per keystroke; at five entries per second against a five-minute expiry, the steady state is 1 500 live entries regardless of how sensible the timer looks.
That is why key design matters more than either setting. A key of ['orders', filterId, page] has cardinality equal to the number of real filter presets times the number of pages — a few dozen. A key of ['orders', { search, from, to, status, sort }] has cardinality equal to the number of distinct filter combinations the user has ever typed, which is unbounded in practice.
The third factor is observer count. An entry with a mounted observer is never removed, whatever gcTime says, because something on screen is using it. A background poller or a hidden-but-mounted tab therefore pins its data indefinitely — correct behaviour that regularly gets reported as a leak.
Step-by-Step Fix
- Count the entries. Command:
queryClient.getQueryCache().getAll().length. Expected: a number close to the count of distinct screens visited; anything an order of magnitude higher is key cardinality. - Group keys by family. Command: reduce
getAll()byq.queryKey[0]. Expected: one family dominates, and it is the one with an object or free-text component in the key. - Stabilise that key. Hash or enumerate the filter into a short id. Verification: the family’s entry count stops tracking keystrokes and starts tracking real filter presets.
- Tune
gcTimeby payload size. Large list responses get 30–60 seconds; small lookups can keep the default. Verification: retained size falls without any change in perceived navigation speed. - Add a hard cap. Trim least-recently-used idle entries on an interval. Verification: entry count plateaus at the cap under a synthetic stress test.
Command & Code Reference
Global defaults plus a per-query override, which is where most of the real tuning belongs.
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // freshness: costs network, never memory
gcTime: 5 * 60_000, // memory: how long an IDLE entry survives
},
},
});
// Heavy endpoints override the default — a 4 MB report has no business
// sitting in memory for five minutes after the user left the screen.
useQuery({
queryKey: ['report', reportId],
queryFn: fetchReport,
gcTime: 30_000,
});
Stabilising a key so its cardinality is bounded by the domain rather than by typing.
// BAD: a new cache entry on every keystroke and every date tweak.
useQuery({ queryKey: ['orders', { search, from, to, status, sort }], queryFn });
// GOOD: one short, canonical id per distinct filter state. Sorting the
// entries makes the id independent of property order, and rounding the
// dates to the day collapses thousands of near-identical keys into one.
function filterId({ search, from, to, status, sort }) {
const canonical = {
search: search.trim().toLowerCase().slice(0, 40),
from: from.slice(0, 10),
to: to.slice(0, 10),
status,
sort,
};
return Object.entries(canonical)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}:${v}`)
.join('|');
}
useQuery({ queryKey: ['orders', filterId(filters), page], queryFn });
The hard cap. Fifty lines of policy that turn an unbounded structure into a bounded one.
const MAX_IDLE_ENTRIES = 200;
export function startCacheTrimmer(queryClient, intervalMs = 60_000) {
const timer = setInterval(() => {
const cache = queryClient.getQueryCache();
const idle = cache.getAll()
.filter((q) => q.getObserversCount() === 0) // never touch active data
.sort((a, b) => a.state.dataUpdatedAt - b.state.dataUpdatedAt);
const excess = idle.length - MAX_IDLE_ENTRIES;
for (let i = 0; i < excess; i++) cache.remove(idle[i]); // oldest first
}, intervalMs);
timer.unref?.(); // no-op in browsers
return () => clearInterval(timer);
}
Verification & Regression Prevention
Verify with a stress test rather than a session: drive the filter panel with a script that produces a few hundred distinct states, then assert both the entry count and the cache root’s retained size. The count proves the policy works; the retained size proves the entries are not holding onto something larger through a shared reference.
For prevention, export the entry count as a metric in the same place your other client telemetry lands, bucketed by key family. A family whose 95th-percentile count starts climbing after a release is a key that has just lost its stability — usually because someone added a field to an object that ends up inside the key. That signal arrives weeks before anyone notices the memory.
FAQ
What is the difference between staleTime and gcTime?
staleTime controls refetching: how long data is considered fresh enough to serve without going back to the network. gcTime controls memory: how long an entry with no mounted observer is kept before removal. Raising staleTime never increases memory; raising gcTime always can.
Why does my cache have thousands of entries after one session?
Almost always key cardinality. A key containing an object literal, a Date, a search string or a scroll offset produces a new entry on every change, so an interactive filter panel can create hundreds of keys in a minute — far faster than a five-minute expiry removes them.
Does setting gcTime to zero fix memory problems?
It bounds them at the cost of the feature: every unmount discards the data, so returning to a screen always refetches and the user sees a loading state each time. A short gcTime with a stable key and a hard cap on idle entries gives nearly all the memory benefit while keeping navigation instant.
Related
- State Management and Client Cache Memory Leaks — the shared policy model for stores and caches
- Apollo Client Cache Eviction and Memory Growth — the same problem with a normalised graph cache
- Redux Store Memory Growth and Normalized State — when the entities live in a store instead