Skip to content

enhance(normalizr): Lazy-clone entity tables to fix getNewEntities deopt#3884

Merged
ntucker merged 1 commit intomasterfrom
perf-lazy-entity-clone
Apr 7, 2026
Merged

enhance(normalizr): Lazy-clone entity tables to fix getNewEntities deopt#3884
ntucker merged 1 commit intomasterfrom
perf-lazy-entity-clone

Conversation

@ntucker
Copy link
Copy Markdown
Collaborator

@ntucker ntucker commented Apr 7, 2026

Motivation

V8 trace profiling (BENCH_V8_TRACE=true) showed getNewEntities suffering a Maglev bailout — "Insufficient type feedback for generic named access" on this.entities. The function was compiled early by Maglev but bailed out because the POJO clone (this.entities[key] = { ...this.entities[key] }) accessed this.entities before V8 had gathered enough type feedback. After bailout, the function stayed in interpreter mode for the rest of the benchmark.

Solution

Move the entity/meta table POJO clone from getNewEntities into setEntity, triggered lazily on the first write per entity type (updateMeta && newEntities.size === 0). This keeps getNewEntities as a pure Map operation that Maglev can optimize and keep optimized.

Also extracts a MetaEntry type alias to reduce the repeated { fetchedAt: number; date: number; expiresAt: number } inline type.

V8 deopt trace results

Before (baseline): getNewEntities compiled → bailed out ("generic named access" on this.entities) → stuck in interpreter for remainder of benchmark.

After (fix): getNewEntities compiled → bails out once ("generic global access" on new Map) → V8 recovers and re-optimizes → stays in Maglev-compiled code for the rest of the run.

Remaining "generic named access" deopts (5) are all React internals, not data-client code.

Benchmark results

Focused benchmarks (5 runs each, CI-tightened convergence) show the fix is performance-neutral — no measurable throughput improvement or regression:

Scenario Baseline mean (5 runs) Fix mean (5 runs) Delta
update-entity (small) 614.5 ops/s 606.6 ops/s ~0% (noise)
update-user (large) 596.4 ops/s 602.9 ops/s ~0% (noise)
update-user-10000 (large) 109.8 ops/s 108.0 ops/s ~0% (noise)
move-item (small) 320.7 ops/s 322.6 ops/s ~0% (noise)

All deltas are within the per-run variance bands (±7-14%). Low-variance scenarios (getlist-100, invalidate-and-resolve) were also unchanged.

The value of this change is V8 optimization quality: getNewEntities stays in compiled Maglev code instead of falling back to the interpreter. This is a code-health improvement that protects against future performance cliffs as the normalize path evolves.


Note

Low Risk
Low risk performance refactor confined to POJO normalization; main risk is subtle mutation/clone timing differences per entity type on first write.

Overview
Keeps getNewEntities() as a pure Map lookup by moving POJO cloning of entities[key]/entitiesMeta[key] out of that method and into setEntity() on the first write per entity type.

Adds a small type cleanup via a shared MetaEntry alias and includes a patch changeset documenting the Maglev deopt motivation and the lazy-clone behavior.

Reviewed by Cursor Bugbot for commit a55d329. Bugbot is set up for automated code reviews on this repo. Configure here.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 7, 2026

🦋 Changeset detected

Latest commit: 6216c5c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@data-client/normalizr Patch
@data-client/core Patch
example-benchmark Patch
normalizr-github-example Patch
normalizr-redux-example Patch
normalizr-relationships Patch
@data-client/react Patch
@data-client/vue Patch
example-benchmark-react Patch
test-bundlesize Patch
coinbase-lite Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs-site Ignored Ignored Preview Apr 7, 2026 0:39am

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 7, 2026

Size Change: 0 B

Total Size: 80.7 kB

ℹ️ View Unchanged
Filename Size
examples/test-bundlesize/dist/App.js 1.46 kB
examples/test-bundlesize/dist/polyfill.js 307 B
examples/test-bundlesize/dist/rdcClient.js 10.5 kB
examples/test-bundlesize/dist/rdcEndpoint.js 8 kB
examples/test-bundlesize/dist/react.js 59.7 kB
examples/test-bundlesize/dist/webpack-runtime.js 726 B

compressed-size-action

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 7, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.11%. Comparing base (0ccd6c6) to head (6216c5c).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #3884   +/-   ##
=======================================
  Coverage   98.11%   98.11%           
=======================================
  Files         153      153           
  Lines        2913     2916    +3     
  Branches      565      566    +1     
=======================================
+ Hits         2858     2861    +3     
  Misses         11       11           
  Partials       44       44           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark React

Details
Benchmark suite Current: a55d329 Previous: cc330d6 Ratio
data-client: getlist-100 147.06 ops/s (± 5.7%) 142.86 ops/s (± 5.9%) 0.97
data-client: getlist-500 42.28 ops/s (± 4.1%) 38.24 ops/s (± 7.1%) 0.90
data-client: update-entity 339.08 ops/s (± 8.9%) 344.83 ops/s (± 9.0%) 1.02
data-client: update-user 344.83 ops/s (± 6.9%) 384.62 ops/s (± 7.9%) 1.12
data-client: getlist-500-sorted 43.39 ops/s (± 6.1%) 42.74 ops/s (± 7.0%) 0.99
data-client: update-entity-sorted 285.71 ops/s (± 5.9%) 322.58 ops/s (± 8.3%) 1.13
data-client: update-entity-multi-view 312.5 ops/s (± 6.2%) 357.14 ops/s (± 7.8%) 1.14
data-client: list-detail-switch-10 7.19 ops/s (± 4.8%) 7.88 ops/s (± 7.4%) 1.10
data-client: update-user-10000 76.34 ops/s (± 13.3%) 81.3 ops/s (± 15.4%) 1.06
data-client: invalidate-and-resolve 35.71 ops/s (± 5.2%) 35.59 ops/s (± 4.4%) 1.00
data-client: unshift-item 217.39 ops/s (± 4.6%) 227.27 ops/s (± 5.1%) 1.05
data-client: delete-item 285.71 ops/s (± 6.5%) 285.71 ops/s (± 6.6%) 1
data-client: move-item 186.93 ops/s (± 6.4%) 196.08 ops/s (± 5.9%) 1.05

This comment was automatically generated by workflow using github-action-benchmark.

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark

Details
Benchmark suite Current: a55d329 Previous: cc330d6 Ratio
normalizeLong 459 ops/sec (±1.80%) 438 ops/sec (±2.01%) 0.95
normalizeLong Values 416 ops/sec (±0.44%) 409 ops/sec (±1.30%) 0.98
denormalizeLong 247 ops/sec (±4.23%) 249 ops/sec (±4.26%) 1.01
denormalizeLong Values 229 ops/sec (±3.44%) 226 ops/sec (±3.37%) 0.99
denormalizeLong donotcache 1005 ops/sec (±0.15%) 1035 ops/sec (±0.71%) 1.03
denormalizeLong Values donotcache 754 ops/sec (±0.20%) 751 ops/sec (±0.14%) 1.00
denormalizeShort donotcache 500x 1583 ops/sec (±0.16%) 1572 ops/sec (±0.21%) 0.99
denormalizeShort 500x 723 ops/sec (±3.63%) 745 ops/sec (±3.28%) 1.03
denormalizeShort 500x withCache 6322 ops/sec (±0.13%) 7527 ops/sec (±0.25%) 1.19
queryShort 500x withCache 2972 ops/sec (±0.10%) 2974 ops/sec (±0.10%) 1.00
buildQueryKey All 53314 ops/sec (±0.39%) 56343 ops/sec (±0.45%) 1.06
query All withCache 6757 ops/sec (±0.32%) 6654 ops/sec (±0.35%) 0.98
denormalizeLong with mixin Entity 268 ops/sec (±6.15%) 231 ops/sec (±4.16%) 0.86
denormalizeLong withCache 7104 ops/sec (±0.24%) 7143 ops/sec (±0.22%) 1.01
denormalizeLong Values withCache 5021 ops/sec (±0.23%) 4991 ops/sec (±0.67%) 0.99
denormalizeLong All withCache 6546 ops/sec (±0.21%) 6369 ops/sec (±0.31%) 0.97
denormalizeLong Query-sorted withCache 6868 ops/sec (±0.23%) 6738 ops/sec (±0.23%) 0.98
denormalizeLongAndShort withEntityCacheOnly 1727 ops/sec (±0.24%) 1819 ops/sec (±0.20%) 1.05
denormalize bidirectional 50 6991 ops/sec (±0.29%) 5227 ops/sec (±4.07%) 0.75
denormalize bidirectional 50 donotcache 40548 ops/sec (±1.54%) 41228 ops/sec (±1.25%) 1.02
getResponse 4620 ops/sec (±0.64%) 4534 ops/sec (±0.66%) 0.98
getResponse (null) 10010189 ops/sec (±0.61%) 10894251 ops/sec (±0.78%) 1.09
getResponse (clear cache) 334 ops/sec (±2.76%) 234 ops/sec (±3.72%) 0.70
getSmallResponse 3531 ops/sec (±0.17%) 3476 ops/sec (±0.19%) 0.98
getSmallInferredResponse 2709 ops/sec (±0.14%) 2748 ops/sec (±0.14%) 1.01
getResponse Collection 4529 ops/sec (±0.80%) 4471 ops/sec (±0.35%) 0.99
get Collection 4545 ops/sec (±0.32%) 4550 ops/sec (±0.77%) 1.00
get Query-sorted 4551 ops/sec (±0.58%) 5228 ops/sec (±0.33%) 1.15
setLong 467 ops/sec (±0.24%) 464 ops/sec (±0.20%) 0.99
setLongWithMerge 252 ops/sec (±0.35%) 252 ops/sec (±0.50%) 1
setLongWithSimpleMerge 277 ops/sec (±0.18%) 270 ops/sec (±0.24%) 0.97
setSmallResponse 500x 929 ops/sec (±0.97%) 929 ops/sec (±0.08%) 1

This comment was automatically generated by workflow using github-action-benchmark.

getNewEntities eagerly cloned entity and meta table POJOs on first
access per key, causing a Maglev bailout ("Insufficient type feedback
for generic named access") because this.entities lacked stable type
feedback at optimization time.

Move the clone to setEntity (lazy, on first write per entity type) so
getNewEntities stays a pure Map operation that Maglev can optimize and
keep optimized. Also extract MetaEntry type alias to reduce repetition.

Made-with: Cursor
@ntucker ntucker force-pushed the perf-lazy-entity-clone branch from 6216c5c to a55d329 Compare April 7, 2026 12:39
@ntucker ntucker merged commit 7df6a49 into master Apr 7, 2026
7 of 8 checks passed
@ntucker ntucker deleted the perf-lazy-entity-clone branch April 7, 2026 12:40
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