Hashing Query Keys Deterministically

The query key you write is an array; the address the cache uses is a string. Everything surprising about cache identity happens in the function between them, and almost nobody has read it. This page reproduces that function, turns its behaviour into tests you can own, and covers the one situation where replacing it is justified. It sits under Cache Key Serialization & Hashing and pairs with Avoiding Query Key Collisions Across Features, which covers the namespace half of the same problem, and Serializing Dates and Sets in Query Keys, which covers the values this function handles badly.

Prerequisites

  • TanStack Query v5, whose exported hashKey is the function described here.
  • A key factory, or at least one module where keys are constructed.
  • A test runner — the assertions in Step 2 need nothing else.
The two jobs a query key does The key array feeds two independent paths. On the storage path it is serialized with a sorting replacer into a string used as the cache Map key. On the matching path the array itself is compared element by element for prefix matching during invalidation. The hash is never used for matching. The array is the identity; the string is only an address queryKey array ['todos','list',{…}] storage path JSON.stringify with a sorting replacer '["todos","list",{"a":1}]' used as the cache Map key object property order normalized matching path element-by-element prefix compare ['todos'] matches ['todos','list',…] used by invalidateQueries never touches the hash string
Replacing the hash function changes the left path only. That is why a digest breaks storage sharing but not invalidation.

Step 1 — Reproduce the Default Hash

Having the algorithm in your own test file removes the guesswork about which keys collide.

/** Equivalent to TanStack Query v5's exported hashKey. */
export function hashKey(queryKey: readonly unknown[]): string {
  return JSON.stringify(queryKey, (_key, value) =>
    isPlainObject(value)
      ? Object.keys(value)
          .sort()
          .reduce<Record<string, unknown>>((acc, prop) => {
            acc[prop] = (value as Record<string, unknown>)[prop];
            return acc;
          }, {})
      : value,
  );
}

/** Only objects with a plain prototype are sorted — Date, Map, Set are not. */
function isPlainObject(value: unknown): value is Record<string, unknown> {
  if (Object.prototype.toString.call(value) !== '[object Object]') return false;
  const prototype = Object.getPrototypeOf(value);
  return prototype === null || prototype === Object.prototype;
}

Cache Behavior Analysis: The replacer visits every value during serialization and rewrites plain objects into a new object whose properties were inserted in sorted order, which JSON.stringify then emits in that order — that is the entire mechanism by which { a: 1, b: 2 } and { b: 2, a: 1 } become one cache entry. Arrays are untouched, so their order remains significant, and anything that is not a plain object falls through to JSON.stringify’s own rules: a Date becomes an ISO string, a Set becomes {}, a BigInt throws. The prototype check is what makes a class instance non-plain, which is why an ORM model in a key serializes only its own enumerable fields and silently drops its getters.


Step 2 — Assert Equivalence and Distinctness

Two assertions cover the two ways a key contract can break. Both belong beside the key factory.

import { describe, expect, test } from 'vitest';
import { todoKeys } from './keys';

describe('todo key contract', () => {
  test('logically equal keys share one address', () => {
    const a = todoKeys.list({ status: 'active', assignee: 'u1' });
    const b = todoKeys.list({ assignee: 'u1', status: 'active' });
    // Property order must not create a second cache entry.
    expect(hashKey(a)).toBe(hashKey(b));
  });

  test('an omitted filter and an explicit undefined are the same address', () => {
    expect(hashKey(todoKeys.list({ status: 'active' })))
      .toBe(hashKey(todoKeys.list({ status: 'active', assignee: undefined })));
  });

  test('logically different keys never collide', () => {
    const keys = [
      todoKeys.all,
      todoKeys.lists(),
      todoKeys.list({ status: 'active' }),
      todoKeys.list({ status: 'done' }),
      todoKeys.detail('t1'),
      todoKeys.detail('t2'),
    ].map(hashKey);
    expect(new Set(keys).size).toBe(keys.length);
  });
});

Cache Behavior Analysis: The distinctness test is the one people skip and the one that catches real bugs — a factory that accidentally drops a parameter produces two logically different keys with one address, so two screens silently share an entry and each sees the other’s data. The undefined assertion checks a genuine asymmetry in JSON.stringify: an undefined property is omitted from the output, so it collides with the key that never had the property, while an undefined array element serializes as null and does not. Running these in CI costs milliseconds and turns a class of slow-burning cache leak into a red build.

What the default hash does with each kind of value Matrix of seven JavaScript value types showing what the default hash produces and whether the result is safe in a query key. What the default hash does with each kind of value Serialized as Safe in a key? string, number, boolean, null Themselves Yes Plain object Properties sorted, then JSON Yes — order is normalized for you Array JSON, order preserved Yes, if you control the order Date ISO string with millisecond precision No — a new address per construction Set or Map The empty object {} No — every value collapses to one address Class instance Own enumerable fields only No — getters and prototype data are lost BigInt Throws a TypeError No — crashes inside the library
Only the first three rows are safe without encoding. Everything below the line needs an explicit conversion before it reaches a key.

Step 3 — Check the Prefix Relationships

A factory’s value is that detail(id) is nested under all. That property is about the array, not the hash, and deserves its own test.

import { partialMatchKey } from '@tanstack/react-query';

test('every accessor stays under the root prefix', () => {
  const accessors = [
    todoKeys.lists(),
    todoKeys.list({ status: 'active' }),
    todoKeys.details(),
    todoKeys.detail('t1'),
  ];
  // partialMatchKey is exactly what invalidateQueries uses for fuzzy matching.
  for (const key of accessors) {
    expect(partialMatchKey(key, todoKeys.all)).toBe(true);
  }
});

test('invalidating lists does not reach details', () => {
  expect(partialMatchKey(todoKeys.list({ status: 'active' }), todoKeys.lists())).toBe(true);
  expect(partialMatchKey(todoKeys.detail('t1'), todoKeys.lists())).toBe(false);
});

Cache Behavior Analysis: partialMatchKey compares the filter array against the beginning of the query key element by element, using a deep equality that treats objects as partial matches — so ['todos','list'] matches ['todos','list',{status:'active'}] while ['todos','detail','t1'] does not. Because this comparison never consults the hash, a custom queryKeyHashFn leaves invalidation entirely intact, which is the single most useful fact when deciding whether a custom hash is safe. Testing the negative case matters as much as the positive one: a factory refactor that flattens the hierarchy still passes the “everything is under the root” test while quietly making every list invalidation flush the details too.


Step 4 — Weigh a Custom Hash Function

queryKeyHashFn is supported, occasionally useful, and almost always the wrong tool. Know what you give up.

import { QueryClient, hashKey } from '@tanstack/react-query';

const client = new QueryClient({
  defaultOptions: {
    queries: {
      queryKeyHashFn: (key) => {
        // A legitimate use: strip a volatile element that must travel with the
        // key for the queryFn but must not create a new cache entry.
        const stripped = key.map((element) =>
          isPlainObject(element) ? omit(element as Record<string, unknown>, ['_requestedAt']) : element,
        );
        // Delegate to the default so the sorting guarantee is preserved.
        return hashKey(stripped);
      },
    },
  },
});

function omit(source: Record<string, unknown>, keys: string[]) {
  return Object.fromEntries(Object.entries(source).filter(([k]) => !keys.includes(k)));
}

Cache Behavior Analysis: Delegating to the exported hashKey after transforming the array keeps the sorting behaviour, which a hand-rolled JSON.stringify would silently discard — that omission is the most common way a custom hash function introduces order-sensitive keys. Because prefix matching operates on the untransformed array, an element stripped from the hash is still visible to invalidateQueries, so an invalidation naming it will match nothing; strip only elements you never invalidate on. Replacing the hash with a digest such as SHA-256 works for storage and is almost never worth it: it makes the DevTools listing unreadable, it makes cache dumps impossible to correlate with code, and it buys only a shorter Map key.

Distinct cache entries for one screen's key set Bar chart of distinct cache entries created. The default hash creates 4. A custom hash without sorting creates 8. A digest hash creates 4 but makes DevTools entries unreadable. A hash that strips a volatile field creates 4. Distinct cache entries for one screen's key set Four filter combinations, each rendered twice with properties in different order default hashKey 4 entries custom hash, no sorting 8 entries SHA-256 digest 4 entries default hash + field stripped 4 entries
Dropping the sort is the trap: eight entries where there should be four, with no error and a hit rate that halves.

Edge Cases & Gotchas

Nested objects are sorted too. The replacer visits every level, so { filters: { b: 1, a: 2 } } normalizes at both levels. That is usually what you want, and it means adding a property deep inside a params object changes every hash containing it.

undefined behaves differently in arrays and objects. As an object property it is omitted; as an array element it becomes null. A trailing undefined in a key array therefore produces a distinct address rather than collapsing.

SSR and browser must agree. The same key must hash identically in Node and in the browser, which it does — unless a value’s serialization is environment-dependent, such as a locale-formatted string. See Avoiding Hydration Mismatch Errors.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
Two components with the same filters do not share an entry A custom hash function dropped the property sort Delegate to the exported hashKey after transforming
Two different screens read each other’s data A factory dropped a parameter, so two keys collide Add the distinctness assertion from Step 2
Invalidating a prefix stopped matching after a refactor The factory no longer nests keys under the root Assert with partialMatchKey in both directions
A key crashes the library A BigInt reached JSON.stringify Encode it as a string at the factory boundary
DevTools entries became unreadable The hash was replaced with a digest Revert to a readable serialization; shorten segments instead

Frequently Asked Questions

Is the query hash used for matching, or only for storage?

Only for storage. The hash string is the key of the Map that holds cache entries, and nothing else consults it. Fuzzy matching — everything invalidateQueries, removeQueries, cancelQueries and refetchQueries do with a partial key — compares the query key array element by element against the filter you supplied. That separation is why hierarchy works at all, and it is the reason a custom hash function is less dangerous than it sounds: it changes which keys share an address, not which keys an invalidation reaches.

What breaks if I replace queryKeyHashFn?

You inherit responsibility for the sorting guarantee. The default sorts plain-object properties before serializing, so two objects with the same contents in a different order address one entry; a hand-rolled JSON.stringify does not, and you get two entries with no warning and a hit rate that quietly halves. Prefix matching is unaffected, and so is everything about freshness and garbage collection. If you need a custom hash, transform the array and then delegate to the exported hashKey rather than serializing yourself.

Do I need to test hashing if I use a key factory?

Yes — and the factory is what makes it cheap. Because every key in the application is built in one module, a dozen assertions cover the entire key surface, and they run in milliseconds. Without them, the failure mode is not a crash but a slow leak: a factory change that makes two keys collide, or that splits one key into many, produces no error at all and shows up weeks later as a memory report or a “why is this screen showing the wrong data” ticket. The distinctness assertion in particular has no other line of defence.