Skip to content

Report the native memory of created shadow nodes to the JS GC - #59702

Open
hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:hermes-shadow-node-external-memory
Open

hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:hermes-shadow-node-external-memory

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

The JS object that createNode returns owns the new shadow node, so the shadow node (with its props, family and event emitter) is only freed when the GC collects that object. The GC doesn't know about this native memory: it only sees a small JS object. With Hermes, it can go a long time without a full collection, and the shadow nodes of trees that were already unmounted pile up in native memory.

This change reports an estimate of that native memory with setExternalMemoryPressure when createNode returns, like the Blob module does for blob data (#44336). The GC then accounts for it and collects unmounted trees in time.

The estimate is 2 KB per node. A View with a few style props takes about 2.4 KB on Android (shadow node, props, family and event emitter, measured with heapprofd), a bit less on iOS, which doesn't keep a folly::dynamic copy of the props. Only nodes from createNode are reported, not clones: that's where most of the memory is, and reporting clones too made no difference in the measurements below while it would make the GC run more often during frequent re-renders. JSC's setExternalMemoryPressure is a no-op, so this only changes behavior with Hermes.

Changelog:

[GENERAL] [FIXED] - With Hermes, the native memory of unmounted shadow trees is freed in time instead of piling up until the JS heap grows

Test Plan:

Fantom test. ShadowNodeExternalMemory-itest.js renders 101 Views, checks that Hermes' external memory (HermesInternal.getInstrumentedStats().js_externalBytes) grows by at least 101 × 2 KB, then unmounts the tree and checks it goes back to where it started after a GC.

yarn fantom packages/react-native/src/private/__tests__/ShadowNodeExternalMemory-itest.js

It passes with this change and fails without it (Expected 0 to be greater than or equal to 206848), with the Fantom tester rebuilt in each case. yarn flow, ESLint, Prettier and node scripts/clang-format.js --check pass on the changed files.

Android devices. RNTester release build, a screen that mounts 1000 Views in a ScrollView and unmounts them again, 24 times. After each unmount, a Java GC is forced and the app's native heap is read from dumpsys meminfo with nothing mounted. The same APK runs both arms, with the change switched by a system property; on the Pixel 9a the runs alternate (without, with, with, without), on the Galaxy A16 there is one run per arm.

Native heap after unmounting (MB) Without this change With this change
Pixel 9a, after the last cycle (2 runs) 56 / 57, still growing 35 / 34
Pixel 9a, mean of cycles 13–24 50.8 / 50.4 39.7 / 39.2
Galaxy A16, after the last cycle 73, still growing 47
Galaxy A16, mean of cycles 13–24 67.3 46.8

Without the change, native memory grows by about 1 MB per cycle on both phones. With it, Hermes runs a full collection after about 10 cycles and native memory then stays flat. Total memory (PSS) on the Pixel 9a, mean of cycles 13–24: 176.2 / 173.8 MB without, 164.2 / 161.1 MB with. Reporting clones as well gave the same result (38.8 / 39.2 MB).

Cost. Galaxy A16, a benchmark inside the same RNTester build: 33 rounds that mount and unmount 1000 Views and then 1000 cards (about 10,000 nodes), the first 3 rounds discarded. Mount time is measured from setState to the layout effect of the commit, and Hermes' GC statistics are compared over the whole run. Two runs per arm:

Without this change With this change
Mount 1000 Views, median of 60 90.2 ms 92.6 ms
Mount 1000 cards, median of 60 1579 ms 1535 ms
GCs during the run 259 / 260 496 / 496
Total GC time 1.38 / 1.38 s 1.50 / 1.47 s
Longest GC 10 / 10 ms 8 / 8 ms

Mount time doesn't change measurably. Hermes collects about twice as often, but the collections are smaller: total GC time grows by about 8%, and the longest one doesn't get longer. JS allocation grows by 1.2%, from the internal property Hermes adds to each node's object. With the 1000 cards mounted, the same JS allocation work took as long and ran as many GCs as with nothing mounted, in both arms, so the memory reported for a large mounted tree doesn't make collections more frequent.

iPhone. iPhone 15 Pro (iOS 26.6), RNTester release build, the same screen and 24 cycles. The app's memory footprint (phys_footprint) is read every second and taken 7.5 s after each unmount, with nothing mounted. The same build runs both arms, with the change switched by a launch argument; the runs alternate (without, with, with, without).

Memory footprint after unmounting (MB) Without this change With this change
After the last cycle (2 runs) 55.8 / 50.0 44.2 / 41.9
Highest 70.3 / 65.9 58.5 / 58.5
Mean of cycles 13–24 58.5 / 51.0 47.3 / 44.1

Without the change, the footprint grows by about 3 MB per cycle until Hermes runs a full collection on its own after 9 to 13 cycles. With it, the collection comes after about 6 cycles, and the footprint then goes down to 42–44 MB.

🤖 Generated with Claude Code

The JS object that createNode returns owns the new shadow node (with its
props, family and event emitter), so that native memory is only freed when
the GC collects the object. The GC didn't know about it: it saw small JS
objects and rarely ran a full collection, so the shadow nodes of unmounted
trees piled up.

Report a 2 KB estimate per created node with setExternalMemoryPressure, as
the Blob module already does for blob data, so that the GC collects them in
time. A View with a few style props takes about 2.4 KB on Android (measured
with heapprofd), a bit less on iOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 30, 2026
@facebook-github-tools facebook-github-tools Bot added p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. labels Oct 30, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant