Skip to content

Comments

perf(query-core): optimize find/findAll lookups in QueryCache and MutationCache#10174

Open
976520 wants to merge 5 commits intoTanStack:mainfrom
976520:perf/cache-lookup-optimization
Open

perf(query-core): optimize find/findAll lookups in QueryCache and MutationCache#10174
976520 wants to merge 5 commits intoTanStack:mainfrom
976520:perf/cache-lookup-optimization

Conversation

@976520
Copy link
Contributor

@976520 976520 commented Feb 23, 2026

🎯 Changes

  • Remove unnecessary array allocations:
    In QueryCache and MutationCache methods such as find, findAll, onFocus, and onOnline, we removed the intermediate arrays that were being created on every call via getAll(), and instead iterated the internal collections directly

  • When using the filter combination exact: true + queryKey/mutationKey, we now bypass full-cache scans and perform direct hash map lookups

    • QueryCache: since #queries is already a hash → Query map, we directly access it via hashKey(queryKey)
    • MutationCache: we maintain a separate index map #byMutationKey and keep it synchronized on add / remove / clear

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm run test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Refactor
    • Optimized cache lookups for exact key-based query and mutation lookups, significantly reducing lookup time for exact matches.
    • Improved resume behavior for paused mutations to operate on internal collections for faster processing.
    • Faster handling of focus/online events by iterating internal stores directly for better responsiveness.

@changeset-bot
Copy link

changeset-bot bot commented Feb 23, 2026

🦋 Changeset detected

Latest commit: b018ef1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@tanstack/query-core Patch
@tanstack/angular-query-experimental Patch
@tanstack/preact-query Patch
@tanstack/query-async-storage-persister Patch
@tanstack/query-broadcast-client-experimental Patch
@tanstack/query-persist-client-core Patch
@tanstack/query-sync-storage-persister Patch
@tanstack/react-query Patch
@tanstack/solid-query Patch
@tanstack/svelte-query Patch
@tanstack/vue-query Patch
@tanstack/angular-query-persist-client Patch
@tanstack/react-query-persist-client Patch
@tanstack/solid-query-persist-client Patch
@tanstack/svelte-query-persist-client Patch
@tanstack/react-query-devtools Patch
@tanstack/react-query-next-experimental Patch
@tanstack/solid-query-devtools Patch
@tanstack/svelte-query-devtools Patch
@tanstack/vue-query-devtools Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 23, 2026

📝 Walkthrough

Walkthrough

Introduces O(1) key-based lookup optimizations by adding index maps to MutationCache and QueryCache, updating add/remove/clear paths to maintain indexes, and changing find/findAll and some iteration paths to use indexed access when filters.exact and a key are provided.

Changes

Cohort / File(s) Summary
Changeset Documentation
\.changeset/perf-cache-lookup-optimization.md
Adds a patch changeset documenting cache lookup performance optimizations for @tanstack/query-core.
MutationCache index & lookup
packages/query-core/src/mutationCache.ts
Adds private #byMutationKey: Map<string, Mutation[]>, updates constructor, add, remove, clear to maintain the index; optimizes find/findAll to use the index for exact mutationKey lookups; resumePausedMutations iterates internal collection directly.
QueryCache lookup optimization
packages/query-core/src/queryCache.ts
Introduces hashKey-based direct lookup for exact key queries in find/findAll, iterates #queries.values() for non-exact flows and updates onFocus/onOnline iteration paths to use internal store.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • TkDodo

Poem

🐰 I dug through caches, found the trace,
Built little maps to speed the race,
Keys now hop straight to where they hide,
No loops to wear my fluffy stride,
Quick returns — a joyful bounce inside! 🚀

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically summarizes the main optimization: O(1) lookups in find/findAll methods for QueryCache and MutationCache using hash-based access.
Description check ✅ Passed The PR description fully follows the template structure, clearly explains the changes, includes issue references, completes the checklist, and indicates a changeset was generated.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nx-cloud
Copy link

nx-cloud bot commented Feb 23, 2026

View your CI Pipeline Execution ↗ for commit b018ef1

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 3m 41s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 2s View ↗

☁️ Nx Cloud last updated this comment at 2026-02-23 15:57:59 UTC

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 23, 2026

More templates

@tanstack/angular-query-experimental

npm i https://pkg.pr.new/@tanstack/angular-query-experimental@10174

@tanstack/eslint-plugin-query

npm i https://pkg.pr.new/@tanstack/eslint-plugin-query@10174

@tanstack/preact-query

npm i https://pkg.pr.new/@tanstack/preact-query@10174

@tanstack/query-async-storage-persister

npm i https://pkg.pr.new/@tanstack/query-async-storage-persister@10174

@tanstack/query-broadcast-client-experimental

npm i https://pkg.pr.new/@tanstack/query-broadcast-client-experimental@10174

@tanstack/query-core

npm i https://pkg.pr.new/@tanstack/query-core@10174

@tanstack/query-devtools

npm i https://pkg.pr.new/@tanstack/query-devtools@10174

@tanstack/query-persist-client-core

npm i https://pkg.pr.new/@tanstack/query-persist-client-core@10174

@tanstack/query-sync-storage-persister

npm i https://pkg.pr.new/@tanstack/query-sync-storage-persister@10174

@tanstack/react-query

npm i https://pkg.pr.new/@tanstack/react-query@10174

@tanstack/react-query-devtools

npm i https://pkg.pr.new/@tanstack/react-query-devtools@10174

@tanstack/react-query-next-experimental

npm i https://pkg.pr.new/@tanstack/react-query-next-experimental@10174

@tanstack/react-query-persist-client

npm i https://pkg.pr.new/@tanstack/react-query-persist-client@10174

@tanstack/solid-query

npm i https://pkg.pr.new/@tanstack/solid-query@10174

@tanstack/solid-query-devtools

npm i https://pkg.pr.new/@tanstack/solid-query-devtools@10174

@tanstack/solid-query-persist-client

npm i https://pkg.pr.new/@tanstack/solid-query-persist-client@10174

@tanstack/svelte-query

npm i https://pkg.pr.new/@tanstack/svelte-query@10174

@tanstack/svelte-query-devtools

npm i https://pkg.pr.new/@tanstack/svelte-query-devtools@10174

@tanstack/svelte-query-persist-client

npm i https://pkg.pr.new/@tanstack/svelte-query-persist-client@10174

@tanstack/vue-query

npm i https://pkg.pr.new/@tanstack/vue-query@10174

@tanstack/vue-query-devtools

npm i https://pkg.pr.new/@tanstack/vue-query-devtools@10174

commit: b018ef1

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
packages/query-core/src/queryCache.ts (3)

215-223: findAll exact-path returns [] on filter mismatch — correct but asymmetric with find.

When the fast-path candidate exists but fails the remaining filters (e.g., type, stale, predicate), findAll returns [] immediately rather than falling through to scan. This is correct (at most one query can match a given exact key), but the behavior is subtly different from the find method where the candidate-not-found case falls through to the linear scan while candidate-found-but-mismatch does not. A brief comment would help future readers understand the early return is intentional.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/query-core/src/queryCache.ts` around lines 215 - 223, The findAll
fast-path in QueryCache (inside findAll) early-returns [] when a candidate is
found via hashKey(filters.queryKey) but fails matchQuery(filtersWithoutKey,
candidate); add a brief comment above this block clarifying that this early
return is intentional because at most one query can match an exact queryKey, so
a mismatched candidate means no other query can match and we should not fall
through to the linear scan (contrast with find which only falls through when the
candidate is missing). Mention the involved symbols: findAll, find, `#queries`,
hashKey, matchQuery, and filters.queryKey to help future readers locate the
logic.

211-213: Minor: Object.keys(filters).length === 0 allocates an array to check emptiness.

For a perf-focused PR, you could avoid the temporary Object.keys() array. However, this is only called once per findAll({}) invocation and the object is typically tiny, so the impact is negligible.

Alternative without allocation

A zero-allocation check using a for...in bail-out pattern, though arguably less readable:

-    if (Object.keys(filters).length === 0) {
+    let hasFilters = false
+    for (const _ in filters) { hasFilters = true; break }
+    if (!hasFilters) {
       return [...this.#queries.values()]
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/query-core/src/queryCache.ts` around lines 211 - 213, The current
emptiness check uses Object.keys(filters).length === 0 which allocates an array;
update the check inside the findAll (or the method containing filters) to avoid
allocation by using a zero-allocation pattern (e.g., bail out with a for...in
that returns early if any enumerable property exists) so that when filters is
empty you still return [...this.#queries.values()] without creating the
temporary keys array; locate the check referencing filters and replace the
Object.keys(...) length test with the non-allocating for...in emptiness test.

188-207: Fast path optimization silently degrades to O(n) when custom queryKeyHashFn is used.

Queries are stored under query.queryHash, which is computed as options.queryHash ?? hashQueryKeyByOptions(queryKey, options). The fast path on line 191 attempts a direct lookup using hashKey(queryKey), which will fail when a custom hash function is configured in options, causing the code to fall through to the linear scan at line 201. While correctness is preserved (the linear scan uses matchQuery which properly validates using the query's stored options), this means the O(1) optimization is silently lost for any queries created with custom queryKeyHashFn. Consider adding an inline comment documenting this trade-off.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/query-core/src/queryCache.ts` around lines 188 - 207, The fast-path
direct lookup using hashKey(defaultedFilters.queryKey) in the get/find logic
(around defaultedFilters, hashKey, and matchQuery) can miss queries that were
stored under a custom hash (query.queryHash computed with
options.queryKeyHashFn), silently degrading to the O(n) linear scan; update the
code in the fast-path block to include an inline comment explaining this
trade-off (that direct lookup only works for the global/default hashKey and
queries with a custom queryKeyHashFn are stored under query.queryHash so the
code falls back to the linear scan using matchQuery), and mention that
preserving O(1) for custom hashes would require computing/using the query's
stored hash when possible.
packages/query-core/src/mutationCache.ts (1)

237-254: Verify that matchMutation behaves correctly when mutationKey is stripped but exact remains.

The fast path removes mutationKey from the filters before calling matchMutation, but exact: true is still present. Looking at matchMutation, this is safe because exact is only consulted inside the if (mutationKey) branch, which is skipped when mutationKey is absent. However, this coupling is subtle — if matchMutation ever changes to inspect exact independently, this optimization would silently break.

Consider adding a brief inline comment explaining why stripping mutationKey alone is sufficient.

Also applies to: 265-272

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/query-core/src/mutationCache.ts` around lines 237 - 254, Add a short
inline comment in the fast-path blocks inside MutationCache where
defaultedFilters.mutationKey is stripped before calling matchMutation (the
blocks that iterate candidates from this.#byMutationKey and use
filtersWithoutKey) explaining that removing mutationKey is safe even when exact:
true because matchMutation only checks exact inside its mutationKey branch;
reference the symbols defaultedFilters, mutationKey, exact, matchMutation and
the candidates loop so future maintainers know this coupling and to update the
comment if matchMutation's logic changes (apply the same comment to the second
occurrence around the other fast-path).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/query-core/src/mutationCache.ts`:
- Around line 162-174: The deletion from the private map `#byMutationKey` is
missing a defensive check and should mirror the `#scopes` logic: when keyed.length
=== 1, verify that keyed[0] === mutation before calling
this.#byMutationKey.delete(hash) to avoid deleting the bucket if the invariant
is violated; update the removal code that handles mutation.options.mutationKey /
hashKey(mutationKey) to perform this extra equality check (use the existing
local variables mutation, keyed and hash) and only delete the map entry when the
first item equals the mutation.

---

Nitpick comments:
In `@packages/query-core/src/mutationCache.ts`:
- Around line 237-254: Add a short inline comment in the fast-path blocks inside
MutationCache where defaultedFilters.mutationKey is stripped before calling
matchMutation (the blocks that iterate candidates from this.#byMutationKey and
use filtersWithoutKey) explaining that removing mutationKey is safe even when
exact: true because matchMutation only checks exact inside its mutationKey
branch; reference the symbols defaultedFilters, mutationKey, exact,
matchMutation and the candidates loop so future maintainers know this coupling
and to update the comment if matchMutation's logic changes (apply the same
comment to the second occurrence around the other fast-path).

In `@packages/query-core/src/queryCache.ts`:
- Around line 215-223: The findAll fast-path in QueryCache (inside findAll)
early-returns [] when a candidate is found via hashKey(filters.queryKey) but
fails matchQuery(filtersWithoutKey, candidate); add a brief comment above this
block clarifying that this early return is intentional because at most one query
can match an exact queryKey, so a mismatched candidate means no other query can
match and we should not fall through to the linear scan (contrast with find
which only falls through when the candidate is missing). Mention the involved
symbols: findAll, find, `#queries`, hashKey, matchQuery, and filters.queryKey to
help future readers locate the logic.
- Around line 211-213: The current emptiness check uses
Object.keys(filters).length === 0 which allocates an array; update the check
inside the findAll (or the method containing filters) to avoid allocation by
using a zero-allocation pattern (e.g., bail out with a for...in that returns
early if any enumerable property exists) so that when filters is empty you still
return [...this.#queries.values()] without creating the temporary keys array;
locate the check referencing filters and replace the Object.keys(...) length
test with the non-allocating for...in emptiness test.
- Around line 188-207: The fast-path direct lookup using
hashKey(defaultedFilters.queryKey) in the get/find logic (around
defaultedFilters, hashKey, and matchQuery) can miss queries that were stored
under a custom hash (query.queryHash computed with options.queryKeyHashFn),
silently degrading to the O(n) linear scan; update the code in the fast-path
block to include an inline comment explaining this trade-off (that direct lookup
only works for the global/default hashKey and queries with a custom
queryKeyHashFn are stored under query.queryHash so the code falls back to the
linear scan using matchQuery), and mention that preserving O(1) for custom
hashes would require computing/using the query's stored hash when possible.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between eb7dca5 and 7a87894.

📒 Files selected for processing (3)
  • .changeset/perf-cache-lookup-optimization.md
  • packages/query-core/src/mutationCache.ts
  • packages/query-core/src/queryCache.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
packages/query-core/src/mutationCache.ts (1)

162-173: Past concern is resolved by the indexOf-based removal.

The previous version of this block was flagged for omitting the keyed[0] === mutation guard before deleting the bucket. The current implementation avoids that entire branch by using indexOf uniformly: splicing only when the mutation is found, then deleting the bucket only after the splice leaves it empty. This is both correct and cleaner than the #scopes pattern it mirrors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/query-core/src/mutationCache.ts` around lines 162 - 173, The removal
logic in MutationCache that uses indexOf to find and splice the mutation
(referenced by mutation.options.mutationKey, hashKey, and the `#byMutationKey`
map) is correct and safer than the old keyed[0] guard; leave the current
implementation as-is: find the index with keyed.indexOf(mutation), only splice
when index !== -1, and delete the hash bucket only when keyed.length === 0 after
splicing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/query-core/src/mutationCache.ts`:
- Around line 162-173: The removal logic in MutationCache that uses indexOf to
find and splice the mutation (referenced by mutation.options.mutationKey,
hashKey, and the `#byMutationKey` map) is correct and safer than the old keyed[0]
guard; leave the current implementation as-is: find the index with
keyed.indexOf(mutation), only splice when index !== -1, and delete the hash
bucket only when keyed.length === 0 after splicing.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7a87894 and b018ef1.

📒 Files selected for processing (1)
  • packages/query-core/src/mutationCache.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant