Measuring Cache Hit Rate in Production
Most teams cannot answer “what fraction of our reads are served from cache?” because the number is not in the network panel — the network panel only sees what missed. Without the denominator you cannot tell whether a change to staleTime helped, whether a key regression shipped, or whether the cache is doing anything at all. This page builds the measurement, under Cache Observability & Debugging. It uses the same event stream as Logging Query Lifecycle Events for Debugging, aggregated rather than logged, and it is what turns the local reading in Inspecting Cache State With React Query DevTools into something you can watch across a fleet.
Prerequisites
- A
QueryClientyou can subscribe to at construction time. - Query keys with a stable first element per feature — see Cache Key Serialization & Hashing.
- A metrics endpoint that accepts a small JSON body over
sendBeacon.
Step 1 — Define the Denominator as Activations
An activation is one component starting to read one key. That is the unit the cache either satisfies or does not.
import type { QueryClient } from '@tanstack/react-query';
interface Counters { activations: number; misses: number; fetches: number }
const ALLOWED = new Set(['boards', 'todos', 'users', 'activity', 'billing']);
const label = (key: readonly unknown[]) => {
const root = String(key[0] ?? 'unknown');
// A closed allowlist keeps an entity id in the key from creating a series.
return ALLOWED.has(root) ? root : 'other';
};
export function createHitRateMeter(client: QueryClient) {
const counters = new Map<string, Counters>();
const bump = (name: string, field: keyof Counters) => {
const row = counters.get(name) ?? { activations: 0, misses: 0, fetches: 0 };
row[field] += 1;
counters.set(name, row);
};
const unsubscribe = client.getQueryCache().subscribe((event) => {
const name = label(event.query.queryKey as readonly unknown[]);
if (event.type === 'observerAdded') bump(name, 'activations');
});
return { counters, unsubscribe, bump };
}
Cache Behavior Analysis: observerAdded fires for every useQuery subscription, including the ones satisfied instantly from a fresh entry — which is precisely the population a network-derived metric cannot see, because those activations produce no request. It also fires once per observer rather than once per component, so a list rendering twelve rows against one shared key produces twelve activations, correctly reflecting that the cache served twelve reads. Bucketing on the first key element means the metric follows your key namespaces, which is another reason to enforce namespacing as described in Avoiding Query Key Collisions Across Features.
Step 2 — Count a Miss Only for a Fetch That Was Not Already Running
The single most common instrumentation bug is counting every fetch action as a miss, which turns request deduplication into an apparent failure.
export function attachMissCounting(client: QueryClient, meter: ReturnType<typeof createHitRateMeter>) {
return client.getQueryCache().subscribe((event) => {
if (event.type !== 'updated' || event.action.type !== 'fetch') return;
const name = label(event.query.queryKey as readonly unknown[]);
meter.bump(name, 'fetches');
// fetchStatus is still 'idle' at the moment the FIRST fetch action lands.
// Observers joining an in-flight request see 'fetching' and are not misses.
if (event.query.state.fetchStatus === 'idle') meter.bump(name, 'misses');
});
}
Cache Behavior Analysis: TanStack Query deduplicates concurrent fetches for the same key into one in-flight promise, so a screen mounting twelve observers against one key produces twelve fetch actions but exactly one network request. Reading fetchStatus at the moment the action lands distinguishes the two: it is still idle for the observer that actually started the request and fetching for everyone who joined. Keeping both fetches and misses is worth the extra counter — their ratio is the deduplication factor, and a sudden drop in it usually means keys that used to be shared have diverged.
Step 3 — Aggregate and Flush on a Beacon
Emitting per event would cost more than the cache saves. Aggregate in memory and flush coarsely.
export function reportHitRate(
meter: { counters: Map<string, { activations: number; misses: number; fetches: number }> },
endpoint: string,
intervalMs = 60_000,
) {
const flush = () => {
const rows = [...meter.counters.entries()]
.filter(([, c]) => c.activations > 0)
.map(([feature, c]) => ({
feature,
activations: c.activations,
hitRate: Math.round((1 - c.misses / c.activations) * 1000) / 1000,
// fetches / misses is the deduplication factor: 1.0 means no sharing.
dedupe: c.misses === 0 ? 1 : Math.round((c.fetches / c.misses) * 100) / 100,
}));
if (rows.length === 0) return;
meter.counters.clear(); // report deltas, not cumulative totals
navigator.sendBeacon(endpoint, new Blob([JSON.stringify({ rows })], { type: 'application/json' }));
};
const timer = window.setInterval(flush, intervalMs);
// visibilitychange fires reliably on mobile where beforeunload does not.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
return () => window.clearInterval(timer);
}
Cache Behavior Analysis: Clearing the counters after each flush turns the series into per-interval deltas, which is what makes a step change visible — cumulative totals average a regression away, so a rate that halves halfway through a session still looks like a mild dip. sendBeacon hands the payload to the browser rather than the page, so the final interval of a session is captured even though the document is being torn down. Flushing on visibilitychange rather than beforeunload matters on iOS, where beforeunload frequently never fires and the last window is otherwise lost entirely.
Step 4 — Alert on Fetches per Activation
The raw hit rate is noisy across cohorts. Its reciprocal-shaped sibling — fetches per activation — is stable under traffic changes and moves only when behaviour changes.
// Server side, over a five-minute window per feature.
export function shouldAlert(current: { fetches: number; activations: number },
baseline: { fetches: number; activations: number }) {
const ratio = (row: { fetches: number; activations: number }) =>
row.activations === 0 ? 0 : row.fetches / row.activations;
const now = ratio(current);
const then = ratio(baseline);
// Ignore low-traffic windows: a handful of activations is all noise.
if (current.activations < 500) return false;
// A 50% rise in requests per read is a behavioural change, not variance.
return then > 0 && now / then >= 1.5;
}
Cache Behavior Analysis: Fetches per activation is invariant to traffic: doubling the number of users doubles both terms, so the ratio stays flat and any movement is a genuine change in how the cache behaves. It rises for all three of the expensive misconfigurations — a staleTime that is too short, a key that has become unstable, and a refetch trigger firing more often than intended — which makes it a good single alert to start with. The volume floor is not optional: with fewer than a few hundred activations the ratio swings wildly, and an alert that fires on every quiet window gets muted within a week.
Edge Cases & Gotchas
A frozen cache scores perfectly. A staleTime of Infinity produces a hit rate of 1.0 and serves data that may be hours old. Always pair the rate with a correctness signal — a count of entries whose dataUpdatedAt is older than the resource’s expected change interval.
Prefetches inflate the denominator. prefetchQuery populates the cache without creating an observer, so it produces a fetch with no activation. That is a miss with no matching read and will drag the rate down unfairly. Either exclude fetches with zero observers or count a prefetch as an activation of its own.
Suspense queries activate differently. useSuspenseQuery suspends on a miss, so the component tree unmounts and remounts, producing a second observerAdded for the same read. Deduplicate activations by hash within a short window if you use suspense widely.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Reported hit rate is far below what DevTools suggests | Deduplicated observers were counted as misses | Only count a miss when fetchStatus is idle at the fetch action |
| Metric backend rejects the payload for cardinality | The query hash was used as a label | Bucket on a closed allowlist of key namespaces |
| A regression is invisible in the dashboard | Counters are cumulative, so the average absorbs the step | Clear counters after each flush and report deltas |
| The alert fires nightly | Low-traffic windows produce extreme ratios | Add a minimum-activations floor |
| Hit rate is perfect but users report stale data | staleTime exceeds the resource’s real change rate |
Add a staleness-age signal alongside the rate |
Frequently Asked Questions
Why not measure hit rate from the network panel?
Because the network panel has no denominator. It records what missed, and a cache serving a thousand reads from one cached response looks identical to one that served a single read — both show one request. Worse, the number moves with traffic, so a quiet Sunday looks like an improvement. The whole value of instrumenting at the cache is that observerAdded gives you the reads that did not produce a request, which is the population you are trying to grow.
What is a good hit rate?
There is no universal target, and chasing a number is how teams end up with a staleTime of Infinity and a support queue full of stale-data reports. A rate of 0.6 on a resource that changes every few seconds may be exactly right; a rate of 0.98 on the same resource means you are serving data that is wrong. What is actionable is the trend per feature: a step change between adjacent windows is a regression with a specific cause, and it is visible regardless of what the absolute level happens to be.
How do I keep query keys from exploding metric cardinality?
Never label a metric with the query hash, and never label it with anything derived from a key element that can contain an entity id. Use a closed allowlist of key namespaces — the same list you would use for a persistence allowlist — and bucket everything else as other. This bounds the series count at the number of features you have, which is a number that changes on your schedule rather than your users’. If you need per-entity detail for an investigation, that is a job for the lifecycle log, not for a metric.
Related
- Cache Observability & Debugging — the parent guide covering the four points where a cache reports on itself.
- Logging Query Lifecycle Events for Debugging — the sibling technique that keeps the detail this metric deliberately discards.
- Inspecting Cache State With React Query DevTools — reading locally what this instrumentation measures across a fleet.
- Cache Key Serialization & Hashing — the key discipline that makes both the metric and the cache work.