-
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
perf(queriesObserver): fix O(n²) performance issue in batch updates #9467
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
perf(queriesObserver): fix O(n²) performance issue in batch updates #9467
Conversation
…1) observer lookup
View your CI Pipeline Execution ↗ for commit 5f51b4a
☁️ Nx Cloud last updated this comment at |
Codecov ReportAll modified and coverable lines are covered by tests ✅
Additional details and impacted files@@ Coverage Diff @@
## main #9467 +/- ##
===========================================
+ Coverage 45.30% 59.60% +14.29%
===========================================
Files 208 137 -71
Lines 8283 5525 -2758
Branches 1869 1477 -392
===========================================
- Hits 3753 3293 -460
+ Misses 4085 1930 -2155
+ Partials 445 302 -143 🚀 New features to boost your workflow:
|
const index = this.#observers.indexOf(observer) | ||
if (index !== -1) { | ||
const index = this.#indexMap.get(observer) | ||
if (index !== undefined) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why don’t we just pass the index
into onUpdate
instead of passing the whole observer and then trying to find the index again? For example:
diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts
index 853e490ab..84defa874 100644
--- a/packages/query-core/src/queriesObserver.ts
+++ b/packages/query-core/src/queriesObserver.ts
@@ -63,9 +63,9 @@ export class QueriesObserver<
protected onSubscribe(): void {
if (this.listeners.size === 1) {
- this.#observers.forEach((observer) => {
+ this.#observers.forEach((observer, index) => {
observer.subscribe((result) => {
- this.#onUpdate(observer, result)
+ this.#onUpdate(index, result)
})
})
}
@@ -137,9 +137,9 @@ export class QueriesObserver<
observer.destroy()
})
- difference(newObservers, prevObservers).forEach((observer) => {
+ difference(newObservers, prevObservers).forEach((observer, index) => {
observer.subscribe((result) => {
- this.#onUpdate(observer, result)
+ this.#onUpdate(index, result)
})
})
@@ -251,12 +251,9 @@ export class QueriesObserver<
return observers
}
- #onUpdate(observer: QueryObserver, result: QueryObserverResult): void {
- const index = this.#observers.indexOf(observer)
- if (index !== -1) {
- this.#result = replaceAt(this.#result, index, result)
- this.#notify()
- }
+ #onUpdate(index: number, result: QueryObserverResult): void {
+ this.#result = replaceAt(this.#result, index, result)
+ this.#notify()
}
#notify(): void {
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for the suggestion
I considered passing the index directly, but noticed these potential issues
When setQueries()
reorders observers, existing subscriptions keep their old captured indices
// Initial: observers = [A, B, C]
// A subscribes with index=0 in onSubscribe()
// After setQueries(): observers = [D, A, B, C]
// A still uses index=0, but should use index=1
The index from difference().forEach()
is not the actual position in this.#observers
difference(newObservers, prevObservers).forEach((observer, index) => {
// If difference returns [D, E], index is 0,1
// But their actual positions in observers might be [A, D, B, E, C] -> 1,3
observer.subscribe((result) => {
this.#onUpdate(index, result) // <- I think there is a problem here.
})
})
What do you think about these concerns?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, can you then please add a test case where the implementation I suggested would fail.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { queryKey } from '@tanstack/query-test-utils'
import { QueriesObserver, QueryClient } from '..'
import type { QueryObserverResult } from '..'
describe('queriesObserver - index tracking issue', () => {
let queryClient: QueryClient
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
queryClient.mount()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
test('should fail when using forEach index with dynamic query changes', async () => {
const key1 = queryKey()
const key2 = queryKey()
const key3 = queryKey()
const key4 = queryKey()
const queryFn1 = vi.fn().mockReturnValue('data1')
const queryFn2 = vi.fn().mockReturnValue('data2')
const queryFn3 = vi.fn().mockReturnValue('data3')
const queryFn4 = vi.fn().mockReturnValue('data4')
const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
])
const results: Array<Array<QueryObserverResult>> = []
const unsubscribe = observer.subscribe((result) => {
results.push([...result])
})
await vi.advanceTimersByTimeAsync(0)
results.length = 0
observer.setQueries([
{ queryKey: key3, queryFn: queryFn3 },
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key4, queryFn: queryFn4 },
{ queryKey: key2, queryFn: queryFn2 },
])
await vi.advanceTimersByTimeAsync(0)
queryClient.setQueryData(key3, 'updated3')
queryClient.setQueryData(key4, 'updated4')
queryClient.setQueryData(key1, 'updated1')
unsubscribe()
const finalResult = results[results.length - 1]
expect(finalResult).toHaveLength(4)
expect(finalResult?.[0]).toMatchObject({ data: 'updated3' })
expect(finalResult?.[1]).toMatchObject({ data: 'updated1' })
expect(finalResult?.[2]).toMatchObject({ data: 'updated4' })
expect(finalResult?.[3]).toMatchObject({ data: 'data2' })
})
test('should fail when reordering queries with existing subscriptions', async () => {
const key1 = queryKey()
const key2 = queryKey()
const key3 = queryKey()
let updateCount = 0
const queryFn1 = vi.fn().mockImplementation(() => `data1-${++updateCount}`)
const queryFn2 = vi.fn().mockImplementation(() => `data2-${++updateCount}`)
const queryFn3 = vi.fn().mockImplementation(() => `data3-${++updateCount}`)
const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
{ queryKey: key3, queryFn: queryFn3 },
])
const results: Array<Array<QueryObserverResult>> = []
const unsubscribe = observer.subscribe((result) => {
results.push([...result])
})
await vi.advanceTimersByTimeAsync(0)
results.length = 0
observer.setQueries([
{ queryKey: key3, queryFn: queryFn3 },
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
])
await queryClient.invalidateQueries({ queryKey: key1 })
await vi.advanceTimersByTimeAsync(0)
const resultAfterInvalidate = results[results.length - 1]
expect(resultAfterInvalidate?.[1]?.data).toMatch(/data1-\d+/)
expect(resultAfterInvalidate?.[0]?.data).toBe('data3-3')
expect(resultAfterInvalidate?.[2]?.data).toBe('data2-2')
unsubscribe()
})
})

the failure only occurs with the version that captures the index in the subscription callback. When I run the same tests against either the current main branch’s queriesObserver.ts or the queriesObserver.ts in my PR, all of the tests pass without any issues.
This is an additional workaround attempt for an issue I've been challenged to solve before.
Previous issue
First Resolution Attempt PR
Background
This PR addresses a long-standing performance issue first reported in #8295, where using
useQueries
with batch data fetching patterns (like DataLoader) causes severe performance degradation due to O(n²) complexity.The Problem
When multiple queries resolve simultaneously (common with DataLoader pattern), the performance degrades quadratically:
The issue occurs because:
extra notes
i found two remaining pain‑points:
If 100 observers change only 1 member, we still perform
100 × 100 = 10 000
comparisons.Missing early‑return / direct access
Concrete mental model
Real-world impact
Previous Improvements
Over the past months, several optimizations have been made:
1. Caching
findMatchingObservers
results (#8304)I previously added
observerMatches
to cache results instead of recalculating:2. Optimizing
difference
function (O(n²) → O(n))3. Optimizing
findMatchingObservers
with MapThe Remaining Issue
Despite these improvements, one critical O(n) operation remains in
#onUpdate
:With batch updates, this creates:
This PR's Solution
This PR introduces a
WeakMap
to track observer indices, eliminating the O(n) search:Why WeakMap?
I chose
WeakMap
over regularMap
to address the concern raised in #8686 about storing observers in both a Map and an Array:WeakMap
solves this elegantly:This approach maintains the simplicity of the original design while achieving O(1) performance.
Performance Results
With this change, batch updates now scale linearly:
Summary
This completes the optimization journey for
QueriesObserver
:findMatchingObservers
caching (my previous PR)difference
function: O(n²) → O(n)findMatchingObservers
: O(n) → O(1) with Map#onUpdate
: O(n) → O(1) with WeakMap (this PR)The combination of these improvements transforms what was once a quadratic bottleneck into efficient linear scaling, making
useQueries
viable for large-scale batch operations.