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 persistQueryClient restore 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.

The inactive-query gcTime lifecycle A timeline shows a query active while mounted, becoming inactive when the last observer unmounts, a gcTime countdown, and two outcomes: remount before the timer elapses reuses the cached entry, or the timer elapses and the entry is garbage collected and freed from memory. time → Active (mounted) observers > 0 · never GC'd last observer unmounts Inactive · gcTime timer observers = 0 · counting down Remount before gcTime elapses timer cancelled · cached data reused gcTime elapses → garbage collected entry dropped · memory freed
The gcTime timer only starts once a query has zero observers; remounting before it fires cancels it and reuses the cached entry, while letting it elapse frees the memory.

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.


  • Cache Memory Management — the parent reference covering the active/inactive lifecycle, maxPages for infinite queries, Apollo cache.evict/cache.gc, and heap profiling that this gcTime deep-dive sits inside.
  • Structural Sharing and Referential Stability — why stable references matter for both re-render avoidance and the query-key discipline that lets gcTime actually 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.