GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — perf: faster helpers, hot hooks, resolvers and cache keys by fratzinger · Pull Request #81 · feathersjs/feathers-utils

Conversation

@fratzinger

@fratzinger fratzinger commented Oct 20, 2026

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

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
fratzinger and others added 3 commits October 20, 2026 10:33
@fratzinger @claude
perf(helpers): fast paths for pickPaths, omitPaths and isEqual
5a0c2d5
- 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 <[email protected]>
@fratzinger @claude
perf: cut per-call and per-item overhead in hot hooks and resolvers
4852021
- 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 <[email protected]>
@fratzinger @claude
perf(cache): serialize the key once per miss, faster stringifyParams
82aa68d
- 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 <[email protected]>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 20, 2026

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

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@fratzinger
fratzinger merged commit 6f204d9 into main Oct 20, 2026
9 checks passed

Uh oh!

There was an error while loading. Please reload this page.

@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

Reviewers

No reviews

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Uh oh!

There was an error while loading. Please reload this page.

1 participant

@fratzinger
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You are visitor 01402520 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.