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-Modified header, an audit log, or a database updated_at distribution.
  • Analytics on how long users take to return to a screen, or a willingness to estimate it and revisit.
The two windows an entry lives through A horizontal timeline from a successful fetch. The first segment is the fresh window, bounded by staleTime, during which reads never revalidate. The second segment is stale but cached, during which reads return data immediately and revalidate in the background. A separate track shows the garbage collection window, which starts only when the last observer unmounts. Two clocks, started by different events clock 1 — starts on fetch success FRESH — staleTime reads never revalidate STALE — data still served instantly reads return the cached value and revalidate in the background staleTime elapses clock 2 — starts when the LAST observer unmounts RETAINED — gcTime nothing is reading it, but returning is still instant COLLECTED returning now costs a full fetch and a skeleton gcTime shorter than staleTime throws away data that would still have been trusted.
The clocks start on different events, which is why one value cannot be derived from the other.

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.

Requests per session as staleTime rises on a board-list query Bar chart of median requests per session. staleTime of zero produces 41, five seconds produces 24, thirty seconds produces 9, two minutes produces 6 and five minutes produces 4. Requests per session as staleTime rises on a board-list query Replay of 400 real sessions against a fixed navigation trace staleTime 0 41 req staleTime 5s 24 req staleTime 30s 9 req staleTime 2min 6 req staleTime 5min 4 req
Three quarters of the saving arrives in the first thirty seconds. Past two minutes you are trading correctness for almost nothing.

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”.

Deriving both values for four kinds of resource Matrix of four resource classes with their measured change interval, chosen staleTime, chosen gcTime and the reasoning. Deriving both values for four kinds of resource Change interval… staleTime gcTime Why Country and currency lists Only on deploy Infinity 24h Invalidated explicitly, never by time Organisation settings Hours 10 min 30 min Rarely edited, frequently read Board and project lists Minutes 30 s 5 min Users return within a few minutes Active board contents Seconds 5 s 1 min Collaborators are editing it right now
Each row is a measurement plus a decision, not a preference. That is what makes the numbers defensible in review.

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.