Skip to content

perf(tanstack-query): useClientQueries rebuilds every model's hooks on every call — 43 s of a 74 s trace on a 149-model schema #2908

Open

Description

@therealbrad

Summary

useClientQueries(schema) rebuilds the full hooks object on every call: for every model in the schema it allocates a fresh set of ~24 hook closures, and useModelQueries locates each model with a linear scan of schema.models that calls toLowerCase() on both sides of every comparison. Nothing is cached, so the cost is paid on every render of every component that calls it. The helper calls no React hooks itself, so there is nothing that prevents building the object once per schema.

The documented usage pattern is useClientQueries(schema).model.useFindMany(...) inside components, which means any app following the docs pays the whole rebuild to reach one model.

On a 149-model schema with ~1,000 call sites, a 74.5 s Chrome performance trace of one page attributed 43.2 s of self time to useClientQueries alone (63 % of each of the seven longest main-thread freezes). Worst INP was 5.4 s. The allocation churn also showed up as 311 scavenge GCs / 1.2 s.

Where

packages/clients/tanstack-query/src/react.ts (identical in the 3.10.0 and 3.9.6 builds, and unchanged on main today):

export function useClientQueries(schema, options) {
    return Object.keys(schema.models).reduce((acc, model) => {
        acc[lowerCaseFirst(model)] = useModelQueries(schema, model, options);
        return acc;
    }, {});
    // ... $procs, $transaction
}

export function useModelQueries(schema, model, rootOptions) {
    // O(models) scan per model → O(models²) per useClientQueries call
    const modelDef = Object.values(schema.models).find((m) => m.name.toLowerCase() === model.toLowerCase());
    ...
    return { useFindUnique, useFindMany, useCreate, /* ~24 closures */ };
}

Per call on the 149-model schema this is 11,175 name comparisons, 22,350 throwaway lowercased strings, and 3,576 closure allocations. vue.ts and svelte/index.svelte.ts have the same shape.

Measured

Benchmark against the real generated schema, 2,000 iterations, Node 24 (same figure whether the hooks object is consumed or not):

ms / call
useClientQueries(schema) as published (3.10.0) 0.555
same, memoised per schema (patch below) 0.00009

The cost is quadratic in model count, so it is invisible on small schemas and dominant on large ones.

Proposed fix

Two independent changes:

  1. Memoise per schema object. Cache the result in a WeakMap<SchemaDef, ClientHooks> on the no-options path (the options form would need to be part of the key, or left uncached). The returned closures only close over schema and options, so a shared object is observationally identical except that useClientQueries(schema) becomes referentially stable across renders, which is also friendlier to dependency arrays. Rules of hooks are not affected: the real hook calls still happen inside the returned closures at the same points in render as today.

  2. Index models once in useModelQueries instead of rescanning Object.values(schema.models) with toLowerCase() per lookup. That turns the single remaining build from quadratic to linear and helps anyone calling useModelQueries directly.

This is the patch we are running in production via pnpm patch against dist/react.js; it implements (1) only:

+const __clientQueriesCache = new WeakMap();
 export function useClientQueries(schema, options) {
+    if (!options) {
+        const __cached = __clientQueriesCache.get(schema);
+        if (__cached)
+            return __cached;
+    }
     const result = Object.keys(schema.models).reduce((acc, model) => {
     ...
     result.$transaction = {
         useSequential: (hookOptions) => useInternalTransactionMutation(schema, { ...options, ...hookOptions }),
     };
+    if (!options)
+        __clientQueriesCache.set(schema, result);
     return result;
 }

Verified against the full schema: same 150 keys, 24 hooks per model, $transaction.useSequential intact, every value still a function, and the app's full unit suite (1,342 files) passes unchanged with the patch applied.

Same family as #2773 and #2715 (uncached full-schema work on a hot path).

Environment

  • ZenStack version: 3.10.0 (@zenstackhq/tanstack-query, @zenstackhq/orm); @tanstack/react-query 5.105.0; React 19.3.1; Next.js 16
  • Database type: PostgreSQL
  • Node.js version: 24
  • Package manager: pnpm 12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions