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 QueryClient you 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.
What counts as a hit and what counts as a miss One hundred query activations split into three groups: sixty-one served from a fresh cache entry, twenty-two joining a request already in flight, and seventeen starting a new network request. Only the last group counts as a miss; the middle group must be excluded or the rate is understated. 100 activations — but only 17 of them cost a request observerAdded — a component started reading a key 61 — fresh entry data returned immediately no request at all counts as a hit 22 — deduplicated joined a request already in flight for this key must not count as a miss 17 — new request fetchStatus was idle a real network round trip the only true miss hit rate = 1 − 17/100 = 0.83 Counting the deduplicated group as misses would report 0.61 — a 22-point error on the same cache.
Deduplicated joins are the easiest group to miscount, and miscounting them makes a healthy cache look broken.

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.

Hit rate before and after a key-stability regression Line chart across eight hourly buckets. Boards holds between 0.86 and 0.88 throughout. Search drops from 0.74 to 0.31 at hour four and stays there. Users holds at 0.95. Hit rate before and after a key-stability regression users boards search 0 0.3 0.5 0.8 1 hit rate 0 1 2 3 4 5 6 7 hours after deploy
The regression is a step, not a slope. Alerting on a percentage-point drop between adjacent windows catches it within one flush interval.

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.

What each metric can and cannot tell you Matrix comparing request count, hit rate, fetches per activation and deduplication factor across what each detects and what each misses. What each metric can and cannot tell you Detects Blind to Raw request count Absolute load on your API Whether the cache is helping at all Hit rate How often reads avoid the network Whether the data served was correct Fetches per activation staleTime, key and trigger regressions Correctness; a frozen cache scores perfectly Deduplication factor Keys that used to be shared and diverged Everything about freshness
Ship fetches-per-activation first. It is the only one of the four that is both traffic-invariant and sensitive to all three common regressions.

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.