Cache Key Serialization & Hashing
A query key is not stored as an array. Every server-state library reduces it to a string, and that string — not the array you wrote — is the address of the cache entry. Two keys that serialize to the same string are the same entry; two keys that serialize differently are two entries, however similar they look in source. Most “the cache is not sharing” and “the cache keeps growing” reports are a serialization story, which is why this sits alongside Query Key Factory Patterns inside Data Normalization & Query Key Design: a factory guarantees you construct keys consistently, and serialization determines what that consistency buys you.
This guide covers what the default hash functions actually do in TanStack Query v5, SWR v2 and Apollo Client v3, which JavaScript values survive that process unchanged, which ones quietly poison a key, and how to namespace keys so two teams working in the same codebase cannot collide.
Diagnostic Checklist
Prerequisites
You should already be comfortable with the identity rules in Designing Stable Query Keys for React Query, and with how prefix matching drives invalidation, covered in Invalidating Queries With a Key Factory. This guide goes one level below both: what happens between the array you pass and the string the cache uses.
Implementation 1 — Know What the Default Hash Does
TanStack Query’s hashKey is a JSON.stringify with a replacer that sorts object properties. Knowing the exact algorithm removes the guesswork about which of your keys collide and which do not.
- Reproduce the algorithm locally so you can test keys without mounting components.
- Assert that logically equal keys hash equally in a unit test.
- Assert that logically different keys hash differently — the direction people forget.
- Wire the assertions into the test suite that runs on every key-factory change.
// A faithful reimplementation of TanStack Query v5's default key hashing, for tests.
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,
);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (Object.prototype.toString.call(value) !== '[object Object]') return false;
const proto = Object.getPrototypeOf(value);
return proto === null || proto === Object.prototype;
}
// The two assertions worth owning for every factory you write.
export function assertKeyContract(
equivalent: Array<readonly unknown[]>,
distinct: Array<readonly unknown[]>,
) {
const hashes = equivalent.map(hashKey);
if (new Set(hashes).size !== 1) throw new Error(`keys should share one address: ${hashes.join(' | ')}`);
if (new Set(distinct.map(hashKey)).size !== distinct.length) {
throw new Error('distinct keys collided on one address');
}
}
Cache Behavior Analysis: The sorting replacer only applies to plain objects — objects whose prototype is Object.prototype or null. A Date, a Map, a Set or a class instance falls through to JSON.stringify’s default handling, which is why those types behave so differently from the objects you intend to put in keys. Because the hash is a readable serialization rather than a digest, invalidateQueries({ queryKey: ['todos'] }) can do prefix matching by comparing the array elements, not the string — the string exists to be a Map key, and the array remains the thing matching operates on.
Configuration Trade-offs:
- Testing key contracts costs nothing at runtime and catches the class of bug that is otherwise only visible as a slow memory leak.
- Overriding
queryKeyHashFnglobally is supported but removes the sorting guarantee unless you reimplement it — a common way to make keys order-sensitive by accident. - Sorted serialization means adding a property to a params object changes every hash that includes it, invalidating that whole family. That is correct behaviour, but it makes params objects a poor place for optional presentational flags.
Implementation 2 — Normalize Parameters Before They Reach the Key
The most reliable fix for key churn is to stop trusting the caller. A factory that normalizes its inputs makes an unstable key structurally impossible rather than merely discouraged.
- Accept a loose input type at the factory boundary.
- Strip
undefinedand empty values so an omitted filter and an explicitundefinedproduce the same address. - Coerce every value to a primitive whose serialization is a pure function of meaning.
- Return the tuple
as constso the compiler preserves the prefix relationship.
export interface TodoFilters {
status?: 'active' | 'done';
assignee?: string | null;
tags?: string[];
updatedSince?: Date;
}
/** Everything that goes into a key passes through here first. */
function normalizeFilters(filters: TodoFilters = {}) {
const normalized: Record<string, string | number | string[]> = {};
if (filters.status) normalized.status = filters.status;
// null and undefined both mean "no assignee filter" — collapse them to one address.
if (filters.assignee != null) normalized.assignee = filters.assignee;
// Arrays are positional under JSON.stringify, so sort to make order irrelevant.
if (filters.tags?.length) normalized.tags = [...filters.tags].sort();
// Round to the day: the caller means "recently", not "this millisecond".
if (filters.updatedSince) {
normalized.updatedSince = filters.updatedSince.toISOString().slice(0, 10);
}
return normalized;
}
export const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters?: TodoFilters) => [...todoKeys.lists(), normalizeFilters(filters)] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: string) => [...todoKeys.details(), id] as const,
};
Cache Behavior Analysis: Dropping empty values matters more than it looks: { status: 'active', assignee: undefined } serializes to {"status":"active"} because JSON.stringify omits undefined properties — but { status: 'active', assignee: null } serializes with the null intact, producing a second address for the same intent. Sorting the tags array is necessary because the sorting replacer applies only to objects; arrays keep their order, so ['a','b'] and ['b','a'] are genuinely different keys. Truncating the date to a day means a component that recomputes new Date() on every render still lands on one entry for the whole day, which is the behaviour the caller intended all along.
Configuration Trade-offs:
- Day-bucketing a timestamp trades precision for cache reuse. If the underlying resource changes hourly, bucket by hour and set
staleTimeto match. - Sorting
tagscosts an allocation per key construction. It is negligible next to the fetch it prevents, but do not sort in a render loop when auseMemowill do. - Collapsing
nullandundefinedis right for filters and wrong for fields wherenullis a meaningful value the server distinguishes. Decide per field, not globally.
Implementation 3 — Encode Values JSON Cannot Represent
Set, Map, BigInt and class instances all fail in different ways. Rather than banning them, encode them at the boundary into something with a stable serialization.
- Detect the unsupported types explicitly instead of relying on
JSON.stringifyto do something sensible. - Convert each to a canonical primitive form — sorted arrays for sets, sorted entry pairs for maps, strings for bigints.
- Fail loudly in development for types you have not handled, so a new one cannot slip in unnoticed.
- Apply the encoder inside the factory so no call site can bypass it.
export function encodeKeyValue(value: unknown): unknown {
if (value instanceof Date) return value.toISOString();
// A Set serializes to {} — every set becomes the same key. Sort for order-independence.
if (value instanceof Set) return [...value].map(String).sort();
// A Map also serializes to {}. Encode as sorted [key, value] pairs.
if (value instanceof Map) {
return [...value.entries()]
.map(([k, v]) => [String(k), encodeKeyValue(v)] as const)
.sort((a, b) => (a[0] < b[0] ? -1 : 1));
}
if (typeof value === 'bigint') return `${value}n`;
if (Array.isArray(value)) return value.map(encodeKeyValue);
if (value && typeof value === 'object') {
const proto = Object.getPrototypeOf(value);
if (proto !== null && proto !== Object.prototype) {
// A class instance serializes only its own enumerable fields, silently
// dropping getters and anything on the prototype.
if (process.env.NODE_ENV !== 'production') {
throw new Error(`Unsupported query key value: ${proto.constructor?.name ?? 'class instance'}`);
}
return String(value);
}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, encodeKeyValue(v)]),
);
}
return value;
}
Cache Behavior Analysis: JSON.stringify(new Set([1, 2])) returns "{}", so without this encoder every distinct set of filters collapses onto a single cache entry — a silent correctness bug, not a performance one, because two different queries then share one entry’s data. Map fails identically. BigInt is worse: JSON.stringify throws a TypeError, which surfaces as an opaque crash inside the library rather than at your call site. Throwing in development for class instances catches the case where an ORM model or a form-library object drifts into a key and takes its getters with it.
Configuration Trade-offs:
- Recursive encoding is O(size of the params object) per key construction. Keep params objects small — they are addresses, not payloads.
- Throwing in development and coercing in production keeps the failure loud where it is cheap and survivable where it is not.
String(value)as the production fallback is deliberately crude; it guarantees a deterministic address even for a type you forgot, at the cost of a possible collision between two instances with the sametoString.
Implementation 4 — Namespace Keys So Features Cannot Collide
A key beginning with a bare noun — ['list'], ['detail'], ['settings'] — is a collision waiting for the second team that has a list. Namespacing is the cheapest possible insurance.
- Reserve the first key element for a feature namespace, never a resource verb.
- Generate namespaces from a single union type so two features cannot claim the same string.
- Compose feature key factories into one root object so the collision surface is visible in one file.
- Add a development-time assertion that no two registered factories produce the same root.
export const NAMESPACES = ['boards', 'todos', 'users', 'activity', 'billing'] as const;
export type Namespace = (typeof NAMESPACES)[number];
function defineKeys<N extends Namespace, T extends Record<string, (...args: never[]) => readonly unknown[]>>(
namespace: N,
build: (root: readonly [N]) => T,
): T & { all: readonly [N] } {
const root = [namespace] as const;
return { ...build(root), all: root };
}
export const billingKeys = defineKeys('billing', (root) => ({
invoices: () => [...root, 'invoice'] as const,
invoice: (id: string) => [...root, 'invoice', id] as const,
}));
export const todoKeys2 = defineKeys('todos', (root) => ({
lists: () => [...root, 'list'] as const,
list: (filters: Record<string, unknown>) => [...root, 'list', filters] as const,
}));
// One place to see every root. A duplicate here is a compile error, not a support ticket.
export const keyRegistry = { billing: billingKeys, todos: todoKeys2 } satisfies Record<string, { all: readonly [Namespace] }>;
Cache Behavior Analysis: Because invalidateQueries matches on array prefix, a namespace as element zero means invalidateQueries({ queryKey: billingKeys.all }) can never reach a todos entry, regardless of how similar the rest of the key looks. Without a namespace, invalidateQueries({ queryKey: ['list'] }) flushes every list in the application — the failure mode is not a wrong value but a thundering herd of refetches on unrelated screens. The satisfies clause keeps the registry type-checked without widening the individual factory types, so autocomplete on billingKeys.invoice still works.
Configuration Trade-offs:
- A namespace adds one element to every key, which lengthens the hash string slightly and buys total isolation between features. Take the trade.
- A closed
NAMESPACESunion means adding a feature requires touching a shared file — a feature, not a bug, because that file is where collisions become visible. - Deep namespaces (
['billing','invoices','list',…]) give finer invalidation granularity at the cost of longer prefixes to remember. Two levels is usually the sweet spot.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Two features overwrite each other’s cached data | Both use a bare verb such as ['list'] as element zero, so their keys serialize identically |
Prefix every key with a feature namespace and register roots in one file, as in Implementation 4 |
| A filter set silently returns another filter set’s data | The filters live in a Set or Map, which JSON.stringify renders as {} |
Run every key value through encodeKeyValue; add a test asserting two different filter sets hash differently |
| Cache grows one entry per render | A Date, inline object literal or array is rebuilt every render and embedded in the key |
Normalize inside the factory rather than memoizing at the call site — see Serializing Dates and Sets in Query Keys |
| Server and client fetch the same data immediately after hydration | The server built the key with a value that serializes differently in Node than in the browser, commonly a locale-formatted date | Serialize dates as ISO strings on both sides; see Avoiding Hydration Mismatch Errors |
| Invalidating a prefix flushes unrelated queries | The prefix is shorter than intended, or a namespace is missing | Log the matched hashes before invalidating; narrow the prefix or introduce the namespace |
Frequently Asked Questions
Does the order of properties inside a query key object matter?
Not in TanStack Query. Its default hash function sorts object properties before serializing, so { a: 1, b: 2 } and { b: 2, a: 1 } produce the same hash and address one entry. Array order does matter, because arrays are positional and the sorting replacer does not touch them — ['a','b'] and ['b','a'] are different keys. SWR applies no sorting at all, which is the main reason string keys remain conventional there. If you replace queryKeyHashFn with your own implementation, you take on responsibility for the sorting behaviour yourself.
Why does including a Date in a query key create a new cache entry on every render?
JSON.stringify serializes a Date through its toJSON method, producing an ISO-8601 string with millisecond precision. new Date() evaluated during render therefore yields a different string on every pass, and each string is a different cache address. The fix is not to memoize the Date — a remount recreates it — but to reduce it to the precision you actually mean before it enters the key: toISOString().slice(0, 10) for a day, slice(0, 13) for an hour. Do the truncation inside the key factory so no call site can forget.
Can two different query keys hash to the same string?
Yes, and it is a real source of bugs. The hash is a deterministic serialization, not a cryptographic digest, so any two keys that serialize identically are the same address. The classic collisions are a trailing undefined element versus an omitted element, null versus undefined inside a params object, and two features that both start their keys with a bare noun. The assertKeyContract helper in Implementation 1 exists precisely to make these collisions fail in CI rather than in production.
Should I hash query keys myself for shorter cache addresses?
Almost never. Prefix matching is what allows invalidateQueries({ queryKey: ['todos','list'] }) to flush a whole branch of related queries, and it works by comparing key array elements. Replacing the readable serialization with a digest such as SHA-256 makes every key opaque and reduces invalidation to exact matches, which then forces you to track key lists by hand. If key length genuinely matters — for example because you persist the cache and storage quota is tight — shorten the individual segments instead, or store a mapping table alongside the persisted payload.
Related
- Data Normalization & Query Key Design — the parent section covering entity identity, key stability and cache topology.
- Query Key Factory Patterns — the companion guide on structuring keys into a hierarchy, which this guide gives the serialization rules for.
- Hashing Query Keys Deterministically — a full walkthrough of the default hash function and the tests worth writing against it.
- Avoiding Query Key Collisions Across Features — namespacing, a registry, and the compile-time checks that keep two teams apart.
- Serializing Dates and Sets in Query Keys — the encoder for values JSON cannot faithfully represent.