TanStack Query 05 ⚛️ Stale & Cache — Configuring staleTime and gcTime
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
| Option | Default | Effect |
|---|---|---|
staleTime | 0 | Data is stale immediately; refetches on mount/focus/reconnect |
gcTime | 5 minutes | Inactive cache entries removed after 5 minutes |
refetchOnWindowFocus | true | Stale queries refetch when tab regains focus |
refetchOnReconnect | true | Stale queries refetch when network reconnects |
staleTime Guidelines by Data Volatility
| Data Type | Recommended staleTime | Example |
|---|---|---|
| Static reference | Infinity | Country list, timezones |
| Rarely changing | 1 hour | Feature flags, app config |
| Moderately changing | 1–5 minutes | User profiles, todo lists |
| Frequently changing | 0–30 seconds | Notifications, activity feeds |
| Real-time | 0–5 seconds | Stock prices, chat, live status |
Timer Relationship Rules
| Rule | Reason |
|---|---|
gcTime >= staleTime | Prevent garbage collection of fresh data |
staleTime: Infinity requires gcTime: Infinity | Fresh data must not be collected |
staleTime does not affect active queries | Active queries stay in memory regardless |
gcTime starts only when observers unmount | Mounted 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Data refetches constantly | Default staleTime: 0 | Raise staleTime for non-volatile data |
| Loading state appears unexpectedly | gcTime expired, cache entry removed | Raise gcTime to match navigation patterns |
| Data never refreshes | staleTime: Infinity used incorrectly | Use finite staleTime for changing data |
cacheTime not recognized | v4 option name used in v5 | Rename to gcTime |
| Fresh data garbage collected | gcTime < staleTime | Ensure gcTime >= staleTime |
| Background refetch not visible | isFetching not checked | Render indicator when isFetching is true |
| Cache grows unbounded | gcTime: Infinity on many queries | Use 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
| Item | Value |
|---|---|
staleTime | Duration data is considered fresh; no refetch within window |
gcTime | Duration inactive data remains in memory before removal |
Default staleTime | 0 (immediately stale) |
Default gcTime | 5 minutes |
| Fresh data behavior | Cached value returned; no network request |
| Stale data behavior | Cached value returned immediately; background refetch runs |
| Inactive query | No mounted observers; gcTime timer starts |
| Constraint | gcTime >= staleTime |
| v5 rename | cacheTime → gcTime |
| Static data | staleTime: Infinity, gcTime: Infinity |
| Real-time data | Short staleTime, optional refetchInterval |
Key takeaways:
staleTimeandgcTimeanswer 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
staleTimeof0is aggressive by design. TanStack Query assumes data is volatile until told otherwise. RaisingstaleTimeis 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. gcTimestarts 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.gcTimemust be greater than or equal tostaleTime. Otherwise fresh data can be garbage collected, forcing an unnecessary network request on the next mount .cacheTimewas renamed togcTimein v5. The old name is not recognized. The rename reflects the option’s actual function: garbage collection, not caching .- Use Infinity sparingly.
staleTime: InfinityandgcTime: Infinityare 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.
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!