Visitar URL original
perf: faster helpers, hot hooks, resolvers and cache keys by fratzinger · Pull Request #81 · feathersjs/feathers-utils · GitHub
Skip to content

perf: faster helpers, hot hooks, resolvers and cache keys - #81

Merged
fratzinger merged 3 commits into
mainfrom
fix/helpers-performance
Oct 20, 2026
Merged

fratzinger merged 3 commits into
mainfrom
fix/helpers-performance

Conversation

@fratzinger

Copy link
Copy Markdown
Member

Performance pass over code that runs per service call or per result item. Every change was benchmarked against main and keeps the existing behavior, with one exception: the cache key fix described below.

Results

Node 24, microbenchmarks, ns per call:

before after
pickPaths, 4 flat keys 295 135 2.2×
omitPaths, 2 flat keys 371 164 2.3×
isEqual, typical query 937 774 1.2×
resolver core, per item (resolveResult/resolveData) 689 399 1.7×
setData/setResult, per call 1047 201 5×
checkContext, used by ~12 hooks per call 62 21 3×
iff(true, …) 179 127 1.4×
stringifyParams, typical query 3157 2109 1.5×
stringifyParams, large $or 25398 18526 1.4×

Changes

helpers (5a0c2d5)

  • pickPaths: a string without . or [ skips castPath. A nested path is resolved in one walk instead of three.
  • omitPaths: top-level keys are skipped while copying instead of deleted afterwards, which kept the result out of V8's dictionary mode.
  • isEqual: when both objects have the same keys in the same order, the per-key propertyIsEnumerable lookup is skipped.

hooks and resolvers (4852021)

  • Resolvers: the property list is built in place (no concat/Set/spread), and the top level skips the status spreads.
  • setData/setResult: from is read through the new internal getContextPath, which skips building context.toJSON() on every call. A test checks it against hasPath/getPath on toJSON() with a real context.
  • checkRequired: walks each path once. The second walk only runs on failure, to choose the error message.
  • iff/iffElse: hooks are combined once at creation instead of on every call.
  • checkContext: matches inline instead of copying the options and building an isContext closure on every call.
  • disallow: the isProvider predicate is built once.
  • throwIf, transformData, transformResult, muteEvent, resolve: they only await an actual promise. Public return types stay Promise<void>.

cache (82aa68d)

  • A miss is stored under the key it was looked up with, so the key is computed once per miss instead of twice.
  • stringifyParams uses JSON.stringify when normalize confirms it prints exactly what safe-stable-stringify would. That excludes cycles, BigInts, class instances, toJSON keys, encoded symbol keys and integer-like keys. Primitive $in/$nin elements are sorted without safeStringify.
  • Cache keys are unchanged. A fuzz run of 200k random params against the implementation on main found no differences, so existing entries in persistent caches stay valid.

Behavior changes

  • cache stores entries under the lookup key. Before, a hook running after cache that changed params caused the entry to be stored under a different key, one that no identical call would ever look up. Covered by a new test.
  • iffElse with an array argument now combines the hooks when it is created. Mutating that array afterwards no longer has an effect. iff already copied its hooks.

Not changed

  • setResult with dispatch keeps the deep copy. With a shallow copy, context.dispatch and context.result would share objects. A later transformResult(..., { dispatch }) would then write into the caller's result.
  • defineStash keeps both snapshots. They protect the stored params from the fetch's hooks, and each one sits next to a find against the database.
  • createRelated and trigger are unchanged. They make service calls anyway, so a few saved microtasks would not matter there.

Verification

pnpm test passes: lint without new warnings, typecheck, 1993 tests, coverage at 98%.

🤖 Generated with Claude Code

fratzinger and others added 3 commits October 20, 2026 10:33
- pickPaths: a string without `.` or `[` is read as one key without
  `castPath`, and a nested path is resolved in one walk instead of three
  (~2.2x faster for flat keys)
- omitPaths: top-level keys are skipped while copying instead of deleted
  afterwards, which kept the result out of V8's dictionary mode
  (~2.3x faster for flat keys)
- isEqual: objects with the same keys in the same order skip the
  per-key `propertyIsEnumerable` lookup in `b` (~20% faster)

Adds edge-case tests that pin the previous behavior (inherited keys,
unsafe keys, literal dotted keys, symbol/numeric/empty keys,
non-enumerable keys).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- resolvers: build the property list in place instead of concat/Set/spread,
  skip the status spreads on the top level (~1.7x per item)
- setData/setResult: read `from` via the new internal `getContextPath`
  instead of building `context.toJSON()` on every call (~5x per call)
- checkRequired: walk each path once, the second walk only runs to word
  the error
- iff/iffElse: combine the hooks once at creation instead of per call
- checkContext: match inline instead of copying the options and building
  an `isContext` closure per call (~3x)
- disallow: build the `isProvider` predicate once
- throwIf, transformData, transformResult, muteEvent, resolve: only
  `await` a pending promise; public return types stay `Promise<void>`

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- cache: a miss is stored under the key it was looked up with. Before, `set`
  serialized the params again, so a hook after `cache` that changed the
  params moved the entry to a key no identical call would look up - and
  every miss paid for the key twice.
- stringifyParams: `normalize` now tracks whether `JSON.stringify` prints
  the normalized params exactly like `safe-stable-stringify` (no cycle,
  BigInt, class instance, `toJSON` key, encoded symbol or integer-like
  key) and uses the native one then (~1.4-1.5x). Keys are unchanged:
  fuzzed against the previous implementation with 200k random params.
- normalize: sort primitive `$in`/`$nin` elements without `safeStringify`,
  track cycles with an array instead of a `WeakSet`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying feathers-utils with  Cloudflare Pages  Cloudflare Pages

Latest commit: 82aa68d
Status: ✅  Deploy successful!
Preview URL: https://ca9b31c7.feathers-utils.pages.dev
Branch Preview URL: https://fix-helpers-performance.feathers-utils.pages.dev

View logs

@fratzinger
fratzinger merged commit 6f204d9 into main Oct 20, 2026
9 checks passed
@fratzinger
fratzinger deleted the fix/helpers-performance branch October 20, 2026 09:02
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.

1 participant