Cache Observability & Debugging

A query cache is the only part of a frontend that silently decides not to do work. When it decides correctly the application feels instant; when it decides incorrectly you get phantom refetches, stale screens, or a heap that grows all afternoon — and none of those announce themselves. This guide is about making those decisions visible. It belongs to State Architecture & Cache Fundamentals because observability is what turns the architectural choices described there into something you can verify, and it pairs closely with Cache Memory Management, which covers what to do once the instrumentation tells you the cache is too large.

The techniques here are deliberately framework-specific where it matters. TanStack Query v5 exposes its internal QueryCache and MutationCache as subscribable event emitters, which is the foundation everything else is built on. Apollo Client v3 exposes cache.extract() and the Apollo DevTools bridge. SWR exposes its Map-backed cache provider. Each gives you a different seam, and knowing which seam to reach for is most of the work.

Diagnostic Checklist

Read this guide if any of the following describe your application:

Prerequisites

This guide assumes you understand how reference vs value storage determines when a cached object is replaced rather than mutated, and how stable query keys determine whether two reads address the same entry at all. Most “the cache is broken” reports resolve to one of those two topics; instrumentation just tells you which.

Where to observe a query cache A horizontal pipeline from a component's useQuery call through key resolution, cache lookup and the fetch decision to observer notification. Four numbered probe points hang below the pipeline: query hash counting at key resolution, entry state at cache lookup, fetch reason at the fetch decision, and render counting at observer notification. The four points where a cache will tell you what it is doing useQuery call component mounts Key resolution hashKey(queryKey) Cache lookup hit, miss or stale hit Observer render or no-op probe 1 count observers per queryHash finds duplicate fetches probe 2 count distinct hashes per resource finds unstable keys probe 3 log fetch reason from the event stream finds noisy triggers probe 4 count renders per data change finds identity churn Every cache complaint lands on exactly one of these four probes Identify the probe first; the fix follows from which number is wrong.
Instrument in this order. Probing renders before you have checked key stability usually means fixing the wrong layer.

Implementation 1 — Read the Cache Directly

The QueryClient is not a black box. queryClient.getQueryCache().getAll() returns every Query instance the client is holding, each with its key, its hash, its state, and its list of observers. That single call answers most cache questions without any tooling at all.

  1. Grab the client with useQueryClient() inside a component, or import your singleton directly in a module-scope debug helper.
  2. Project each Query into a flat row: hash, key, status, dataUpdatedAt, observer count.
  3. Derive freshness by comparing dataUpdatedAt against the query’s own staleTime, not a global constant.
  4. Sort by the axis you are investigating — observer count for sharing problems, dataUpdatedAt for staleness problems.
import { QueryClient } from '@tanstack/react-query';

export interface CacheRow {
  hash: string;
  key: unknown[];
  status: 'pending' | 'error' | 'success';
  observers: number;
  ageMs: number;
  isStale: boolean;
  bytes: number;
}

export function inspectCache(client: QueryClient): CacheRow[] {
  const now = Date.now();

  return client
    .getQueryCache()
    .getAll()
    .map((query) => ({
      hash: query.queryHash,
      key: query.queryKey as unknown[],
      status: query.state.status,
      // getObserversCount() is the number of live useQuery subscriptions. Zero
      // means the entry is only being kept alive by gcTime.
      observers: query.getObserversCount(),
      ageMs: query.state.dataUpdatedAt ? now - query.state.dataUpdatedAt : -1,
      // isStale() consults the query's own staleTime, including per-query overrides.
      isStale: query.isStale(),
      // A rough footprint. JSON length is not the heap cost, but it ranks entries
      // correctly, which is all you need to find the outlier.
      bytes: query.state.data ? JSON.stringify(query.state.data).length : 0,
    }))
    .sort((a, b) => b.bytes - a.bytes);
}

Cache Behavior Analysis: getAll() returns live Query objects, not snapshots — reading query.state gives you the current state at call time, and isStale() re-evaluates against the query’s own staleTime rather than a client default. Calling this function does not create an observer, so it cannot itself trigger a fetch or reset a garbage-collection timer. Entries with observers: 0 are unmounted queries in their gcTime window; they are the ones that will disappear on their own, and the ones worth counting when you are sizing the cache.

Configuration Trade-offs:

  • JSON.stringify on every entry is O(total cache size) and will visibly stall a large cache. Call it on demand from a debug panel, never on an interval.
  • getObserversCount() counts observers, not components — a component calling useQuery twice with the same key registers two observers, which is exactly the signal you want when hunting duplicate fetches.
  • Sorting by bytes finds the memory outlier; sorting by ageMs finds the entry that a user is staring at. They are rarely the same entry.
Where the bytes actually are in a mid-session cache Bar chart of serialized cache entry sizes. The activity feed entry dominates at 512 kilobytes, followed by the board list at 184, search results at 96, the user list at 41 and the current user at 3. Where the bytes actually are in a mid-session cache One 20-minute session on a project-management app, top entries by serialized size activity feed (infinite) 512 KB board list 184 KB search results 96 KB user directory 41 KB current user 3 KB
A single unbounded list usually dominates. Tuning gcTime globally is far less effective than capping the one entry that holds most of the heap.

Implementation 2 — Subscribe to the Cache Event Stream

Reading the cache tells you its state now. Subscribing tells you why it got there. QueryCache.subscribe emits an event for every added, updated, removed and observer-attached transition, and the updated event carries the action that caused it.

  1. Subscribe once, at client construction, so no fetch escapes the log.
  2. Filter to the event types you care about — updated with a fetch action is the one that explains network traffic.
  3. Record the query hash, the trigger, and a monotonic timestamp.
  4. Return the unsubscribe function so tests and hot reloads can tear the listener down.
import { QueryClient, type QueryCacheNotifyEvent } from '@tanstack/react-query';

export interface FetchLogEntry {
  at: number;
  hash: string;
  reason: 'mount' | 'invalidate' | 'refetch' | 'unknown';
  sinceLastMs: number | null;
}

export function traceFetches(
  client: QueryClient,
  sink: (entry: FetchLogEntry) => void,
): () => void {
  const lastSeen = new Map<string, number>();

  return client.getQueryCache().subscribe((event: QueryCacheNotifyEvent) => {
    if (event.type !== 'updated') return;
    // v5 wraps every state transition in an action; 'fetch' is the one that
    // means a network request is about to leave the browser.
    if (event.action.type !== 'fetch') return;

    const at = performance.now();
    const previous = lastSeen.get(event.query.queryHash) ?? null;
    lastSeen.set(event.query.queryHash, at);

    sink({
      at,
      hash: event.query.queryHash,
      reason:
        event.query.getObserversCount() === 0
          ? 'invalidate'
          : previous === null
            ? 'mount'
            : 'refetch',
      sinceLastMs: previous === null ? null : Math.round(at - previous),
    });
  });
}

Cache Behavior Analysis: The subscription fires synchronously inside the cache’s notify cycle, before React has re-rendered, so sinceLastMs measures the true interval between fetch decisions rather than between paints. A pair of entries for the same hash a few milliseconds apart is the signature of two observers mounting with keys that hash identically but were created separately — TanStack Query deduplicates those into one in-flight request, so you will see two fetch actions but only one network request. Entries hundreds of milliseconds apart with reason: 'refetch' are genuine repeat traffic and are what you actually need to eliminate.

Configuration Trade-offs:

  • Subscribing at client construction catches bootstrap prefetches; subscribing inside a component misses everything that happened before mount.
  • The sink runs on the notify path — keep it to an array push. Anything that serializes or posts synchronously will show up in your interaction latency.
  • performance.now() rather than Date.now() keeps the deltas monotonic across system clock adjustments, which matters for long-lived sessions.
A refetch storm as the event stream records it Timeline of five fetch events for the same query hash within 900 milliseconds, showing window focus, reconnect and two component mounts collapsing into overlapping refetches. A refetch storm as the event stream records it 0ms Tab regains focus refetchOnWindowFocus fires 12ms Route component mounts second observer attaches 18ms Deduplicated one request in flight 640ms Network reconnects refetchOnReconnect fires again 900ms Interval elapses refetchInterval adds a third
Five fetch actions, one useful response. Each trigger is individually reasonable; the storm is what they do together.

Implementation 3 — Derive a Hit Rate You Can Trust

“Cache hit rate” is easy to define badly. Counting network requests against renders flatters the number, because a component that re-renders ten times from one cached value looks like nine hits. The meaningful denominator is query activations: every time an observer starts caring about a key.

  1. Count an activation whenever an observer attaches to a query (observerAdded).
  2. Count a miss whenever that activation is followed by a fetch that was not already in flight.
  3. Compute the ratio over a rolling window, not since page load — a long session otherwise averages away the regression you are hunting.
  4. Bucket by key prefix so you can see which feature is missing, not just that something is.
import type { QueryClient } from '@tanstack/react-query';

interface Bucket { activations: number; misses: number }

export function trackHitRate(client: QueryClient) {
  const buckets = new Map<string, Bucket>();
  const prefixOf = (key: readonly unknown[]) => String(key[0] ?? 'unknown');

  const bump = (prefix: string, field: keyof Bucket) => {
    const bucket = buckets.get(prefix) ?? { activations: 0, misses: 0 };
    bucket[field] += 1;
    buckets.set(prefix, bucket);
  };

  const unsubscribe = client.getQueryCache().subscribe((event) => {
    const prefix = prefixOf(event.query.queryKey as readonly unknown[]);

    if (event.type === 'observerAdded') {
      bump(prefix, 'activations');
      // A fresh entry with data already present is served without a fetch.
      return;
    }

    if (event.type === 'updated' && event.action.type === 'fetch') {
      // fetchStatus was idle before this action, so this is a real miss and
      // not an extra observer joining a request that is already running.
      if (event.query.state.fetchStatus === 'idle') bump(prefix, 'misses');
    }
  });

  return {
    unsubscribe,
    snapshot: () =>
      [...buckets.entries()].map(([prefix, b]) => ({
        prefix,
        activations: b.activations,
        hitRate: b.activations === 0 ? 1 : 1 - b.misses / b.activations,
      })),
  };
}

Cache Behavior Analysis: observerAdded fires once per useQuery subscription, including subscriptions that are immediately satisfied from a fresh entry — which is precisely the event a network-based counter cannot see. Checking fetchStatus === 'idle' before counting a miss excludes observers that piggyback on an in-flight request; without that guard, a list screen mounting twelve rows against one shared key would report eleven false misses. Because the bucket key is the first element of the query key, this reports per-feature hit rates provided your keys follow the hierarchical shape described in Query Key Factory Patterns.

Configuration Trade-offs:

  • Bucketing on key[0] is cheap and stable; bucketing on the full hash produces a cardinality explosion in your metrics backend.
  • A rolling window needs a timestamp per event and periodic pruning — worth it, because a lifetime average hides a regression introduced an hour into a session.
  • Hit rate alone is not a goal. A 100% hit rate with a five-minute staleTime on a resource that changes every ten seconds means the cache is serving wrong data efficiently.

Implementation 4 — Ship a Bounded Sample to Production

Development instrumentation answers “why is this happening on my machine”. Production instrumentation answers “is this happening at all, and to whom”. The constraint is that neither the payload nor the CPU cost may be noticeable.

  1. Aggregate in memory; never emit per event.
  2. Flush on a coarse interval and on visibilitychange, so a closing tab still reports.
  3. Cap cardinality — a fixed allowlist of key prefixes, everything else bucketed as other.
  4. Use navigator.sendBeacon so the flush survives unload without blocking it.
const TRACKED_PREFIXES = new Set(['boards', 'todos', 'users', 'activity']);

export function reportCacheMetrics(
  snapshot: () => Array<{ prefix: string; activations: number; hitRate: number }>,
  endpoint: string,
) {
  const flush = () => {
    const rows = snapshot()
      .filter((row) => row.activations > 0)
      .map((row) => ({
        prefix: TRACKED_PREFIXES.has(row.prefix) ? row.prefix : 'other',
        activations: row.activations,
        // One decimal place is plenty and keeps the payload small.
        hitRate: Math.round(row.hitRate * 1000) / 1000,
      }));

    if (rows.length === 0) return;
    navigator.sendBeacon(endpoint, new Blob([JSON.stringify({ rows })], {
      type: 'application/json',
    }));
  };

  const timer = window.setInterval(flush, 60_000);
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') flush();
  });

  return () => window.clearInterval(timer);
}

Cache Behavior Analysis: sendBeacon queues the payload with the browser rather than the page, so it completes after the document is torn down — the only reliable way to capture the final window of a session. Flushing on visibilitychange rather than beforeunload matters on mobile Safari, where beforeunload frequently never fires. The prefix allowlist is not an optimisation detail: an unbounded prefix label is the standard way teams accidentally create a million-series metric out of query keys that embed entity ids.

Configuration Trade-offs:

  • A 60-second flush interval trades resolution for request count. Below about 30 seconds the beacon traffic itself becomes visible in analytics.
  • Rounding hitRate to three decimals keeps rows small; keeping raw floats roughly doubles payload size for no analytical gain.
  • Sampling a fraction of sessions is usually better than reducing the flush interval — you keep resolution where you are looking.
Hit rate by feature over one working day Line chart of cache hit rate across six two-hour buckets for three features. Boards stays between 0.82 and 0.9. Activity feed drops from 0.71 to 0.34. Users stays near 0.95. Hit rate by feature over one working day users boards activity feed 0 0.3 0.5 0.8 1 hit rate 08:00 10:00 12:00 14:00 16:00 18:00 time of day
The activity feed's decline is a key-stability regression shipped at midday — invisible in a lifetime average, obvious in a rolling one.

Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
Cache listing shows dozens of near-identical keys for one resource A non-primitive value — an inline object, a Date, a freshly built array — is embedded in the query key and re-created every render Group getAll() output by key[0] and count distinct queryHash values; more hashes than genuine parameter sets confirms it. Route the key through a factory as in Building a Type-Safe Query Key Factory
Hit rate looks excellent but users report stale screens staleTime exceeds the resource’s real change rate, so the cache is confidently serving old data Compare dataUpdatedAt against server Last-Modified for a sample of entries; if the server is consistently newer, the fix is invalidation, not more caching
Fetch trace shows several fetch actions milliseconds apart, but only one network request Multiple observers attached to the same hash and were deduplicated into one in-flight request Expected behaviour, not a bug. Filter the trace to fetchStatus === 'idle' transitions to see real requests only
Memory climbs steadily even though gcTime is set One infinite-query entry accumulates pages and is never unmounted, so gcTime never starts Sort inspectCache() by bytes; cap the page count as described in Tuning gcTime to Control Cache Memory
DevTools shows the correct data but the component renders old values The component holds a derived value that was memoized against an unstable dependency Count renders per data change; see Structural Sharing and Referential Stability

Frequently Asked Questions

Why does the DevTools panel show a query as fresh when the UI is clearly showing old data?

Freshness is a statement about staleTime, not about correctness. A query is fresh for staleTime milliseconds after its last successful fetch, regardless of what happened on the server in that window. If the UI is wrong but the entry is fresh, either staleTime exceeds the rate at which that resource actually changes, or a mutation elsewhere failed to invalidate the key. Check the second possibility first: it is far more common, and the fix — invalidating on the mutation’s onSettled — is local. Widening or narrowing staleTime to compensate for a missing invalidation trades one bug for a performance regression.

Can I ship the React Query DevTools to production?

You can, but lazily. Import the production build from @tanstack/react-query-devtools/production behind a dynamic import gated on a query parameter or an internal feature flag, so the bundle cost is only paid by the engineers who open it. Never render the development entry point in a production bundle — it pulls in development-only instrumentation and will be tree-shaken inconsistently across bundlers. The pattern that works well is a keyboard chord that sets a flag in sessionStorage and then dynamically imports the panel, so support engineers can enable it on a real user’s session without a redeploy.

What is the single most useful cache metric to alert on?

Fetches per mounted observer over a rolling window. It rises when staleTime is too short, when query keys are unstable, and when a refetch trigger fires more often than intended — three of the most common and most expensive cache misconfigurations, all visible in one number. It is also robust to traffic changes in a way that raw request counts are not: a doubling of users doubles requests and observers together, leaving the ratio flat, so a movement in the ratio is always a real behavioural change.

How do I tell an unstable query key from a genuinely stale entry?

Count distinct queryHash values for the same logical resource. A stable key produces exactly one hash per distinct parameter set, so a screen with three filter combinations should show three hashes. An unstable key produces a new hash on nearly every render, which shows up as a cache that grows in proportion to render count with a hit rate near zero. The getAll() listing makes it obvious: you will see the same key[0] repeated with hashes that differ only in the serialization of an object you did not intend to include.