Avoiding Query Key Collisions Across Features

['list'] is a perfectly reasonable query key right up until a second team writes one. Then two unrelated features address the same cache entry, each overwriting the other’s data on every fetch, and every invalidateQueries({ queryKey: ['list'] }) refetches both — with no warning, no type error, and no way to tell from either feature’s code that the other exists. This page makes that impossible, under Cache Key Serialization & Hashing. The serialization rules behind it are in Hashing Query Keys Deterministically, and the hierarchy this namespace sits at the top of is covered in Building a Type-Safe Query Key Factory.

Diagnostic Checklist

Prerequisites

Collision and isolation Above: a billing feature and a todo feature both use the key list, so both address one cache entry and each overwrites the other. Below: the same two features prefix their keys with billing and todos, addressing two separate entries that no invalidation can cross between. One element decides whether these are one entry or two billing feature ['list', page] todo feature ['list', page] ONE cache entry last fetch wins, always invalidate(['list']) refetches both features billing feature ['billing','list',page] todo feature ['todos','list',page] billing entry todos entry invalidate(['billing']) cannot reach todos
The namespace is also an invalidation boundary: with it in place, one feature's blast radius provably stops at the other's edge.

Step 1 — Reserve the First Element for a Namespace

The rule is one line and mechanical: element zero names a feature, never an operation.

export const NAMESPACES = ['billing', 'todos', 'boards', 'users', 'activity'] as const;
export type Namespace = (typeof NAMESPACES)[number];

/** Every key in the application starts here. */
export function root<N extends Namespace>(namespace: N) {
  return [namespace] as const;
}

export const billingKeys = {
  all: root('billing'),
  invoices: () => [...root('billing'), 'invoice'] as const,
  invoice: (id: string) => [...root('billing'), 'invoice', id] as const,
  // Note the operation word is element ONE, never element zero.
  list: (page: number) => [...root('billing'), 'invoice', 'list', page] as const,
};

export const todoKeys = {
  all: root('todos'),
  lists: () => [...root('todos'), 'list'] as const,
  list: (page: number) => [...root('todos'), 'list', page] as const,
};

Cache Behavior Analysis: Because fuzzy matching compares key arrays from the front, a namespace at element zero makes cross-feature matching impossible by construction: invalidateQueries({ queryKey: ['billing'] }) cannot reach a key beginning with 'todos' regardless of how similar the rest of the key looks. The cost is a few characters in the serialized hash, which affects nothing except the readability of a DevTools listing — and arguably improves that, since every entry now says which feature owns it. Typing the namespace as a union rather than a string is what turns a misspelling into a compile error instead of a silent second namespace.


Step 2 — Register Every Root in One File

A registry makes the whole key surface visible and makes a duplicate root a type error rather than a runtime coincidence.

interface KeyModule { all: readonly [Namespace] }

/** Every feature's key module, in one place. A duplicate root fails to compile. */
export const keyRegistry = {
  billing: billingKeys,
  todos: todoKeys,
  boards: boardKeys,
  users: userKeys,
  activity: activityKeys,
} satisfies Record<Namespace, KeyModule>;

/** A runtime assertion for the case the type system cannot see: two modules
 *  both claiming the same namespace through a dynamic import. */
export function assertUniqueRoots() {
  const roots = Object.values(keyRegistry).map((module) => module.all[0]);
  const duplicates = roots.filter((value, index) => roots.indexOf(value) !== index);
  if (duplicates.length > 0) {
    throw new Error(`Duplicate query key namespaces: ${[...new Set(duplicates)].join(', ')}`);
  }
}

Cache Behavior Analysis: satisfies Record<Namespace, KeyModule> checks that every namespace in the union has exactly one module and that every module conforms, while preserving each module’s precise type so autocomplete on billingKeys.invoice still works — a plain type annotation would widen them all to KeyModule and lose that. The runtime assertion covers lazily loaded feature modules, which the compiler cannot see together. Keeping the registry in one file also gives reviewers a single diff to look at when a new feature is added, which is the moment a collision is cheapest to prevent.

Cache entries a single invalidation reaches Bar chart of entries invalidated. Invalidating the bare key list reaches 22 entries across both features. Invalidating billing reaches 11. Invalidating billing invoice reaches 7. Invalidating a single invoice reaches 1. Cache entries a single invalidation reaches Cache warmed by two features, 34 entries total ['list'] — no namespace 22 entries ['billing'] 11 entries ['billing','invoice'] 7 entries ['billing','invoice',id] 1 entries
Without a namespace the smallest available blast radius is both features. With one, every level of the hierarchy is addressable.

Step 3 — Detect Collisions in a Running Cache

An existing application needs to find the collisions it already has before it can prevent new ones.

import type { QueryClient } from '@tanstack/react-query';
import { NAMESPACES } from './keys';

export function auditKeyRoots(client: QueryClient) {
  const suspicious: Array<{ root: string; hashes: string[] }> = [];
  const byRoot = new Map<string, Set<string>>();

  for (const query of client.getQueryCache().getAll()) {
    const first = String((query.queryKey as unknown[])[0] ?? '');
    (byRoot.get(first) ?? byRoot.set(first, new Set()).get(first)!).add(query.queryHash);
  }

  for (const [rootKey, hashes] of byRoot) {
    // A root outside the declared union is either a collision waiting to
    // happen or a key someone built inline.
    if (!(NAMESPACES as readonly string[]).includes(rootKey)) {
      suspicious.push({ root: rootKey, hashes: [...hashes].slice(0, 5) });
    }
  }

  return suspicious;
}

Cache Behavior Analysis: Auditing the live cache rather than the source finds keys built inline at call sites, which a static search for the factory would miss entirely — and inline keys are exactly the population most likely to use a bare noun. Grouping by element zero and comparing against the declared union turns the audit into a list of roots nobody owns, which is a short and actionable output even on a large application. Run it after exercising the application rather than at startup: a key that is never constructed cannot appear in the cache, so the audit’s coverage is exactly the coverage of whatever you clicked through.


Step 4 — Enforce It With a Lint Rule

A convention that is not enforced is a convention that decays. A small ESLint rule catches the inline key at review time.

// eslint-plugin-local/rules/namespaced-query-keys.js
const NAMESPACES = ['billing', 'todos', 'boards', 'users', 'activity'];

module.exports = {
  meta: { type: 'problem', docs: { description: 'query keys must start with a declared namespace' } },
  create(context) {
    return {
      // Matches: useQuery({ queryKey: [...] }) and the filter forms.
      Property(node) {
        if (node.key.name !== 'queryKey' || node.value.type !== 'ArrayExpression') return;
        const first = node.value.elements[0];
        if (!first || first.type !== 'Literal' || !NAMESPACES.includes(first.value)) {
          context.report({
            node: first ?? node,
            message:
              'Query keys must start with a declared namespace. Build the key with its feature key factory instead of inline.',
          });
        }
      },
    };
  },
};

Cache Behavior Analysis: Matching on the literal first element deliberately fails any inline array whose head is a variable or a spread, which pushes people toward the factory — the rule is as much about centralising construction as about the namespace itself. It cannot see keys built by a helper it does not know about, so it is a backstop rather than a proof; the runtime audit in Step 3 covers what it misses. Keeping the namespace list in the rule and in the type union means they can drift, so generate one from the other, or import the union at lint time if your setup allows it.

Four layers, and what each one catches Matrix of four defences against key collisions, showing what each catches and what it misses. Four layers, and what each one catches Catches Misses Typed namespace union Misspelled namespaces at compile time Keys built inline without the factory Central key registry Two modules claiming one namespace Keys that bypass the registry entirely Runtime cache audit Inline keys, in whatever was exercised Code paths nobody clicked through Lint rule Inline keys at review time Keys built by an unrecognised helper
No single layer is sufficient. The type union and the lint rule are cheap; run the audit once on an existing codebase.

Edge Cases & Gotchas

Third-party keys. A library that registers its own queries in your client may use anything as element zero. Reserve those roots in your union with a comment rather than pretending they do not exist.

Mutation keys collide too. mutationKey shares the same namespace hazard, and isMutating({ mutationKey }) matches by prefix in the same way. Apply the same rule to both.

Migration is a two-step. Renaming a root invalidates every persisted entry under the old name. Bump the persistence buster at the same time, as in Busting a Persisted Cache on Deploy, or old snapshots will hydrate into keys nothing reads.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
Two features show each other’s data Both keys begin with the same bare noun Prefix with a feature namespace at element zero
An invalidation refetches unrelated screens The invalidation prefix is shared by two features Narrow the prefix; the namespace makes this possible
A namespace typo creates a silent second cache Namespaces are free-form strings Type them as a closed union
The lint rule passes but a collision exists The key was built by a helper the rule does not match Run the runtime cache audit
Persisted data disappears after a rename Old snapshots hydrate under keys nothing reads Bump the persistence buster with the rename

Frequently Asked Questions

How does a collision actually manifest?

In two ways, both silent. The first is data crossover: two features address one entry, so whichever fetched most recently is what both render — which looks like a rendering bug, appears intermittently, and never reproduces in isolation because it needs both features mounted. The second is invalidation crossover: invalidateQueries({ queryKey: ['list'] }) in one feature refetches the other’s queries too, which looks like unexplained network traffic. Neither produces a warning, because two keys serializing to the same string is indistinguishable from one key used twice, which is a completely normal thing to do.

Is a namespace worth an extra element on every key?

Yes, comfortably. The cost is a few characters in the serialized hash — no measurable memory, no measurable CPU, and a DevTools listing that is arguably easier to read because every entry now announces its owner. What you get is structural isolation: two features cannot address one entry, and an invalidation in one provably cannot reach the other. That second property is worth the element on its own, because it turns “how wide is this invalidation?” from a question requiring knowledge of the whole codebase into one answerable from the key alone.

What about a monorepo where packages do not share a keys module?

Publish the namespace union from a small shared package that every consumer already depends on — it is a handful of string literals and a type, so the dependency is trivial and the coupling is exactly the coupling that already exists by virtue of sharing a cache. If two packages genuinely cannot depend on a common module, that is a strong signal they should not be sharing a QueryClient either: give each its own client and connect them with an explicit invalidation bus, which makes the boundary visible instead of accidental.