Skip to content

Conversation

vasylenkoval
Copy link
Contributor

Description

I was looking at js-framework-benchmark and found something that looked a bit strange. All of the Preact benchmarks that use memoized rows have swap rows performance similar to React, while all other benchmarks that don't use memoized rows have faster swaps but incur the cost of not being able to skip processing unchanged rows in select row.

After taking a closer look at your codebase, I was pleasantly surprised that Preact is flagging all moved nodes granularly during reconciliation as opposed to React's more optimistic handling (deopt backwards swaps). So I wanted to track down where the cost was coming from.

Screenshot 2025-08-13 at 9 03 59 PM

Debugging

I modified the preact-hooks benchmark to have memoized rows and made an unminified build to debug.

The insert condition seems to be triggered for these 3 cases:

  • vnode was explicitly marked for insertion
  • vnode has memoized children
  • Combination of the two
if (
   childVNode._flags & INSERT_VNODE ||
   oldVNode._children === childVNode._children
) {
   oldDom = insert(....)
}

For most cases, if the insert call is made for a memoized vnode without an insert flag, it will match the old dom and skip the insert. Unfortunately, when a swap is made, it's not guaranteed to match, resulting in unnecessary calls to insertBefore even though the order of vnodes between swapped items did not change.

Notice how it starts re-inserting rows between the swapped ones while pushing the second (swapped) row forward, instead of skipping over nodes in between and swapping the second row at the end when it encounters it during the diff. It's happening because the second row is still in the DOM at its old position and children in between are failing to skip the dom equality check that would bail out the insertBefore call.

preact-debug.1.mp4

Proposed change

I figured that a quick and dirty fix with the least amount of code would be to just pass a flag to insert to disable the DOM insertion in cases when it's not required, while still keeping the logic around sCU bailout and incrementing the next oldDom pointer in tact. Ideally it would be a separate code path but I wanted to get your feedback first. Let me know your thoughts!

Tests

I added a test that verifies extra insertBefore calls are not happening in this case. I can also add more tests around simply testing re-ordering with memoized children, however, you seemed to have a pretty decent coverage there already.

Results

Here's the comparison between unmodified preact-classes bench with and without the above commit. I chose react-classes because it's using memoized rows and I did not want to compare against a benchmark that I refactored to keep it fair.

Please disregard the v10.25.2 at the top, both benchmarks are using the latest build from main at this commit 68b30fe, not npm. I just swapped the preact package in node modules, the version in the bench is pulled from package.json.

Other Preact benchmarks that do not use memoized rows would see even bigger shifts in overall scores if refactored.

Screenshot 2025-08-15 at 8 27 34 PM

Copy link

github-actions bot commented Aug 16, 2025

📊 Tachometer Benchmark Results

Summary

duration

  • create10k: unsure 🔍 -1% - +1% (-7.17ms - +12.09ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 -0% - +0% (-0.03ms - +0.03ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -3% - +1% (-2.35ms - +0.65ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -1% - +0% (-0.13ms - +0.08ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -2% - +1% (-1.50ms - +0.78ms)
    preact-local vs preact-main
  • text-update: faster ✔ 2% - 7% (0.04ms - 0.15ms)
    preact-local vs preact-main
  • todo: unsure 🔍 -1% - +0% (-0.20ms - +0.11ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -5% - +1% (-1.50ms - +0.49ms)
    preact-local vs preact-main

usedJSHeapSize

  • create10k: unsure 🔍 -0% - +0% (-0.00ms - +0.02ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 -0% - +0% (-0.00ms - +0.00ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -3% - +11% (-0.16ms - +0.63ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -0% - +0% (-0.00ms - +0.00ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -0% - +1% (-0.01ms - +0.01ms)
    preact-local vs preact-main
  • text-update: slower ❌ 0% - 3% (0.00ms - 0.03ms)
    preact-local vs preact-main
  • todo: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -0% - +0% (-0.00ms - +0.00ms)
    preact-local vs preact-main

Results

create10k

duration

VersionAvg timevs preact-localvs preact-main
preact-local1022.79ms - 1035.15ms-unsure 🔍
-1% - +1%
-7.17ms - +12.09ms
preact-main1019.12ms - 1033.90msunsure 🔍
-1% - +1%
-12.09ms - +7.17ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local19.03ms - 19.05ms-unsure 🔍
-0% - +0%
-0.00ms - +0.02ms
preact-main19.03ms - 19.03msunsure 🔍
-0% - +0%
-0.02ms - +0.00ms
-
filter-list

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.53ms - 16.57ms-unsure 🔍
-0% - +0%
-0.03ms - +0.03ms
preact-main16.53ms - 16.57msunsure 🔍
-0% - +0%
-0.03ms - +0.03ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.53ms - 1.54ms-unsure 🔍
-0% - +0%
-0.00ms - +0.00ms
preact-main1.53ms - 1.54msunsure 🔍
-0% - +0%
-0.00ms - +0.00ms
-
hydrate1k

duration

VersionAvg timevs preact-localvs preact-main
preact-local68.71ms - 70.75ms-unsure 🔍
-3% - +1%
-2.35ms - +0.65ms
preact-main69.47ms - 71.67msunsure 🔍
-1% - +3%
-0.65ms - +2.35ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local6.06ms - 6.62ms-unsure 🔍
-3% - +11%
-0.16ms - +0.63ms
preact-main5.82ms - 6.38msunsure 🔍
-10% - +2%
-0.63ms - +0.16ms
-
many-updates

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.41ms - 16.57ms-unsure 🔍
-1% - +0%
-0.13ms - +0.08ms
preact-main16.44ms - 16.58msunsure 🔍
-1% - +1%
-0.08ms - +0.13ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local3.72ms - 3.72ms-unsure 🔍
-0% - +0%
-0.00ms - +0.00ms
preact-main3.72ms - 3.72msunsure 🔍
-0% - +0%
-0.00ms - +0.00ms
-
replace1k
  • Browser: chrome-headless
  • Sample size: 100
  • Built by: CI #5071
  • Commit: 654d79d

duration

VersionAvg timevs preact-localvs preact-main
preact-local62.65ms - 64.13ms-unsure 🔍
-2% - +1%
-1.50ms - +0.78ms
preact-main62.88ms - 64.62msunsure 🔍
-1% - +2%
-0.78ms - +1.50ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local2.99ms - 3.01ms-unsure 🔍
-0% - +1%
-0.01ms - +0.01ms
preact-main2.98ms - 3.00msunsure 🔍
-0% - +0%
-0.01ms - +0.01ms
-

run-warmup-0

VersionAvg timevs preact-localvs preact-main
preact-local24.95ms - 25.54ms-unsure 🔍
-2% - +2%
-0.50ms - +0.42ms
preact-main24.94ms - 25.64msunsure 🔍
-2% - +2%
-0.42ms - +0.50ms
-

run-warmup-1

VersionAvg timevs preact-localvs preact-main
preact-local32.61ms - 33.59ms-unsure 🔍
-3% - +2%
-0.95ms - +0.51ms
preact-main32.78ms - 33.86msunsure 🔍
-2% - +3%
-0.51ms - +0.95ms
-

run-warmup-2

VersionAvg timevs preact-localvs preact-main
preact-local32.80ms - 33.80ms-unsure 🔍
-1% - +3%
-0.40ms - +0.96ms
preact-main32.57ms - 33.47msunsure 🔍
-3% - +1%
-0.96ms - +0.40ms
-

run-warmup-3

VersionAvg timevs preact-localvs preact-main
preact-local25.70ms - 26.41ms-unsure 🔍
-3% - +1%
-0.72ms - +0.25ms
preact-main25.96ms - 26.62msunsure 🔍
-1% - +3%
-0.25ms - +0.72ms
-

run-warmup-4

VersionAvg timevs preact-localvs preact-main
preact-local24.91ms - 26.16ms-unsure 🔍
-4% - +3%
-0.95ms - +0.77ms
preact-main25.03ms - 26.21msunsure 🔍
-3% - +4%
-0.77ms - +0.95ms
-

run-final

VersionAvg timevs preact-localvs preact-main
preact-local21.63ms - 22.49ms-unsure 🔍
-5% - +1%
-1.22ms - +0.15ms
preact-main22.07ms - 23.13msunsure 🔍
-1% - +6%
-0.15ms - +1.22ms
-
text-update
  • Browser: chrome-headless
  • Sample size: 220
  • Built by: CI #5071
  • Commit: 654d79d

duration

VersionAvg timevs preact-localvs preact-main
preact-local1.89ms - 1.97ms-faster ✔
2% - 7%
0.04ms - 0.15ms
preact-main1.99ms - 2.07msslower ❌
2% - 8%
0.04ms - 0.15ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local0.99ms - 1.01ms-slower ❌
0% - 3%
0.00ms - 0.03ms
preact-main0.97ms - 0.99msfaster ✔
0% - 3%
0.00ms - 0.03ms
-
todo

duration

VersionAvg timevs preact-localvs preact-main
preact-local30.51ms - 30.73ms-unsure 🔍
-1% - +0%
-0.20ms - +0.11ms
preact-main30.55ms - 30.78msunsure 🔍
-0% - +1%
-0.11ms - +0.20ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.25ms - 1.25ms-unsure 🔍
+0% - +0%
+0.00ms - +0.00ms
preact-main1.25ms - 1.25msunsure 🔍
-0% - -0%
-0.00ms - -0.00ms
-
update10th1k

duration

VersionAvg timevs preact-localvs preact-main
preact-local31.37ms - 32.20ms-unsure 🔍
-5% - +1%
-1.50ms - +0.49ms
preact-main31.39ms - 33.19msunsure 🔍
-2% - +5%
-0.49ms - +1.50ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local2.93ms - 2.93ms-unsure 🔍
-0% - +0%
-0.00ms - +0.00ms
preact-main2.93ms - 2.93msunsure 🔍
-0% - +0%
-0.00ms - +0.00ms
-

tachometer-reporter-action v2 for CI

@coveralls
Copy link

coveralls commented Aug 16, 2025

Coverage Status

coverage: 99.536%. remained the same
when pulling 654d79d on vasylenkoval:vasylenkoval/speedup-memo-swaps
into 61f47ca on preactjs:main.

@rschristian
Copy link
Member

rschristian commented Aug 16, 2025

Smaller even? Compression works in mysterious ways...

Edit: Bit bigger now


Size Change: +13 B (+0.03%)

Total Size: 47.5 kB

Filename Size Change
dist/preact.js 4.71 kB +3 B (+0.06%)
dist/preact.mjs 4.72 kB +3 B (+0.06%)
dist/preact.umd.js 4.78 kB +7 B (+0.15%)
ℹ️ View Unchanged
Filename Size
compat/dist/compat.js 3.9 kB
compat/dist/compat.mjs 3.82 kB
compat/dist/compat.umd.js 3.95 kB
debug/dist/debug.js 3.9 kB
debug/dist/debug.mjs 3.9 kB
debug/dist/debug.umd.js 3.98 kB
devtools/dist/devtools.js 260 B
devtools/dist/devtools.mjs 271 B
devtools/dist/devtools.umd.js 346 B
hooks/dist/hooks.js 1.55 kB
hooks/dist/hooks.mjs 1.58 kB
hooks/dist/hooks.umd.js 1.62 kB
jsx-runtime/dist/jsxRuntime.js 892 B
jsx-runtime/dist/jsxRuntime.mjs 861 B
jsx-runtime/dist/jsxRuntime.umd.js 966 B
test-utils/dist/testUtils.js 473 B
test-utils/dist/testUtils.mjs 473 B
test-utils/dist/testUtils.umd.js 555 B

compressed-size-action


Thanks for the PR & thorough investigation! I'll need to take a better look tomorrow (or someone else may jump in first), but looks like you did quite a bit of research & testing on this and we appreciate it!

Copy link
Member

@JoviDeCroock JoviDeCroock left a comment

Choose a reason for hiding this comment

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

This is an awesome find, when I re-wrote the heuristics for skew based diffing I never checked the js-framework-benchmark and I even explicitly pointed out better swap performance 😅

Thank you for the thorough research, this is a really great find!

Ideally it would be a separate code path but I wanted to get your feedback first. Let me know your thoughts!

I agree with this generally, in Preact though we have a tendency to balance bytes and performance. Separating code paths often leads to relatively large byte size increases. We could prototype it and see what that balance is and I'm happy to discuss it but wanted to be clear about our philosophy.

childVNode,
oldDom,
parentDom,
!(childVNode._flags & INSERT_VNODE) /* shouldSkipDomUpdate */
Copy link
Member

Choose a reason for hiding this comment

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

We could probably hoist this check into a variable so we don't do it twice but don't feel strongly about that as it's just a bit check

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@JoviDeCroock Thanks for your feedback! Pushed a commit that hoists the check into a variable.

After sleeping on it, I decided to also get rid of double negation to make it simpler to read, so I renamed shouldSkipDomUpdate to shouldPlace. Please let me know if you have any preferences on the flag name or anything else.

Copy link
Contributor Author

@vasylenkoval vasylenkoval Aug 16, 2025

Choose a reason for hiding this comment

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

Re-requested approvals, will cherry-pick in another PR to 10.x right after review.

Copy link
Member

@marvinhagemeister marvinhagemeister left a comment

Choose a reason for hiding this comment

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

What a great find! This is a fascinating PR and explanation how you found this. This is great!!

Copy link
Member

@rschristian rschristian left a comment

Choose a reason for hiding this comment

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

Just as an aside: the main branch here is our upcoming v11 release, v10.x is the current release line. This probably colored your results a bit as v11 has landed some other improvements too, but by all means, this looks to be a really solid improvement (and w/ a byte reduction no less).

This change could likely also be ported to v10 w/ similar (I'm guessing/hoping) improvements there; it should be more or less a direct copy/paste, I don't think these code paths have changed in much v11.

Looks great though, and thanks again for the investigation!

@vasylenkoval
Copy link
Contributor Author

Just as an aside: the main branch here is our upcoming v11 release, v10.x is the current release line. This probably colored your results a bit as v11 has landed some other improvements too, but by all means, this looks to be a really solid improvement (and w/ a byte reduction no less).

This change could likely also be ported to v10 w/ similar (I'm guessing/hoping) improvements there; it should be more or less a direct copy/paste, I don't think these code paths have changed in much v11.

Looks great though, and thanks again for the investigation!

Thanks for heads up! Will address comments and put up a PR to the v10 branch in a couple of hours.

Also, amazing job on the V11 @rschristian @marvinhagemeister @JoviDeCroock!

I did in fact see a good boost in majority of the scores relative to V10. It was big enough jump for me to realize that I need to make sure I bench against latest main otherwise it wouldn’t be a fair benchmark lol. So the scores you see are technically both V11.

Exciting stuff! Would be fun to get even closer to inferno :))

@JoviDeCroock JoviDeCroock merged commit 31632a2 into preactjs:main Aug 17, 2025
13 checks passed
@vasylenkoval vasylenkoval mentioned this pull request Aug 17, 2025
rschristian pushed a commit that referenced this pull request Aug 17, 2025
* Do not re-insert memoized vnodes that keep their relative order after swap

* rename shouldSkipDomUpdate to shouldPlace and hoist INSERT_VNODE flag check into a variable
@JoviDeCroock JoviDeCroock mentioned this pull request Aug 19, 2025
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.

5 participants