Cache Memory Management
A client cache trades memory for latency: every query result you retain is heap that is not returned to the browser until something decides to drop it. On a long-lived single-page app, that decision is the difference between an instant back-navigation and a tab that consumes 900 MB and gets killed on a mid-range phone. Managing the footprint is a first-class part of State Architecture & Cache Fundamentals — it sits directly alongside the storage-topology choices covered in Cache Layer Architecture, because how you lay out the cache determines what you can cheaply evict from it.
This guide covers the concrete mechanisms: how React Query v5’s gcTime garbage-collects inactive queries through the active/inactive lifecycle, why dynamic query keys cause unbounded growth, how maxPages bounds infinite scroll, how Apollo’s cache.evict and cache.gc reclaim orphaned entities, and how to prove any of it with a heap snapshot instead of guessing.
Diagnostic Checklist
You are likely facing a cache memory management problem if you observe:
- Tab memory in
chrome://memoryor the Task Manager climbing steadily over a session and never falling after navigation. - A heap snapshot showing thousands of retained query entries with distinct keys that differ only by a timestamp, request id, or object identity.
useInfiniteQuerysessions where memory grows linearly with scroll depth and never plateaus, even after the user scrolls far past earlier pages.- Apollo
InMemoryCacheextract()output containing normalized objects for records the user deleted or navigated away from minutes ago. - Back-navigation that refetches from scratch instead of showing cached data, indicating
gcTimeis firing sooner than intended. - Detached DOM subtrees or closures retained in a snapshot because a component holds a reference into cached data after unmount.
Prerequisites
Before applying the patterns below, you should be comfortable with:
- The QueryCache model: how React Query v5 stores one entry per unique serialized query key, and what “observer” means in that model.
- Stable query keys: how unmemoized objects and dynamic values inflate key cardinality (foundational to Cache Layer Architecture).
staleTimevsgcTime: that one controls freshness while the other controls retention — they are not interchangeable.- Apollo normalization: how
InMemoryCachestores entities under__typename:idkeys and resolves references at read time.
Implementation 1 — Tuning gcTime Across the Query Lifecycle
gcTime (default five minutes in React Query v5) is the single most important memory lever, but it only acts on inactive queries. A query is active while at least one mounted component observes it; the moment its observer count drops to zero it becomes inactive and the gcTime countdown starts. If nothing re-subscribes before the timer elapses, the entry is deleted from the QueryCache and becomes eligible for the JavaScript garbage collector.
Steps:
- Establish a conservative global
gcTimedefault on theQueryClientso no query lingers indefinitely. - Override
gcTimeper query based on how likely a user is to return — short for one-shot detail views, longer for list roots they navigate back to. - Keep
staleTimea separate decision: it decides whether a returning active query refetches, not whether an inactive one is retained. - Verify with the DevTools that inactive queries disappear from the cache after the expected interval.
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import type { ReactNode } from 'react';
// Global defaults: nothing survives longer than 5 minutes inactive
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 5 * 60_000,
staleTime: 30_000,
},
},
});
export function AppProviders({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
interface User {
id: string;
name: string;
email: string;
}
// A detail view the user rarely revisits: drop it quickly after unmount.
export function useUserDetail(userId: string) {
return useQuery<User, Error>({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
gcTime: 30_000, // reclaim 30s after the detail page closes
staleTime: 10_000,
});
}
// A list root the user pages back to constantly: keep it warm longer.
export function useUserList(page: number) {
return useQuery<User[], Error>({
queryKey: ['users', 'list', page],
queryFn: () => fetch(`/api/users?page=${page}`).then((r) => r.json()),
gcTime: 15 * 60_000, // survive back-navigation for 15 minutes
staleTime: 60_000,
});
}
Cache Behavior Impact. When useUserDetail unmounts, its observer count hits zero and React Query starts a 30-second timer stored on the query entry. If the user reopens the same detail within that window, useQuery re-subscribes, the timer is cleared, and the cached User renders instantly with no network request. If the window elapses first, the entry is spliced out of the QueryCache map and the User object becomes unreachable, so the next engine GC frees it. Because useUserList uses a 15-minute gcTime, its pages stay resident across repeated back-navigation, trading a few kilobytes of heap for zero-latency returns.
Configuration Trade-offs:
- A longer
gcTimeimproves back-navigation latency but raises steady-state heap; size it against the largest realistic set of simultaneously-inactive queries, not a single one. staleTimeandgcTimeare orthogonal: raisingstaleTimesuppresses refetches but never extends retention, and raisinggcTimenever suppresses a refetch — set each for its own job.- Setting
gcTime: 0frees an inactive query almost immediately but destroys the cache-hit benefit of back-navigation; reserve it for genuinely one-shot, memory-heavy results. - Per-query overrides beat a single global value: a blanket high
gcTimeretains every transient detail view, while a blanket low one evicts list roots the user relies on.
Implementation 2 — Bounding Infinite Queries with maxPages
Infinite scroll is the most common source of unbounded growth: without a cap, useInfiniteQuery retains every page ever fetched inside data.pages, so a user who scrolls a thousand items keeps a thousand items’ worth of entities in memory even after scrolling far past them. React Query v5’s maxPages bounds this by trimming pages from the far end as new ones load, keeping the retained window fixed regardless of scroll depth.
Steps:
- Set
maxPagesto the number of pages that plausibly need to be rendered at once (accounting for your virtualization overscan). - Provide both
getNextPageParamandgetPreviousPageParamso React Query can trim from either end and re-fetch a dropped page when the user scrolls back. - Pair the bounded query with a virtualized list so the DOM footprint is bounded too, not just the cache.
- Keep
gcTimemoderate —maxPagesbounds a single active query’s size, whilegcTimereclaims the whole thing once scrolling stops and the component unmounts.
import { useInfiniteQuery } from '@tanstack/react-query';
interface FeedItem {
id: string;
title: string;
}
interface FeedPage {
items: FeedItem[];
nextCursor: string | null;
prevCursor: string | null;
}
async function fetchFeed(cursor: string | null): Promise<FeedPage> {
const params = new URLSearchParams({ limit: '20' });
if (cursor) params.set('cursor', cursor);
const res = await fetch(`/api/feed?${params}`);
if (!res.ok) throw new Error(`Feed fetch failed: ${res.status}`);
return res.json();
}
export function useBoundedFeed() {
return useInfiniteQuery<FeedPage, Error>({
queryKey: ['feed', 'bounded'],
queryFn: ({ pageParam }) => fetchFeed(pageParam as string | null),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined,
maxPages: 5, // retain at most 5 pages (~100 items) regardless of scroll depth
gcTime: 10 * 60_000,
staleTime: 30_000,
});
}
Cache Behavior Impact. With maxPages: 5, once data.pages holds five entries, calling fetchNextPage appends the new page and drops pages[0]; calling fetchPreviousPage prepends and drops the last page. React Query stores only the surviving five pages, so heap for this query is constant no matter how far the user scrolls. The dropped page’s cursor is still derivable from the retained boundary page, so scrolling back re-fetches it on demand rather than reading stale data. Without getPreviousPageParam, backward trimming cannot reconstruct the dropped param and the window effectively becomes forward-only.
Configuration Trade-offs:
maxPagesbounds one active infinite query; it does nothing for the dozens of ordinary queries whose retention is still governed bygcTime.- A small
maxPagesminimizes heap but forces re-fetches when the user scrolls back into trimmed territory — weigh network cost against memory. - Omitting
getPreviousPageParamwhile settingmaxPagessilently breaks bidirectional trimming; both param resolvers are required for the window to slide correctly. maxPagescaps page count, not page weight — if each page embeds large payloads, also normalize entities out of the page array so trimming a page actually releases the bytes.
Implementation 3 — Reclaiming Orphaned Entities with cache.evict and cache.gc
Apollo Client’s InMemoryCache does not expire entries on a timer the way React Query does; a normalized object lives until something removes it. After deletions, logouts, or long editing sessions, the cache accumulates entities nothing references anymore. cache.evict removes a named object or field, and cache.gc sweeps every object no longer reachable from a root query — together they are Apollo’s manual equivalent of garbage collection.
Steps:
- After a delete mutation, call
cache.evictwith the object’s normalized id to remove it from the store. - Follow with
cache.gcto delete any now-unreachable objects the evicted entity was the last referrer to. - On logout or tenant switch, reset broadly so no previous-session entities linger in heap.
- Read
cache.gc’s return value — the list of collected ids — to confirm in tests that the sweep actually reclaimed what you expected.
import { InMemoryCache, useMutation, gql, useApolloClient } from '@apollo/client';
export const cache = new InMemoryCache();
const DELETE_PROJECT = gql`
mutation DeleteProject($id: ID!) {
deleteProject(id: $id) {
id
}
}
`;
export function useDeleteProject() {
const client = useApolloClient();
return useMutation(DELETE_PROJECT, {
update(cacheRef, { data }) {
const deletedId = data?.deleteProject?.id;
if (!deletedId) return;
// 1. Remove the normalized Project object from the store.
cacheRef.evict({ id: cacheRef.identify({ __typename: 'Project', id: deletedId }) });
// 2. Sweep any objects only referenced by the evicted Project.
const collected = cacheRef.gc();
if (process.env.NODE_ENV !== 'production') {
console.debug('cache.gc reclaimed', collected.length, 'objects');
}
},
onError() {
// Reads are unaffected; the failed mutation left the cache intact.
client.cache.gc();
},
});
}
Cache Behavior Impact. cache.evict deletes the Project:123 entry but leaves any dangling Reference to it inside other objects’ fields; Apollo filters those dangling references out at read time so queries do not error. cache.gc then starts from the cache’s root ids, marks everything transitively reachable, and deletes the unmarked remainder — reclaiming, for example, a Comment entity that only the deleted Project pointed to. Calling evict without gc leaves those orphans resident, which is exactly the pattern a heap snapshot exposes as slow Apollo memory creep.
Configuration Trade-offs:
cache.evictalone marks but does not sweep; onlycache.gcfrees the heap, so pair them whenever you delete.cache.gcis O(reachable objects) — cheap after a single delete, but avoid calling it in a tight loop; batch deletions and sweep once.- Prefer targeted
evict+gcovercache.reset()when you only need to drop a subtree —resetnukes every active query and forces a full refetch storm. - Unlike React Query’s
gcTime, Apollo has no time-based reclamation, so orphaned entities persist until you evict them or reset — memory hygiene here is entirely manual.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Heap grows every render and never settles | Query key embeds an unmemoized object or a fresh value (Date.now(), random id), minting a new active cache entry per render |
Stabilize the key: memoize object args and remove volatile values so one logical query maps to one entry that gcTime can later reclaim |
| Inactive queries never leave the cache | gcTime: Infinity set globally, or a very high default, so nothing is ever collected |
Set a finite global gcTime and override per query; confirm entries disappear in the DevTools after unmount |
| Back-navigation refetches instead of showing cached data | gcTime shorter than the time the user spends away, so the entry was collected before return |
Raise gcTime on list-root queries above the realistic away-duration; keep staleTime separate for the refetch decision |
| Infinite scroll memory grows without bound | useInfiniteQuery retains every page because maxPages is unset |
Set maxPages and supply both getNextPageParam and getPreviousPageParam so old pages are trimmed |
| Apollo memory creeps up after deletions | cache.evict called (or entities left dangling) without a following cache.gc sweep |
Call cache.gc() after evict; assert on its returned id list in tests to prove reclamation |
Frequently Asked Questions
Does a large staleTime keep queries in memory longer than gcTime would?
No. staleTime governs freshness — whether React Query serves cached data without a background refetch — while gcTime governs retention of inactive queries. A query with a one-hour staleTime is still garbage collected gcTime milliseconds after its last observer unmounts. The two timers are independent: staleTime only matters while a query has active observers, and gcTime only starts counting once observer count reaches zero. If you want data to survive unmount, raise gcTime, not staleTime.
Why does my React Query cache keep growing even though gcTime is set?
gcTime only reclaims queries that become inactive. If your query keys embed high-cardinality values — a fresh timestamp, a random request id, or an unmemoized filter object whose identity changes every render — every render mints a new key with its own cache entry, and old entries stay active as long as any component still subscribes to them. The fix is to stabilize query keys so the same logical query reuses one cache entry, at which point gcTime fires normally when it unmounts.
How does maxPages bound the memory of an infinite query?
In React Query v5, setting maxPages on useInfiniteQuery caps how many pages are retained in data.pages. Once the limit is reached, fetching a new page in one direction drops the page at the opposite end, so a 500-page scroll session holds only maxPages worth of entities in memory. You must supply getPreviousPageParam alongside getNextPageParam for the bidirectional trimming to reconstruct dropped pages when the user scrolls back.
What is the difference between cache.evict and cache.gc in Apollo Client?
cache.evict removes a specific normalized object or field from the InMemoryCache, but it leaves dangling references behind that Apollo filters out at read time. cache.gc then walks the cache from its root ids and deletes any normalized object that is no longer reachable, reclaiming the heap. Think of it as evict marks and gc sweeps. Calling evict without a subsequent gc leaves orphaned entities occupying memory until the next collection pass.
Related
- State Architecture & Cache Fundamentals — the parent section covering client/server state boundaries, normalization, and the topology decisions that determine what your cache can cheaply evict.
- Cache Layer Architecture — how you structure entity storage and query keys, which directly governs the cardinality and retention behavior this page tunes.
- Tuning gcTime to Control Cache Memory — the deep dive into choosing
gcTimeper query, its interaction with persistencemaxAge, and preventing leaks from query-key cardinality. - Structural Sharing and Referential Stability — how React Query reuses object references so retained cache data does not trigger needless re-renders while it sits in memory.
- Background Refetch Strategies — how refetch timing interacts with retention, since aggressive revalidation can repopulate entries you just tried to keep small.