Limiting Cache Size With LRU Eviction
gcTime bounds how long an unused entry survives. It does not bound how much the cache holds, and on a long-lived session with heavy responses those are very different guarantees — a user who opens forty boards in twenty minutes stays inside every gcTime window and still ends up with forty full board payloads resident. This page builds the missing size policy on top of the public cache API, under Cache Memory Management. Read Detecting Cache Memory Leaks in React Query first: eviction is the right answer for a working set that is genuinely too large, and the wrong answer for a leak, which it will merely mask. Tuning gcTime to Control Cache Memory covers the time-based lever this complements.
Prerequisites
- A confirmed size problem: the cache plateaus, but at a level that is too high.
- A rough budget in bytes, derived from the lowest-memory device you support.
- Query keys grouped under stable namespaces, so eviction policy can differ per resource.
Step 1 — Track Last Access per Query Hash
The QueryCache records when data was last updated, not when it was last read. Recency requires its own bookkeeping, fed from the event stream.
import type { QueryClient } from '@tanstack/react-query';
export function createAccessTracker(client: QueryClient) {
const lastAccess = new Map<string, number>();
const unsubscribe = client.getQueryCache().subscribe((event) => {
// An observer attaching means a component started reading this entry —
// the closest signal the cache emits to "the user looked at this".
if (event.type === 'observerAdded' || event.type === 'updated') {
lastAccess.set(event.query.queryHash, Date.now());
}
if (event.type === 'removed') {
lastAccess.delete(event.query.queryHash);
}
});
return {
unsubscribe,
at: (hash: string) => lastAccess.get(hash) ?? 0,
};
}
Cache Behavior Analysis: observerAdded fires even when the entry is fresh and no fetch happens, which is exactly the case a fetch-based recency signal would miss — a screen the user revisits every minute would otherwise look cold. Deleting on removed keeps the map from becoming its own leak; without it the tracker accumulates one entry per hash the application has ever created, which on an unstable key is the very growth you were trying to bound. Entries with no recorded access fall back to 0 and therefore sort as coldest, which is the right default for anything created before the tracker started.
Step 2 — Decide What Is Eligible
Eviction must never touch an entry a component is currently rendering or waiting on. Everything else is negotiable.
import type { Query } from '@tanstack/react-query';
export interface Candidate { hash: string; key: unknown[]; bytes: number; lastAccess: number }
const PINNED_PREFIXES = new Set(['session', 'flags', 'me']);
export function findCandidates(client: QueryClient, tracker: { at: (h: string) => number }): Candidate[] {
return client
.getQueryCache()
.getAll()
.filter((query: Query) => {
// A mounted component is reading this — removing it shows a spinner.
if (query.getObserversCount() > 0) return false;
// A request is in flight; removing it cancels work already paid for.
if (query.state.fetchStatus !== 'idle') return false;
// Small, cheap, always-needed data is not worth the churn.
if (PINNED_PREFIXES.has(String((query.queryKey as unknown[])[0]))) return false;
return query.state.data !== undefined;
})
.map((query) => ({
hash: query.queryHash,
key: query.queryKey as unknown[],
bytes: JSON.stringify(query.state.data).length,
lastAccess: tracker.at(query.queryHash),
}));
}
Cache Behavior Analysis: The observer check is the one that cannot be relaxed: removeQueries on an observed query removes its data and notifies observers, so a mounted component re-renders into a pending state and refetches, which the user experiences as a screen blanking for no reason. Excluding non-idle fetchStatus covers both an initial fetch and a background refetch — in v5 those are distinguished by status versus fetchStatus, and only the latter tells you whether a request is currently outstanding. The pinned-prefix list exists because session and feature-flag entries are tiny and needed on every screen; evicting them saves kilobytes and costs a round trip on the next render.
Step 3 — Evict the Coldest Until You Are Under Budget
Sort by recency, then remove until the budget is satisfied, preferring large entries when two are equally cold.
export function evictToBudget(
client: QueryClient,
candidates: Candidate[],
budgetBytes: number,
currentBytes: number,
): { evicted: number; freed: number } {
if (currentBytes <= budgetBytes) return { evicted: 0, freed: 0 };
const cold = [...candidates].sort((a, b) => {
// Primary: oldest access first. Secondary: bigger first, so we reach the
// budget in fewer removals and keep more distinct entries cached.
if (a.lastAccess !== b.lastAccess) return a.lastAccess - b.lastAccess;
return b.bytes - a.bytes;
});
let freed = 0;
let evicted = 0;
for (const candidate of cold) {
if (currentBytes - freed <= budgetBytes) break;
// exact: true so a prefix-shaped key cannot remove a whole family by accident.
client.removeQueries({ queryKey: candidate.key, exact: true });
freed += candidate.bytes;
evicted += 1;
}
return { evicted, freed };
}
Cache Behavior Analysis: exact: true is essential — without it, removing ['boards','detail','17'] would also match anything nested beneath that prefix, and a single eviction could silently take a family of related entries with it. removeQueries deletes the entry outright rather than marking it stale, so the next observer starts from a hard miss and shows a loading state; that is the intended cost and the reason the observer filter in Step 2 matters so much. Sorting by size within an access timestamp rather than globally keeps the policy recency-first: a large entry the user is still moving between screens with should not be evicted ahead of a small one they abandoned ten minutes ago.
Step 4 — Run Eviction on Write, Not on a Timer
A timer either runs too often, burning CPU on a stable cache, or too rarely, letting the cache overshoot between ticks. Cache writes are the natural trigger.
export function installSizeCap(client: QueryClient, budgetBytes = 1_500_000) {
const tracker = createAccessTracker(client);
let scheduled = false;
const unsubscribe = client.getQueryCache().subscribe((event) => {
// Only a successful write can push the cache over budget.
if (event.type !== 'updated' || event.action.type !== 'success') return;
if (scheduled) return;
scheduled = true;
// Defer past the current notify cycle so components finish rendering
// against the data that just landed before anything is removed.
queueMicrotask(() => {
scheduled = false;
const candidates = findCandidates(client, tracker);
const total = client
.getQueryCache()
.getAll()
.reduce((sum, q) => sum + (q.state.data ? JSON.stringify(q.state.data).length : 0), 0);
evictToBudget(client, candidates, budgetBytes, total);
});
});
return () => {
tracker.unsubscribe();
unsubscribe();
};
}
Cache Behavior Analysis: Deferring with queueMicrotask moves the eviction outside the cache’s synchronous notify cycle, so observers finish reacting to the write before entries start disappearing — removing inside the notify pass can re-enter the cache mid-notification and produce observers that render against an entry that no longer exists. The scheduled flag collapses a burst of writes, which is what a prefetch loop or a page of parallel queries produces, into a single eviction pass. Serializing the whole cache on every pass is the real cost here; on a cache with more than a few hundred entries, cache the per-entry size at write time instead of recomputing it.
Edge Cases & Gotchas
Infinite queries dominate the ranking. One entry with fifty pages will always be the largest candidate, so a naive size-first policy evicts the user’s scroll position first. Either exclude infinite queries from the candidate set, or bound them with maxPages and let them compete fairly.
Mutation dependencies. An optimistic update writes into an entry that a pending mutation will roll back on failure. Evicting it means the rollback has nothing to restore. Exclude entries whose key prefix matches any pending mutation:
const busy = client.isMutating({ mutationKey: candidate.key.slice(0, 2) }) > 0;
if (busy) return false;
Persisted caches. Eviction changes what dehydrate will write, so a cap set lower than your persisted working set means the persisted snapshot silently shrinks too. Decide the budget for memory and the persistence allowlist separately.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| A visible screen flips to loading with no user action | An observed entry was evicted | Filter out getObserversCount() > 0 before ranking |
| A rollback restores nothing after a failed mutation | The optimistic entry was evicted while the mutation was in flight | Exclude entries with a pending mutation on the same prefix |
| Eviction removes unrelated queries | removeQueries matched by prefix instead of exact key |
Pass exact: true |
| CPU spikes on data-heavy screens | The whole cache is serialized on every write | Record entry size at write time and reuse it |
| The user’s scroll position resets | An infinite query was the largest candidate and was evicted first | Exclude infinite queries or cap them with maxPages |
Frequently Asked Questions
Why does TanStack Query not have a maxSize option?
Because the library cannot measure the size of your data. gcTime works as a built-in because time is something the library can observe exactly and identically for every application. A size cap needs a cost function — bytes, entity count, render weight — that only you can define, plus a policy for what is protected, which depends on your screens. The public QueryCache API is deliberately complete enough to build this on top, which is what the hundred lines on this page do.
Should eviction count entries or bytes?
Bytes, approximately. An entry-count cap treats a three-kilobyte user record and a two-megabyte activity feed as equally expensive, so a cap low enough to protect memory in the worst case is far too aggressive for the common case — you end up evicting fifty cheap entries to make room for one expensive one. Serialized JSON length is not heap bytes and does not need to be; it is monotonic in the thing you care about and it ranks entries correctly, which is all a policy needs.
Is it safe to evict an entry that is being refetched?
No. removeQueries on a query with a non-idle fetchStatus cancels the in-flight request and drops the entry, so you have paid for a request whose result is discarded, and any observer sees its data vanish. The check is cheap and belongs in the eligibility filter rather than anywhere later — by the time you are sorting candidates you should already be looking at a set where every member is safe to remove.
Related
- Cache Memory Management — the parent guide on cache sizing and the eviction lifecycle.
- Detecting Cache Memory Leaks in React Query — run this first; eviction masks a leak rather than fixing it.
- Tuning gcTime to Control Cache Memory — the time-based lever this size cap complements.
- Cache Observability & Debugging — the event-stream instrumentation the access tracker is built on.