Native destructuring on Hermes V1
What changes when @react-native/babel-preset stops running @babel/plugin-transform-destructuring, measured in release builds on a Galaxy A16 and an iPhone 12 mini.
Summary
setState to commit after a theme change.The change is a net win for typical apps. The speedup comes from two Babel helpers that idiomatic code calls constantly: slicedToArray (every const [x, setX] = useState()) and objectWithoutProperties (every const {style, ...rest} = props in a component body, including React Native's own components). Native destructuring makes those 45–55% and about 80% faster per call. The gain is small in wall-clock terms because an app spends little of its time destructuring: the test app calls these helpers about 1,400 times to first frame, worth 0.7–1.2 ms.
The tradeoffs are real but narrow. Hermes has no equivalent of several Babel special cases, so a handful of array patterns get several times slower, and every array pattern costs about 31 bytes more bytecode. One configuration also breaks: dev builds with enableBabelRuntime: false crash in the preset's regenerator transform.
Where removing the transform is a tradeoff
enableBabelRuntime: false fail to compile generators and async functions that destructureWith dev: true, the preset adds @babel/plugin-transform-regenerator itself when the runtime transform is off. Regenerator's hoisting expects destructuring to be lowered already, and throws Property name expected type of string but got undefined on e.g. async function f(x) { const {a} = await x; }. Reproduced with the 0.88 preset. Fix before landing: keep the destructuring transform whenever regenerator runs. (Separately, with the default runtime setting regenerator never runs in dev at all, contrary to the preset's comment.)const [a, b] = [x, y] to const a = x, b = y with no array at all; Hermes allocates the array and runs the iterator protocol. Measured +439% to +1037% for declarations and multi-assignment, +125–215% for the [a, b] = [b, a] swap (Babel still allocates there but indexes directly). Absolute cost is ~90–130 ns per execution. Rare in practice: 0–13 such sites per app bundle (0.7–3.5% of array patterns), and only matters in hot loops such as sorting or shuffling.arguments or a rest parameterBabel knows a rest parameter is an array and indexes it directly, and turns arguments into one slice call. Native goes through the generic iterator path. +69–115% (rest param), +78–79% (arguments). No occurrences in the four app bundles analysed.const [a, ...rest] = arr on a 1,000-element array: Babel's toArray + native slice beats Hermes copying element by element through the iterator. Small arrays (8 elements) are 21–27% faster natively, so the crossover sits somewhere in between.-Xenable-fast-destructure flag, unused by RN, roughly halves the default/nesting overhead. Whole-bundle effect: -0.25% to +0.49% of HBC across the app bundles measured.Component({title, ...rest}), are already native today: the plugin only lowers declarations, assignments, for…of heads and catch clauses, and the preset doesn't run transform-parameters. Every micro case returned identical results in both forms. No open Hermes destructuring bugs; known fixes (async arrows with destructured params, iterator close on throw) are in 260318099.0.4.Typical use, quantified
The test app is a small idiomatic React Native app: React Navigation bottom tabs, a FlatList feed of post cards (avatars, buttons, tags, action bars), and a settings screen, written with the usual hooks, props destructuring and object rest. A counting build wrapped the three Babel destructuring helpers to see how often real UI code hits them.
| Phase | slicedToArray | objectWithoutProperties | toArray | Predicted saving, A16 | Predicted saving, iPhone |
|---|---|---|---|---|---|
| Startup to first frame | 775 | 613 | 40 | 1.23 ms | 0.67 ms |
| One theme change + one remount | 1,282 | 1,147 | 80 | 2.28 ms | 1.23 ms |
Predicted savings multiply call counts by the per-call difference measured in the micro suite. For re-renders the measured saving (right-hand table below) is 2–3× the prediction; hot-loop microbenchmarks keep helper call sites monomorphic and cached, which real UI code doesn't, so they likely understate the helper cost. Startup savings of ~1 ms are below what 40 cold launches can resolve, which matches the null startup result.
Cold start (ms, median of 40 per variant)
| Metric | Device | Lowered | Native | Δ (95% CI) |
|---|---|---|---|---|
| bundle start → first commit | A16 | 390.7 | 389.7 | -1.0 (-0.3%) -6.8 … +6.2 |
| bundle start → first commit | iPhone | 79.5 | 80.7 | +1.2 (+1.5%) -1.1 … +3.7 |
| bundle start → first frame | A16 | 711.1 | 713.0 | +2.0 (+0.3%) -11.5 … +11.4 |
| bundle start → first frame | iPhone | 105.0 | 105.2 | +0.2 (+0.2%) -4.0 … +4.4 |
| am start TotalTime | A16 | 295.5 | 297.0 | +1.5 (+0.5%) -4.0 … +4.0 |
Render (ms, median of 480 samples)
| Scenario | Device | Lowered | Native | Δ (95% CI) |
|---|---|---|---|---|
| Theme change, 40 cards | A16 | 83.91 | 81.91 | -2.00 (-2.4%) -2.95 … -0.38 |
| Theme change, 40 cards | iPhone | 33.46 | 32.75 | -0.71 (-2.1%) -1.05 … -0.35 |
| FlatList remount, 40 cards | A16 | 126.12 | 120.56 | -5.56 (-4.4%) -6.70 … -4.34 |
| FlatList remount, 40 cards | iPhone | 38.39 | 36.65 | -1.74 (-4.5%) -2.29 … -1.18 |
Pattern by pattern
Native time as a multiple of lowered time, both compiled into the same release bundle and timed alternately in the same process (45 paired samples per case per device). Log scale; bars left of 1× mean native is faster. Cases where both forms compile identically are omitted here and listed in the table.
All 46 cases as a table
| Pattern | A16 lowered ns | A16 native ns | A16 Δ | iPhone lowered ns | iPhone native ns | iPhone Δ |
|---|
Bundle size in real apps
Production bundles built both ways and compiled with the same hermesc RN 0.88 ships, using the release flags.
| App | Platform | HBC lowered | HBC native | HBC Δ | HBC gzip Δ | JS Δ |
|---|---|---|---|---|---|---|
| RN 0.88 template | android | 0.97 MB | 0.96 MB | -2.4 KiB (-0.25%) | -0.7 KiB (-0.16%) | -1.66% |
| RN 0.88 template | ios | 0.96 MB | 0.96 MB | -2.1 KiB (-0.22%) | -0.8 KiB (-0.18%) | -1.60% |
| RNTester (RN main) | android | 1.75 MB | 1.76 MB | +7.8 KiB (+0.46%) | +3.6 KiB (+0.48%) | -2.31% |
| RNTester (RN main) | ios | 1.76 MB | 1.77 MB | +8.4 KiB (+0.49%) | +4.0 KiB (+0.54%) | -2.27% |
| Mattermost mobile | android | 25.32 MB | 25.34 MB | +25.0 KiB (+0.10%) | +37.2 KiB (+0.40%) | -0.89% |
| Mattermost mobile | ios | 25.09 MB | 25.13 MB | +37.6 KiB (+0.15%) | +24.6 KiB (+0.27%) | -0.87% |
| Bluesky social-app | android | 31.01 MB | 31.02 MB | +12.8 KiB (+0.04%) | +12.9 KiB (+0.13%) | -1.23% |
| Bluesky social-app | ios | 30.93 MB | 30.97 MB | +37.9 KiB (+0.13%) | +27.9 KiB (+0.28%) | -1.18% |
| Private Expo SDK 58 game app | android | 5.29 MB | 5.29 MB | -0.6 KiB (-0.01%) | +0.2 KiB (+0.01%) | -1.59% |
| Private Expo SDK 58 game app | ios | 5.09 MB | 5.10 MB | +2.7 KiB (+0.06%) | +2.2 KiB (+0.10%) | -1.39% |
JS shrinks in every app (no helper calls, shorter source), but bytecode moves with the ratio of array patterns, which grow, to object-rest patterns, which shrink. The template app has proportionally more object rest from React Native's own components, so its bytecode gets smaller. Mattermost (RN 0.83, Expo 55) builds with the legacy profile, whose preset already lowers parameters and object rest, so its row is a lower bound. The Expo apps use babel-preset-expo, which already omits the destructuring transform for Hermes V1; their A builds add it back.
Method
- Builds. One RN 0.88.0-rc.4 template app, built for release on both platforms (Hermes V1, release VM, no JIT, confirmed at runtime from
HermesInternal). Variant A is the current default; variant B setsunstable_preserveDestructuring, which is what the PR makes the default. - Micro suite. Each case is written once and compiled into the same bundle twice: verbatim, and pre-lowered with the preset's exact plugin options (
transform-destructuringwithuseBuiltIns, helpers viatransform-runtime). Both run in one process, alternating order per sample, each sample about 40 ms, 15 samples × 3 launches. Iteration counts are identical for both forms and results are compared for equality. - Control. The same suite in a variant A build, where both copies end up lowered, shows 0.46% (A16) and 0.69% (iPhone) mean absolute difference. Seven parameter-destructuring cases, unaffected by the transform, sit within ±0.5% in every run.
- App A/B. Four interleaved rounds per device (ABBA order), each with 10 cold launches and 2 render runs per variant. Render runs time 60 theme changes and 60 FlatList remounts after 10 warm-up iterations. Startup marks come from Metro's
__BUNDLE_START_TIME__andperformance.now()at first commit and the following frame. - Transform profile. RN 0.88.0-rc.4's
bundlecommand still defaults--unstable-transform-profiletodefault(main switched tohermes-stablein #57911), and neither the Xcode nor the Gradle bundling step overrides it. So the on-device builds here lower classes in both variants. The destructuring toggle is independent of the profile and the micro suite contains no classes; the bundle-size rows for the template and RNTester usehermes-stable. - Helper resolution. App code lowered by the preset imports Babel's CommonJS helpers. With Metro's inline requires, each lowered array destructure pays a
require()call insideslicedToArray, which makes those helpers about 55% slower than their ESM copies (helpers/esm/*, resolved when code usesimport). The lowered side of every comparison here uses the CommonJS helpers apps really get.
Before landing
- Keep
@babel/plugin-transform-destructuringon whenever the preset adds the regenerator transform, or the dev +enableBabelRuntime: falseconfiguration breaks. - Optionally keep Babel's array-literal special case with a tiny plugin, or ask Hermes to fold
[a, b] = [x, y]and array-typed rest parameters in IRGen; the rest of the transform can go. - The bytecode cost is concentrated in array patterns; worth a look from the Hermes side (fast path for two- and three-element arrays) rather than a reason to keep the transform.
Follow-up: -Xenable-fast-destructure
Hermes has a hidden compiler flag, -Xenable-fast-destructure (also implied by -Xenable-fast-noncompliant), described as "UNSUPPORTED: Enable a faster, but non-compliant, array destructuring". It was not benchmarked on device: its effect is narrow enough to be read from the compiler, and it does not touch any of the tradeoffs above.
What it changes. In native array destructuring, Hermes wraps each store to a target, and each default initialiser, in a try/catch that closes the iterator if that step throws, as the spec requires. The flag drops those wrappers. Stores to plain local bindings can't throw, so a pattern like const [value, setValue] = useState() never had one and compiles identically with or without the flag. Only array patterns with defaults ([a = f()]), nested patterns and member-expression targets ([obj.a, b] = pair) change.
Expected speed. No measurable change. Hermes implements try/catch with exception tables, so a try region costs nothing while nothing throws; the flag removes bytecode and handler entries, not work on the normal path. The array-pattern regressions above come from walking the iterator protocol where Babel indexes directly, which the flag leaves as it is.
Size. The bundles with native destructuring, compiled again with the flag by RN 0.88's hermesc:
| Bundle | Native destructuring | With the flag | Δ |
|---|---|---|---|
| RN 0.88 template | 963,768 B | 963,660 B | −108 B (−0.01%) |
| RNTester (RN main) | 1,762,956 B | 1,762,804 B | −152 B (−0.01%) |
| Mattermost mobile | 25,341,228 B | 25,328,212 B | −13,016 B (−0.05%) |
| Bluesky social-app | 31,022,724 B | 31,017,572 B | −5,152 B (−0.02%) |
That recovers roughly a tenth of the 0.1–0.5% that native destructuring adds to bytecode.
Compatibility. For arrays nothing observable changes, because array iterators have no return(). For generators and custom iterables it does: if a default initialiser or a target throws mid-pattern, return() is skipped, so a generator's finally cleanup does not run.
Recommendation. Don't adopt it alongside this change. It would mean passing an unsupported, non-compliant flag in both the Xcode and Gradle bundling steps for a 0.01–0.05% bytecode saving and no expected runtime gain.
Follow-up: where the bytecode size increase comes from
Almost all of it is the code Hermes emits inline for array destructuring, and most of that is per-element bookkeeping around the iterator rather than the iterator operations themselves. Object patterns compile to the same bytecode as Babel's output, and object rest is smaller.
Opcode-level differences between the two builds, release bytecode for Android:
| Instructions | RNTester | Mattermost |
|---|---|---|
| Added by native array destructuring | ||
| Mov (register copies) | +8.8 KB | +39.5 KB |
| JmpTrue (done and skip branches) | +4.2 KB | +20.6 KB |
| IteratorNext | +2.9 KB | +13.9 KB |
| StrictEq (done checks) | +2.9 KB | +13.8 KB |
| LoadConstUndefined (defaults) | +2.1 KB | +10.2 KB |
| IteratorBegin and IteratorClose | +2.2 KB | +10.2 KB |
| Removed with Babel's helpers | ||
| Call2 and Call3 (helper calls) | −4.6 KB | −19.0 KB |
GetByIndex (_ref[0]) | −3.9 KB | −18.1 KB |
GetByIdShort (.default) | −2.7 KB | −9.1 KB |
| Helper access through the environment | −2.0 KB | −8.9 KB |
| Total | ||
| Bytecode | +8.0 KB (+0.7%) | +33.7 KB (+0.3%) |
| Array destructuring patterns | 373 | 1,663 |
| Net per pattern | about +21 B | about +20 B |
In Mattermost about 14 KB of object-literal instructions also switched between their long and short encodings as buffer offsets shifted; that is layout churn, not a destructuring cost. Strings, literal buffers and function counts are otherwise roughly neutral.
For var [x, y] = a, Hermes emits:
IteratorBegin r5, r6 Mov r3, r6 ; IteratorNext r3, r5, r3 ; Mov r4, r5 LoadConstUndefined r1 ; StrictEq r0, r4, r1 ; done? LoadConstUndefined r4 ; JmpTrue L1, r0 ; Mov r4, r3 ; value, or undefined when done L1: LoadConstUndefined r3 ; JmpTrue L2, r0 ; skip the next() once done IteratorNext r6, r5, r6 ; Mov r7, r5 ; StrictEq r2, r7, r1 ; … L2: JmpTrue L3, r0 ; IteratorClose r5, 0
Each element pays for a done check, a conditional default to undefined, a branch that skips later next() calls, and register copies, about 19 bytes against a 4-byte IteratorNext. Mov alone is 38% of the added bytes.
Ways Hermes could claim it back
- Let iterator instructions handle exhaustion. Hermes already marks a finished iterator by setting its register to
undefined. AnIteratorNextvariant that yieldsundefinedfrom a finished iterator, and anIteratorClosethat does nothing for one, would let the compiler drop the per-element check, branch and default: one 4-byte instruction per element. Estimated from the opcode counts, RNTester would add about 5 KB against the 13.8 KB removed, so native destructuring would come out smaller than Babel's output, with fewer branches on the normal path too. This needs a new opcode or a bytecode version bump. - A fused array-destructure instruction. Something like
DestructureArray dst, count, srccould fill consecutive registers when the source is an array with the default iterator, whichIteratorBeginalready detects, and fall back to the generic sequence otherwise: about 5 bytes per pattern plus one per element. - Fewer register copies. Many
Movs look like artefacts of lowering the iterator and done flag through stack slots; better coalescing after destructuring could remove some without new instructions. - Babel's special cases, at compile time. When the right-hand side is an array literal, or a rest parameter that is known to be an array, Hermes could assign elements directly as Babel does. That would fix both the size and the 2–11× slowdowns above.
The per-element saving and the RNTester estimate are derived from opcode counts and encodings, not from a prototype. Meta's increase scales with its number of array patterns and its own Babel configuration.