Tuning staleTime and gcTime
Both values are usually set once, by guess, and never revisited — which is why so many applications have a staleTime of zero (refetching constantly) or five minutes (serving data that changed thirty seconds ago) with no analysis behind either. The two settings answer different questions and should be derived from different measurements. This page is the derivation, under Stale-While-Revalidate Implementation. Once the values are right, Showing Stale Data While Revalidating in the UI covers presenting the resulting states, and Tuning gcTime to Control Cache Memory goes deeper on the memory half.
Prerequisites
- A list of your cached resources, grouped by how volatile they are.
- Some evidence about change rate — a
Last-Modifiedheader, an audit log, or a databaseupdated_atdistribution. - Analytics on how long users take to return to a screen, or a willingness to estimate it and revisit.
Step 1 — Measure How Often Each Resource Changes
staleTime is a claim about the world, so it needs evidence from the world. The cheapest source is usually already in your database.
-- Median seconds between consecutive updates, per resource, over 30 days.
SELECT resource,
percentile_cont(0.5) WITHIN GROUP (ORDER BY delta) AS median_seconds,
percentile_cont(0.1) WITHIN GROUP (ORDER BY delta) AS p10_seconds
FROM (
SELECT 'todo' AS resource,
EXTRACT(EPOCH FROM updated_at - LAG(updated_at) OVER (PARTITION BY id ORDER BY updated_at)) AS delta
FROM todos WHERE updated_at > now() - interval '30 days'
) changes
WHERE delta IS NOT NULL
GROUP BY resource;
Cache Behavior Analysis: The p10 matters more than the median: staleTime should be short enough that the fast-changing tail of a resource is not served wrong, because those are the records users are actively working on and therefore the ones they notice. A resource whose median gap is twenty minutes but whose p10 is fifteen seconds is not a twenty-minute resource — it is two populations, and it usually deserves two staleTime values selected by a flag on the record. Where you have no database access, the Last-Modified header on a sample of responses gives the same distribution from the outside.
Step 2 — Set staleTime Below the Change Interval
The rule is one line: staleTime should be comfortably under the p10 change interval for that resource, because exceeding it means serving data you know is likely wrong.
import { queryOptions } from '@tanstack/react-query';
// A small set of tiers, each justified by a measurement.
export const FRESHNESS = {
/** Changes only on deploy: enums, country lists, feature definitions. */
static: { staleTime: Infinity, gcTime: 24 * 60 * 60_000 },
/** p10 change interval measured in hours: org settings, user profile. */
slow: { staleTime: 10 * 60_000, gcTime: 30 * 60_000 },
/** p10 in minutes: board lists, project metadata. */
normal: { staleTime: 30_000, gcTime: 5 * 60_000 },
/** p10 in seconds: presence, live counters, an active board. */
volatile: { staleTime: 5_000, gcTime: 60_000 },
} as const;
export const boardQueries = {
list: () => queryOptions({ queryKey: ['boards', 'list'], queryFn: fetchBoards, ...FRESHNESS.normal }),
detail: (id: string) =>
queryOptions({ queryKey: ['boards', 'detail', id], queryFn: () => fetchBoard(id), ...FRESHNESS.volatile }),
countries: () =>
queryOptions({ queryKey: ['reference', 'countries'], queryFn: fetchCountries, ...FRESHNESS.static }),
};
Cache Behavior Analysis: staleTime is evaluated per read against the entry’s dataUpdatedAt, so raising it does not extend the life of data already in the cache retroactively — it only affects decisions made from that point on. Setting staleTime: Infinity disables time-based revalidation entirely but leaves manual invalidateQueries fully functional, which is what makes the static tier safe: the data is refreshed exactly when your own mutation says it changed. Note that refetchOnWindowFocus and refetchOnMount both respect staleTime, so a fresh entry is not refetched on focus — a fact that surprises people who expect focus to force a fetch.
Step 3 — Set gcTime From How Long Users Stay Away
gcTime is a memory decision informed by behaviour: how long between leaving a screen and coming back?
// Instrument the interval between an entry losing its last observer and
// gaining one again. The p75 of this distribution is the useful gcTime.
export function measureReturnInterval(client: QueryClient) {
const idleSince = new Map<string, number>();
const returns: number[] = [];
client.getQueryCache().subscribe((event) => {
const hash = event.query.queryHash;
if (event.type === 'observerRemoved' && event.query.getObserversCount() === 0) {
idleSince.set(hash, Date.now());
}
if (event.type === 'observerAdded') {
const left = idleSince.get(hash);
if (left != null) { returns.push(Date.now() - left); idleSince.delete(hash); }
}
});
return () => {
const sorted = [...returns].sort((a, b) => a - b);
return { p50: sorted[Math.floor(sorted.length * 0.5)], p75: sorted[Math.floor(sorted.length * 0.75)] };
};
}
Cache Behavior Analysis: The gcTime timer arms only when the observer count reaches zero and is cancelled if an observer re-attaches before it fires, so this measurement records exactly the distribution the setting is decided against. Choosing the p75 means three quarters of returns land on a warm entry, which is where the curve of diminishing returns bends for most applications — pushing to p95 typically triples memory for a few percentage points of hit rate. Because gcTime also bounds what dehydrate will serialize, a value shorter than your persistence maxAge produces persisted snapshots that are mostly empty, as described in Cache Versioning & Migration.
Step 4 — Express the Result as Named Tiers
Per-query numbers drift. A closed set of named tiers makes each choice reviewable and keeps the values explainable a year later.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// The default is the tier most resources belong to, not zero.
...FRESHNESS.normal,
retry: (count, error) => (error instanceof ApiError && !error.retryable ? false : count < 2),
},
},
});
// A reviewer can now ask "why is this volatile?" instead of "why 5000?".
export const presenceQueries = {
roomMembers: (roomId: string) =>
queryOptions({
queryKey: ['presence', 'room', roomId],
queryFn: () => fetchRoomMembers(roomId),
...FRESHNESS.volatile,
refetchInterval: 10_000,
}),
};
Cache Behavior Analysis: Making normal the client default rather than the library default of staleTime: 0 changes the baseline behaviour of every query that does not opt out, which is usually the single highest-leverage change in this whole exercise — the library ships with zero because it cannot know your data, not because zero is a good value. Tiers also make the relationship between staleTime and refetchInterval explicit: an interval shorter than staleTime fires and then does nothing, because the entry is still fresh, which is a common source of “my polling does not work”.
Edge Cases & Gotchas
gcTime shorter than staleTime. The entry is collected while it would still have been trusted, so returning to a screen costs a full fetch and a skeleton for data that was fine. Enforce the invariant in the tier definition rather than at each call site.
Per-observer staleTime disagreements. Two components can read one key with different staleTime values; the entry refetches according to whichever observer is currently deciding, which produces behaviour that looks random. Set freshness in one place per resource, as in Separating the Cache Layer From the Transport Layer.
SWR’s different vocabulary. SWR v2 has dedupingInterval (roughly staleTime) and no direct gcTime equivalent — entries live as long as the cache provider holds them. Porting a tier table to SWR means mapping staleTime onto dedupingInterval and implementing retention yourself.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Every navigation shows a skeleton | gcTime is shorter than the typical return interval |
Measure the return-interval p75 and set gcTime above it |
| Polling appears to do nothing | refetchInterval is shorter than staleTime, so the entry is still fresh |
Set staleTime below the interval |
| Users see values that changed a minute ago | staleTime exceeds the p10 change interval for that resource |
Move the resource to a more volatile tier |
| Two components refetch the same key differently | staleTime is set per call site |
Define freshness once per resource |
| Memory grows on data-heavy screens | gcTime is generous and entries are large |
Keep the tier but add a size cap, as in Limiting Cache Size With LRU Eviction |
Frequently Asked Questions
What is the difference between staleTime and gcTime?
staleTime controls how long the cached value is trusted without revalidating — it is a correctness setting, and its clock starts when a fetch succeeds. gcTime controls how long an unused entry survives after its last observer unmounts — it is a memory setting, and its clock starts on unmount. They are frequently conflated because both are durations on the same object, but they are decided by different evidence: change rate for one, return interval for the other. An entry can be stale and retained, fresh and retained, or fresh and collected the instant you navigate away.
Should gcTime always be longer than staleTime?
Almost always, and the exception is rare enough that it is worth treating the invariant as a rule. If gcTime is shorter, an entry is discarded while it would still have been considered fresh, so returning to that screen costs a full fetch and a loading state for data the cache was holding a moment earlier and would have served without revalidating. The one case where a short gcTime is deliberate is genuinely large data that you would rather refetch than retain — and there a size cap is usually a better instrument than a time limit.
Is staleTime: Infinity ever right?
Yes, for data that changes only when your own code changes it: enum lists, country and currency tables, feature definitions, a completed invoice. For those, time-based revalidation is pure waste — the data will not have changed, and refetching it on a schedule adds requests to prove that. What makes it safe rather than reckless is pairing it with explicit invalidation: every mutation that could alter the resource must invalidate its key, and if no such mutation exists in your application, then the only way the data changes is a deploy, which busts the cache anyway.
Related
- Stale-While-Revalidate Implementation — the parent guide on serving cached data while revalidating behind it.
- Showing Stale Data While Revalidating in the UI — presenting the states these settings produce.
- Preventing Request Waterfalls With Parallel Queries — the sibling technique for when the problem is request shape rather than freshness.
- Tuning gcTime to Control Cache Memory — the memory half of this decision in more depth.