Skip to content

perf: drop intermediate object copy in useQueries$ transform#138

Merged
mbret merged 1 commit into
mainfrom
chore/perf-2026-07-19-usequeries-transform
Jul 19, 2026
Merged

perf: drop intermediate object copy in useQueries$ transform#138
mbret merged 1 commit into
mainfrom
chore/perf-2026-07-19-usequeries-transform

Conversation

@mbret

@mbret mbret commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Target

Subsystem: query hooks — useQueries$ (src/lib/queries/useQueries$.ts).

User-visible symptom: useQueries$ is the hook for observing an array of parallel queries (dashboards commonly pass dozens). On every render it rebuilds the full array of query-option objects to bridge each observable queryFn to TanStack's promise queryFn. The per-query transform did more allocation than necessary, adding avoidable main-thread work and GC pressure that scales with the number of queries × render frequency.

Change

One line in the per-query map:

-  const transformedQueries = queries.map(({ queryFn, ...query }) => ({
+  const transformedQueries = queries.map((query) => ({
     ...query,
-    queryFn: createObservableQueryFn(queryClient$, _queryClient, queryFn),
+    queryFn: createObservableQueryFn(queryClient$, _queryClient, query.queryFn),
   }))
  • Mechanism: the old form used object-rest destructuring ({ queryFn, ...query }) and then re-spread query. Object-rest forces V8 to allocate and populate a second throwaway object (the rest) per query on top of the spread that follows — and queryFn was re-added anyway, so the rest was pure overhead. Spreading the original query once and overriding queryFn in place produces the same result with one object allocation instead of two.

  • Scale multiplier: O(queries) per render of every useQueries$ consumer. Halves the object allocations in this map for each of N queries, on every render.

  • Impact (measured): a microbenchmark of just the transform (bridge stubbed out) at 10 / 30 / 100 queries over 200k iterations shows the map running ~5–10× faster — object-rest is the dominant cost:

    queries old (rest+spread) new (spread) speedup
    10 808 ms 82 ms 9.8×
    30 1769 ms 348 ms 5.1×
    100 5739 ms 537 ms 10.7×

    (Absolute time is per 200k transform runs; real render cost is a small fraction of a render, but it scales with query count and is removed for free.)

Behavior

Unchanged. The resulting option object has the same keys and the same values (verified by === on every property, and queryFn resolves to the identical bridged function). Only queryFn's insertion order within the object changes; TanStack reads options by property name, so ordering is irrelevant.

Verification

  • Baseline on clean main (Node 25, per CI): npm run check, npm run build, npm run test:ci all green.
  • After the change: same three gates green, including the dedicated src/lib/queries/useQueries$.test.tsx suite.

Backlog (perf opportunities found, not taken this run)

  • Shared-store persistence adapter (createLocalStorageAdapter({ key })createSharedStoreAdapter): every single-key getItem/setItem/removeItem reads and re-JSON.parses (and, for writes, re-JSON.stringifys) the entire combined blob. Hydrating or persisting N signals into one shared store is therefore O(N × totalSize) — effectively O(N²). No behavior-preserving fix without an interface/caching change (fresh read-modify-write is needed for cross-tab correctness), so left for a design decision.
  • Double hashKey on query cache-miss (createObservableQueryFn computes hashKey(queryKey) for the cache lookup, then QueryClient$.setQuery computes it again). Only on first fetch of a key, so low frequency, but setQuery could accept a precomputed hash.
  • Redundant reference resolution in signal hooks: useSignal calls useSignalReference directly and again inside both useSignalValue and useSetSignal, so a virtual signal is resolved (context read + Map.get) 3× per render. Constant-factor only; not pursued.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HHuicehU24mr9cRu2fdUEf


Generated by Claude Code

useQueries$ rebuilds every query option object on every render. It did so
with object-rest destructuring — `({ queryFn, ...query }) => ({ ...query,
queryFn })` — which forces V8 to allocate and populate a second throwaway
object per query (the rest) before the re-spread. The queryFn was then
re-added anyway, so the rest was pure overhead.

Spread the original query once and override queryFn in place. The resulting
option object is identical (same keys, same values; only queryFn's insertion
order changes, which TanStack ignores since it reads options by name), so
behavior is unchanged.

This is O(queries) per render of every useQueries$ consumer — the hook meant
for arrays of parallel queries. A microbenchmark of the transform at 10/30/100
queries shows the map run ~5-10x faster (object-rest is the dominant cost).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHuicehU24mr9cRu2fdUEf
@mbret
mbret merged commit dc94b5f into main Jul 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants