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.
What LRU eviction may and may not touch Cache entries laid out from most recently used on the left to least recently used on the right. Entries with mounted observers and entries currently fetching are marked protected and skipped. Eviction removes the coldest unprotected entries from the right until the total is under budget. Coldest first, but only among entries nothing is using most recently used least recently used board/42 2 observers protected users/list fetching protected board/41 idle · 180 KB eligible board/39 idle · 164 KB eligible board/17 idle · 210 KB evicted activity/feed idle · 512 KB evicted first budget 1.5 MB · resident before 2.3 MB · after 1.58 MB Two evictions were enough because eviction is ordered by size within the cold set. Evicting a protected entry is a visible bug A mounted component whose entry disappears flips to a loading state with no user action behind it.
Recency decides the candidate set; size decides the order within it. Protection is absolute and checked first.

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.

Resident cache after a 40-screen session, by policy Bar chart of resident cache size. Default gcTime of five minutes leaves 8.4 megabytes. A one-minute gcTime leaves 3.1 but costs refetches. An LRU cap at 1.5 megabytes leaves 1.6. LRU plus a five-minute gcTime leaves 1.5. Resident cache after a 40-screen session, by policy Same navigation trace replayed against four configurations gcTime 5min (default) 8.4 MB gcTime 1min 3.1 MB LRU cap 1.5 MB 1.6 MB LRU cap + gcTime 5min 1.5 MB
Shortening gcTime lowers memory by throwing away everything, including entries the user is about to revisit. A size cap throws away the coldest only.

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.

What each policy protects, and what it gives up Matrix comparing gcTime alone, an entry-count cap, a byte-budget LRU and LRU with pinned prefixes across what each bounds and its main failure mode. What each policy protects, and what it gives up Bounds Main failure mode gcTime alone How long an unused entry survives A large working set stays entirely resident Entry-count cap The number of entries Fifty small entries evicted to make room for one large one Byte-budget LRU Actual memory, approximately Serializing the cache on every pass costs CPU Byte-budget LRU + pinned prefixes Memory, minus cheap always-needed data The pinned list drifts and stops being reviewed
Only a byte budget bounds memory directly. The others bound a proxy for it, which is why they need such conservative values.

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.