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.
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.
- Grab the client with
useQueryClient()inside a component, or import your singleton directly in a module-scope debug helper. - Project each
Queryinto a flat row: hash, key, status,dataUpdatedAt, observer count. - Derive freshness by comparing
dataUpdatedAtagainst the query’s ownstaleTime, not a global constant. - Sort by the axis you are investigating — observer count for sharing problems,
dataUpdatedAtfor 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.stringifyon 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 callinguseQuerytwice with the same key registers two observers, which is exactly the signal you want when hunting duplicate fetches.- Sorting by
bytesfinds the memory outlier; sorting byageMsfinds the entry that a user is staring at. They are rarely the same entry.
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.
- Subscribe once, at client construction, so no fetch escapes the log.
- Filter to the event types you care about —
updatedwith afetchaction is the one that explains network traffic. - Record the query hash, the trigger, and a monotonic timestamp.
- 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
sinkruns 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 thanDate.now()keeps the deltas monotonic across system clock adjustments, which matters for long-lived sessions.
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.
- Count an activation whenever an observer attaches to a query (
observerAdded). - Count a miss whenever that activation is followed by a fetch that was not already in flight.
- Compute the ratio over a rolling window, not since page load — a long session otherwise averages away the regression you are hunting.
- 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
staleTimeon 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.
- Aggregate in memory; never emit per event.
- Flush on a coarse interval and on
visibilitychange, so a closing tab still reports. - Cap cardinality — a fixed allowlist of key prefixes, everything else bucketed as
other. - Use
navigator.sendBeaconso 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
hitRateto 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.
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.
Related
- State Architecture & Cache Fundamentals — the parent section covering cache-layer design, storage models and the boundaries this instrumentation measures.
- Cache Memory Management — what to change once the instrumentation shows the cache is holding more than it should.
- Inspecting Cache State With React Query DevTools — a guided tour of the panel, including the states that look wrong but are not.
- Measuring Cache Hit Rate in Production — the full instrumentation build, from event subscription to a bounded metrics payload.
- Logging Query Lifecycle Events for Debugging — structured logging of the cache event stream, and how to keep it out of your interaction latency.