Tuning gcTime to Control Cache Memory
gcTime is the timer that decides when React Query v5 drops a query it is no longer showing anyone. Get it wrong in one direction and back-navigation refetches everything; get it wrong in the other and the cache never shrinks. Choosing the right value per query is the practical core of Cache Memory Management, and it depends on the same referential-stability guarantees discussed in Structural Sharing and Referential Stability — because a query key that changes identity on every render defeats gcTime before its timer ever starts.
This page walks through how gcTime governs the inactive-query lifecycle, how to pick a value per query, why it must exceed any persistence maxAge, how it interacts with staleTime, and how ever-growing query-key cardinality turns a well-tuned gcTime into a slow leak anyway.
Diagnostic Checklist
You are looking at a gcTime tuning problem if:
- Memory climbs across a session and never drops, even minutes after you leave the pages that populated it.
- A heap snapshot shows retained query entries whose keys differ only by a volatile value.
- Returning to a list refetches from scratch when you expected an instant cached render.
- Your
persistQueryClientrestore comes back missing queries that were on screen shortly before reload. - Setting
gcTime: Infinity“to be safe” correlates with the tab’s memory never plateauing.
Step-by-Step Tuning
Step 1 — Set a finite global default, then override per query
Start from a conservative global gcTime so nothing is retained forever by accident, then lengthen it only for queries a user genuinely returns to. A detail view opened once deserves a short retention; a list root the user pages back to all day deserves a long one.
import { QueryClient, useQuery } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 5 * 60_000, // finite floor: nothing lingers forever by default
staleTime: 30_000,
},
},
});
interface Invoice {
id: string;
total: number;
}
// Rarely revisited: reclaim shortly after the page closes.
export function useInvoice(id: string) {
return useQuery<Invoice, Error>({
queryKey: ['invoice', id],
queryFn: () => fetch(`/api/invoices/${id}`).then((r) => r.json()),
gcTime: 60_000, // 1 minute inactive, then dropped
staleTime: 15_000,
});
}
// Constantly revisited hub: keep it warm across navigation.
export function useInvoiceList(page: number) {
return useQuery<Invoice[], Error>({
queryKey: ['invoices', 'list', page],
queryFn: () => fetch(`/api/invoices?page=${page}`).then((r) => r.json()),
gcTime: 20 * 60_000, // survive 20 minutes of back-and-forth
staleTime: 60_000,
});
}
Cache Behavior Analysis. When useInvoice unmounts, its observer count reaches zero and React Query arms a 60-second timer on that entry. Re-mounting the same invoice within the minute clears the timer and serves the cached Invoice with no fetch; letting it elapse deletes the entry from the QueryCache, after which the engine’s garbage collector frees the object. The list query’s 20-minute gcTime keeps its pages resident through repeated navigation, so paging back is instant — memory spent deliberately, per query, rather than globally.
Step 2 — Keep gcTime above the persistence maxAge
If you persist the cache with persistQueryClient, the persister can only dehydrate queries that still live in the in-memory QueryCache. A gcTime shorter than the persister’s maxAge means a query is collected before the persistence window closes, so it is never written to storage and the post-reload restore quietly drops it. Always keep gcTime at least as large as maxAge.
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { queryClient } from './query-client';
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
const PERSIST_MAX_AGE = 24 * 60 * 60_000; // 24h persisted window
persistQueryClient({
queryClient,
persister,
maxAge: PERSIST_MAX_AGE,
});
// Any query you expect to survive a reload must outlive maxAge in memory:
// its gcTime must be >= PERSIST_MAX_AGE, or it is collected before it can be
// written to storage.
queryClient.setQueryDefaults(['invoices', 'list'], {
gcTime: PERSIST_MAX_AGE, // >= maxAge so it is still resident when dehydrated
staleTime: 60_000,
});
Cache Behavior Analysis. The persister serializes a snapshot of the current QueryCache; a query already garbage-collected is simply absent from that snapshot. With gcTime equal to maxAge, an inactive invoices/list entry stays resident for the full 24-hour window, so it is captured on every dehydration and faithfully restored on reload. Drop gcTime below maxAge and you get the confusing failure mode where persistence “works” for active queries but silently loses recently-inactive ones.
Step 3 — Stabilize query keys to stop cardinality leaks
gcTime can only reclaim an entry that becomes inactive. If your query key embeds an unbounded value — a fresh Date.now(), a random request id, or an inline object whose identity changes every render — each render creates a distinct key, a distinct entry, and often a still-active subscription. The cache grows without any single query ever going inactive, so gcTime never fires. Fix the key, not the timer.
import { useMemo } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
interface SearchArgs {
term: string;
status: 'open' | 'closed';
}
// BROKEN: a new object literal every render → new key → new cache entry.
// export function useSearchBroken(term: string, status: SearchArgs['status']) {
// return useQuery({
// queryKey: ['search', { term, status, at: Date.now() }], // volatile key
// queryFn: () => fetchSearch(term, status),
// });
// }
// FIXED: a stable, low-cardinality key that reuses one entry per logical query.
export function useSearch(term: string, status: SearchArgs['status']) {
const key = useMemo(() => ['search', term, status] as const, [term, status]);
return useQuery({
queryKey: key,
queryFn: () => fetchSearch(term, status),
placeholderData: keepPreviousData,
gcTime: 2 * 60_000,
staleTime: 30_000,
});
}
async function fetchSearch(term: string, status: SearchArgs['status']) {
const res = await fetch(`/api/search?q=${encodeURIComponent(term)}&status=${status}`);
if (!res.ok) throw new Error(`Search failed: ${res.status}`);
return res.json();
}
Cache Behavior Analysis. React Query hashes the query key deterministically to index the QueryCache, so ['search', term, status] collapses every render for the same inputs onto one entry that gcTime can later collect. The broken variant’s Date.now() makes each render a unique key, spawning a fresh entry that is briefly active and then stranded inactive — but with thousands of siblings, the aggregate never shrinks meaningfully. Stabilizing the key bounds cardinality to the real input space, which is the only way gcTime can hold total memory flat. This referential discipline mirrors the guarantees explored in structural sharing and referential stability.
Edge Cases and Gotchas
gcTime: Infinity as an unbounded leak
gcTime: Infinity tells React Query never to collect an inactive query. That is fine for a handful of fixed, deliberately-permanent keys, but combined with per-user, per-filter, or paginated keys it makes the cache grow monotonically for the tab’s entire lifetime — every distinct key that ever went inactive stays forever. If you reach for Infinity, first confirm the key space is bounded; otherwise pick a finite value sized to your session length.
gcTime shorter than maxAge drops persisted data
When persistence is in play, a gcTime below the persister maxAge is a correctness bug, not just a memory choice: the query is evicted from the in-memory cache before it can be dehydrated, so it is missing from the restored snapshot. Users see a “cold” reload for data they expected to be persisted. Treat gcTime >= maxAge as an invariant wherever persistQueryClient is configured.
Per-query overrides beat one global value
A single global gcTime forces a compromise: high enough to keep list roots warm means retaining every transient detail view too, and low enough to reclaim details quickly evicts the roots. Use setQueryDefaults or per-hook gcTime so heavy or rarely-revisited results are collected fast while navigational hubs stay resident. When you also revalidate aggressively — see background refetch strategies — remember that refetching repopulates entries, so retention and refresh must be tuned together rather than in isolation.
Common Pitfalls and Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
Cache never shrinks despite a set gcTime |
High-cardinality keys (volatile values or unmemoized object args) mint a new entry per render, so no single query goes inactive | Stabilize the key with useMemo and remove volatile fields; confirm one entry per logical query in the DevTools |
| Persisted queries missing after reload | gcTime shorter than persister maxAge, so entries are collected before dehydration |
Set gcTime >= maxAge (via setQueryDefaults) for every query the persister should restore |
| Tab memory grows for the whole session | gcTime: Infinity applied over an unbounded key space |
Replace with a finite gcTime sized to session length, or bound the key space first |
| Back-navigation refetches instead of using cache | gcTime shorter than the user’s typical away time, so the entry was already collected |
Raise gcTime on hub queries above realistic away-duration; keep staleTime separate for the refetch decision |
Frequently Asked Questions
Should gcTime always be larger than staleTime?
Usually yes, but they measure different things so there is no hard rule. staleTime applies while a query is active and decides whether it refetches; gcTime applies only after it goes inactive and decides when it is dropped. A gcTime smaller than staleTime means an inactive query is collected while its data was still considered fresh — often wasteful, because a returning user must refetch data you had just discarded. Keep gcTime at least as long as the window in which a user might realistically navigate back.
Why must gcTime exceed the persister maxAge when using persistQueryClient?
The persister can only write queries that still exist in the in-memory QueryCache. If gcTime is shorter than maxAge, an inactive query is garbage collected before the persistence window closes, so it is never dehydrated to storage and the on-reload restore silently misses it. Set gcTime greater than or equal to maxAge so every query the persister is allowed to restore is still resident when the snapshot is written.
Does gcTime: Infinity cause a memory leak?
It can. gcTime: Infinity means an inactive query is never collected, so combined with high-cardinality query keys — per-user, per-filter, or per-timestamp keys — the cache grows monotonically for the life of the tab. Infinity is safe only for a small, fixed set of keys you deliberately want to keep forever. For anything keyed on unbounded input, use a finite gcTime.
Related
- Cache Memory Management — the parent reference covering the active/inactive lifecycle,
maxPagesfor infinite queries, Apollocache.evict/cache.gc, and heap profiling that thisgcTimedeep-dive sits inside. - Structural Sharing and Referential Stability — why stable references matter for both re-render avoidance and the query-key discipline that lets
gcTimeactually reclaim memory. - State Architecture & Cache Fundamentals — the foundational section on client/server state boundaries and normalization that frames every retention decision here.
- Background Refetch Strategies — how revalidation timing repopulates entries you retain, so refresh and retention must be tuned as a pair rather than separately.