| | |

TanStack Query 05 ⚛️ Stale & Cache — Configuring staleTime and gcTime

⏱️ Estimated read: 14 min | |

TanStack Query’s cache is governed by two independent timers that answer two different questions. staleTime asks: “How long is this data considered fresh?” gcTime asks: “How long does this data survive in memory after nothing is using it?” Confusing the two produces the most common category of TanStack Query bugs: data that refetches too aggressively, data that never refetches, or cache entries that disappear while a component still expects them. The defaults are deliberately aggressive—staleTime is 0, meaning every piece of cached data is stale the moment it arrives—and they surprise developers who expect a cache to behave like a persistent store rather than a freshness coordinator .

This chapter explains the lifecycle that connects the two timers, the default values and why they were chosen, the global and per-query configuration points, and the rules that keep gcTime from undercutting staleTime. It builds on the query state handling from the previous chapters: isFetching fires when stale data triggers a background refetch, and isPending fires only when no data exists at all.

Key point: staleTime controls refetch behavior; gcTime controls memory retention. Data can be fresh and inactive, stale and active, or any combination of the two. The only hard constraint is that gcTime should be greater than or equal to staleTime—otherwise a cache entry can be garbage collected while it is still fresh, producing an unnecessary loading state on the next mount .


Why the two timers are separate

The freshness problem.

A cache that never refetches becomes a liability: users see yesterday’s prices, yesterday’s messages, yesterday’s status. A cache that refetches on every render is not a cache at all. staleTime is the dial between these extremes. While data is within its staleTime window, useQuery returns the cached value and does not trigger a network request. After staleTime elapses, the data becomes stale. Stale data is still returned immediately—the component renders without a loading spinner—but TanStack Query schedules a background refetch to verify the data is current . This is the stale-while-revalidate pattern: show what you have, check for updates behind the scenes.

The memory problem.

Every distinct query key occupies memory. A user who navigates through fifty todo detail pages creates fifty cache entries unless something removes them. gcTime is the garbage collector’s timer. It starts counting only when a query has no active observers—no mounted component calling useQuery with that key. If the user navigates back to a detail page before gcTime expires, the cached data is available immediately and the component does not enter a pending state . If gcTime expires first, the entry is removed, and the next mount starts from scratch.

The default trap.

The default staleTime of 0 means data is stale immediately upon arrival . This is intentional: TanStack Query cannot know how volatile your data is, so it assumes the worst. The consequence is that every mount, every window focus, and every network reconnect triggers a background refetch by default. Applications with static reference data—country lists, configuration settings, feature flags—refetch this data constantly unless staleTime is raised. The first configuration change most applications should make is raising staleTime above zero for data that does not change every second .


a. The query lifecycle and the two timers

A query passes through a sequence of states, and each timer governs a different segment of that sequence.

// Lifecycle of a query with staleTime: 5 minutes, gcTime: 10 minutes
const { data, isFetching, isPending } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 1000 * 60 * 5,   // Data is fresh for 5 minutes
  gcTime: 1000 * 60 * 10,     // Inactive data is kept for 10 minutes
})

// T=0: Component mounts
//   No cached data → isPending: true → fetch runs
// T=1s: Fetch resolves
//   Data is cached → isPending: false → data is fresh
// T=0–5min: Component remounts or window refocuses
//   Data is fresh → no refetch → isFetching: false
// T=5min: staleTime elapses
//   Data becomes stale
//   Next mount/focus → cached data renders immediately, background refetch runs
// T=5min–15min: Component unmounts at T=5min
//   Query has no observers → inactive → gcTime timer starts
// T=15min: gcTime elapses
//   Cache entry is removed → next mount starts with isPending: true

The critical distinction is between fresh and active. A query can be fresh but inactive: data was fetched recently, the component unmounted, and no refetch has occurred. A query can also be stale but active: the component is mounted, the data is old, and a background refetch is running while the old data remains visible. staleTime governs freshness; gcTime governs retention. They operate on different axes .


b. Configuring staleTime globally and per query

The QueryClient accepts defaultOptions.queries for application-wide defaults, and individual useQuery calls can override them.

// Global default: data is fresh for 1 minute
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60,      // 1 minute default for all queries
      gcTime: 1000 * 60 * 5,     // 5 minutes (the default)
    },
  },
})

// Per-query override: static reference data
function useCountries() {
  return useQuery({
    queryKey: ['countries'],
    queryFn: fetchCountries,
    staleTime: Infinity,         // Never becomes stale; never refetches
    gcTime: Infinity,            // Never garbage collected
  })
}

// Per-query override: real-time data
function useLivePrice(symbol: string) {
  return useQuery({
    queryKey: ['price', symbol],
    queryFn: () => fetchPrice(symbol),
    staleTime: 5_000,            // Fresh for 5 seconds only
    refetchInterval: 5_000,      // Poll every 5 seconds
  })
}

The choice of staleTime depends on data volatility. Static reference data—country lists, timezone tables, feature flags—can use Infinity. Moderately changing data—user profiles, todo lists—benefits from a staleTime in the range of thirty seconds to five minutes. Real-time data—stock prices, chat messages, live dashboards—needs a short staleTime, often paired with refetchInterval for polling . Setting staleTime correctly reduces network traffic without sacrificing freshness, because the stale-while-revalidate behavior ensures users see cached data instantly and receive updates when they arrive.

staleTime also accepts a function that receives the query and returns a number. This allows dynamic freshness: an admin user’s data might have a shorter staleTime than a regular user’s data, or a query might be fresh for longer after a successful write .


c. Understanding gcTime and its constraints

gcTime (renamed from cacheTime in v5) controls how long an inactive query remains in memory . The timer starts when the last observer unmounts. If a component remounts before the timer expires, the cached data is returned and the timer is cancelled.

// Keep inactive data for 30 minutes instead of the default 5
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 30,    // 30 minutes
    },
  },
})

// A query that should never be garbage collected (use sparingly)
useQuery({
  queryKey: ['user-preferences'],
  queryFn: fetchPreferences,
  gcTime: Infinity,              // Kept for the lifetime of the application
})

The constraint that gcTime >= staleTime exists because a cache entry removed before its staleTime expires would have been fresh but is now gone. The next mount would show a loading state for data that should have been served instantly from cache . TanStack Query’s default gcTime of five minutes is greater than the default staleTime of zero, so the defaults are consistent. When raising staleTime above five minutes, gcTime must be raised as well, or the cache will discard fresh data.

gcTime should also account for navigation patterns. An application where users frequently move between a list view and a detail view benefits from a longer gcTime on the detail queries, so returning to a previously viewed detail does not trigger a loading state. An application with thousands of distinct query keys should use a shorter gcTime to prevent unbounded memory growth.


Complete Example Session

// ============================================
// PART 1: THE QUERY CLIENT WITH TUNABLE DEFAULTS
// ============================================
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Default: data is fresh for 30 seconds
      // This is a compromise for moderately changing data
      staleTime: 30_000,
      // Keep inactive data for 5 minutes (default)
      gcTime: 300_000,
      // Do not refetch on window focus for this application
      refetchOnWindowFocus: false,
    },
  },
})

// ============================================
// PART 2: STATIC REFERENCE DATA
// ============================================
// Country list: fetched once, never changes during a session
function useCountries() {
  return useQuery({
    queryKey: ['countries'],
    queryFn: fetchCountries,
    staleTime: Infinity,      // Never stale
    gcTime: Infinity,         // Never garbage collected
  })
}

// ============================================
// PART 3: MODERATELY CHANGING DATA
// ============================================
// User profile: changes occasionally, but not every second
function useUserProfile(userId: number) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 1000 * 60 * 5,   // Fresh for 5 minutes
    gcTime: 1000 * 60 * 10,     // Kept 10 minutes after unmount
  })
}

// ============================================
// PART 4: REAL-TIME DATA
// ============================================
// Live order status: changes frequently, needs polling
function useOrderStatus(orderId: string) {
  return useQuery({
    queryKey: ['order', orderId, 'status'],
    queryFn: () => fetchOrderStatus(orderId),
    staleTime: 5_000,              // Fresh for 5 seconds
    gcTime: 60_000,                // Kept 1 minute after unmount
    refetchInterval: 5_000,        // Poll every 5 seconds
  })
}

// ============================================
// PART 5: THE STALE-WHILE-REVALIDATE PATTERN
// ============================================
// When stale data exists, it renders immediately
// A background refetch runs, and isFetching is true
function TodoList() {
  const { data, isPending, isFetching } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    staleTime: 30_000,            // Stale after 30 seconds
  })

  // First load: no data → pending
  if (isPending) return <LoadingSkeleton />

  // Subsequent loads: cached data renders immediately
  // If stale, a background refetch runs (isFetching true)
  return (
    <div>
      {isFetching && <span>Checking for updates…</span>}
      <ul>{data.map(todo => <li key={todo.id}>{todo.title}</li>)}</ul>
    </div>
  )
}

// ============================================
// PART 6: VERIFYING CACHE BEHAVIOR WITH DEVTOOLS
// ============================================
// The DevTools panel shows each query's state:
// - fresh (green) vs stale (yellow)
// - active (mounted) vs inactive (unmounted)
// - the time remaining until gcTime expires
// Use it to confirm that staleTime and gcTime are working as intended

The six parts demonstrate the full configuration surface: global defaults, static data with infinite timers, moderate data with balanced timers, real-time data with short timers, the stale-while-revalidate render pattern, and DevTools verification. The critical insight is that the same hook—useQuery—produces different behavior depending entirely on these two numeric options.


Quick Reference

Default Values

OptionDefaultEffect
staleTime0Data is stale immediately; refetches on mount/focus/reconnect
gcTime5 minutesInactive cache entries removed after 5 minutes
refetchOnWindowFocustrueStale queries refetch when tab regains focus
refetchOnReconnecttrueStale queries refetch when network reconnects

staleTime Guidelines by Data Volatility

Data TypeRecommended staleTimeExample
Static referenceInfinityCountry list, timezones
Rarely changing1 hourFeature flags, app config
Moderately changing1–5 minutesUser profiles, todo lists
Frequently changing0–30 secondsNotifications, activity feeds
Real-time0–5 secondsStock prices, chat, live status

Timer Relationship Rules

RuleReason
gcTime >= staleTimePrevent garbage collection of fresh data
staleTime: Infinity requires gcTime: InfinityFresh data must not be collected
staleTime does not affect active queriesActive queries stay in memory regardless
gcTime starts only when observers unmountMounted components keep data alive

Best Practices

✅ Do This:

// Set a global staleTime above 0 for typical applications
staleTime: 30_000                                              // ✅

// Use Infinity for truly static data
staleTime: Infinity, gcTime: Infinity                          // ✅

// Keep gcTime >= staleTime
staleTime: 300_000, gcTime: 600_000                            // ✅

// Use short staleTime with refetchInterval for polling
staleTime: 5_000, refetchInterval: 5_000                       // ✅

// Check isFetching for background refresh indicators
{isFetching && <span>Updating…</span>}                         // ✅

// Verify behavior in DevTools
// Green = fresh, yellow = stale, grey = inactive               // ✅

❌ Don’t Do This:

// Leave staleTime at 0 for static data
staleTime: 0 // Refetches on every mount and focus              // ❌

// Set gcTime below staleTime
staleTime: 300_000, gcTime: 60_000 // Fresh data collected too early // ❌

// Use Infinity for volatile data
staleTime: Infinity // Data never refreshes                     // ❌

// Use cacheTime (v4 name) in v5
cacheTime: 300_000 // Not recognized in v5                      // ❌

// Assume staleTime prevents all refetches
// Manual invalidateQueries still triggers refetch              // ❌

// Set staleTime without considering refetchOnWindowFocus
// Focus refetch still runs if stale                             // ❌

Common Pitfalls

PitfallWhy It HappensFix
Data refetches constantlyDefault staleTime: 0Raise staleTime for non-volatile data
Loading state appears unexpectedlygcTime expired, cache entry removedRaise gcTime to match navigation patterns
Data never refreshesstaleTime: Infinity used incorrectlyUse finite staleTime for changing data
cacheTime not recognizedv4 option name used in v5Rename to gcTime
Fresh data garbage collectedgcTime < staleTimeEnsure gcTime >= staleTime
Background refetch not visibleisFetching not checkedRender indicator when isFetching is true
Cache grows unboundedgcTime: Infinity on many queriesUse finite gcTime for user-specific data

Real-World Examples

1. Static country list

useQuery({ queryKey: ['countries'], queryFn: fetchCountries, staleTime: Infinity, gcTime: Infinity })

2. User profile with 5-minute freshness

useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id), staleTime: 300_000 })

3. Live polling with short timers

useQuery({ queryKey: ['live'], queryFn: fetchLive, staleTime: 5_000, refetchInterval: 5_000 })

4. Global default of 1 minute

const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 } } })

5. Long gcTime for detail views

useQuery({ queryKey: ['detail', id], queryFn: fetchDetail, gcTime: 1_800_000 })

6. Verify fresh state in DevTools

// DevTools: query shows green dot when within staleTime

7. Stale indicator during background refetch

{isFetching && !isPending && <RefreshIcon />}

8. Disable focus refetch globally

const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false } } })

9. Dynamic staleTime based on user role

staleTime: (query) => query.state.data?.role === 'admin' ? 60_000 : 300_000

10. Confirm gcTime cleanup

// Unmount component, wait gcTime, remount → isPending should be true

Visual

The Two Timers on a Timeline

┌──────────────────────────────────────────────────────────────┐
│  staleTime AND gcTime ON A SINGLE QUERY                      │
│                                                              │
│  staleTime: 5 min    gcTime: 10 min                          │
│                                                              │
│  T=0        Fetch resolves, data cached                      │
│  │                                                           │
│  ├── FRESH ──────────────┤                                   │
│  │  (no refetch)         │                                   │
│  T=5min                  │  staleTime elapses                │
│  │                       ▼                                   │
│  │              Data is stale                                │
│  │              Cached data still renders                    │
│  │              Next mount → background refetch              │
│  │                                                           │
│  T=8min  Component unmounts                                  │
│  │       No observers → inactive                             │
│  │       gcTime timer starts                                 │
│  │                                                           │
│  ├── INACTIVE (gcTime) ──────────────────┤                   │
│  │                                       │                   │
│  T=18min                                 ▼                   │
│  │                              gcTime elapses                │
│  │                              Cache entry removed           │
│  │                              Next mount → isPending: true  │
│  │                                                           │
│  Rule: gcTime counts from unmount, not from fetch.           │
│  If component remounts before T=18min, timer cancels.        │
└──────────────────────────────────────────────────────────────┘

staleTime vs gcTime Decision Matrix

┌──────────────────────────────────────────────────────────────┐
│  WHICH TIMER CONTROLS WHAT?                                  │
│                                                              │
│  Question: Should this query refetch on mount?               │
│       → staleTime controls this.                             │
│       → Fresh (within staleTime): no refetch.                │
│       → Stale (past staleTime): background refetch.          │
│                                                              │
│  Question: Should this query show a loading spinner on       │
│  remount after the user navigated away?                      │
│       → gcTime controls this.                                │
│       → Within gcTime: cached data returns instantly.        │
│       → Past gcTime: cache entry gone, isPending: true.      │
│                                                              │
│  Question: Does a background refetch show a spinner?         │
│       → No. isFetching is true, but data is already present. │
│       → Only isPending (no data at all) shows a spinner.     │
│                                                              │
│  ┌────────────────────┬───────────────────────────────────┐  │
│  │ staleTime          │ Refetch behavior                  │  │
│  ├────────────────────┼───────────────────────────────────┤  │
│  │ 0 (default)        │ Refetch on every mount/focus      │  │
│  │ 30s–5min           │ Refetch when data ages out        │  │
│  │ Infinity           │ Never refetch automatically       │  │
│  └────────────────────┴───────────────────────────────────┘  │
│  ┌────────────────────┬───────────────────────────────────┐  │
│  │ gcTime             │ Memory behavior                   │  │
│  ├────────────────────┼───────────────────────────────────┤  │
│  │ 5min (default)     │ Inactive data removed after 5min  │  │
│  │ 30min+             │ Data survives longer navigation   │  │
│  │ Infinity           │ Never removed (memory risk)       │  │
│  └────────────────────┴───────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

The Constraint: gcTime >= staleTime

┌──────────────────────────────────────────────────────────────┐
│  WHY gcTime MUST NOT BE SMALLER THAN staleTime               │
│                                                              │
│  ✅ CORRECT: staleTime: 5 min, gcTime: 10 min                │
│                                                              │
│  T=0       Fetch, data fresh                                 │
│  T=5min    Data stale, cached data renders + refetch         │
│  T=8min    Component unmounts, gcTime timer starts           │
│  T=18min   gcTime expires, entry removed                     │
│            (Data was stale for 13 minutes before removal)    │
│                                                              │
│  ❌ INCORRECT: staleTime: 10 min, gcTime: 2 min              │
│                                                              │
│  T=0       Fetch, data fresh                                 │
│  T=2min    Component unmounts, gcTime timer starts           │
│  T=4min    gcTime expires, entry removed                     │
│            ⚠ Data was still FRESH (staleTime 10 min)         │
│            ⚠ Next mount → isPending: true                    │
│            ⚠ Unnecessary loading state for fresh data        │
│                                                              │
│  Rule: gcTime is the outer bound; staleTime is the inner.    │
│  The cache must outlive the freshness window.                │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
staleTimeDuration data is considered fresh; no refetch within window
gcTimeDuration inactive data remains in memory before removal
Default staleTime0 (immediately stale)
Default gcTime5 minutes
Fresh data behaviorCached value returned; no network request
Stale data behaviorCached value returned immediately; background refetch runs
Inactive queryNo mounted observers; gcTime timer starts
ConstraintgcTime >= staleTime
v5 renamecacheTime → gcTime
Static datastaleTime: Infinity, gcTime: Infinity
Real-time dataShort staleTime, optional refetchInterval

Key takeaways:

  • staleTime and gcTime answer different questions. One controls refetch frequency, the other controls memory retention. Configuring one without the other produces either excessive network traffic or unexpected loading states .
  • The default staleTime of 0 is aggressive by design. TanStack Query assumes data is volatile until told otherwise. Raising staleTime is the first optimization for any application with semi-static data .
  • Stale data still renders instantly. The stale-while-revalidate pattern means users see cached content immediately, with a background refetch updating it silently. Only isPending (no data at all) produces a loading skeleton.
  • gcTime starts only when the last observer unmounts. A mounted component keeps its query active and immune to garbage collection, regardless of how long the data has been stale.
  • gcTime must be greater than or equal to staleTime. Otherwise fresh data can be garbage collected, forcing an unnecessary network request on the next mount .
  • cacheTime was renamed to gcTime in v5. The old name is not recognized. The rename reflects the option’s actual function: garbage collection, not caching .
  • Use Infinity sparingly. staleTime: Infinity and gcTime: Infinity are appropriate for truly static reference data. For user-specific or volatile data, finite timers prevent unbounded memory growth.

Remember: The cache is not a persistent store. It is a freshness coordinator with a garbage collector attached. staleTime determines when the coordinator decides to check for updates; gcTime determines when the collector decides to reclaim memory. Set staleTime based on how often the data actually changes, set gcTime based on how likely users are to return to the same data, and verify the relationship between the two. The defaults are a starting point, not a recommendation.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!