react/react-native #58965 · benchmark

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.

RN 0.88.0-rc.4Hermes 260318099.0.4 (Static Hermes, release, no JIT)Measured 2026-10-12

Summary

Cold startNo changeBundle start → first commit within ±1.5% on both devices; every 95% CI spans zero (40 launches per variant per device).
Re-render 40 cards−2.1 to −2.4%JS time from setState to commit after a theme change.
Remount 40-card list−4.4 to −4.5%Same app, FlatList remount. Consistent across devices.
Bytecode size-0.25 to +0.49%Hermes bytecode across the app bundles measured. Array patterns grow; object rest shrinks.

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

Breaks
Dev builds with 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.)
5–11× slower
Array patterns with an array-literal right-hand sideBabel rewrites 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.
1.7–2.2× slower
Destructuring 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.
+16–18%
Array rest from large arraysconst [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.
+31 B per site
Bytecode size of array patternsA native two-element array pattern is 183 B vs 152 B lowered (+21%), because the iterator protocol, its done-checks and the iterator close are emitted inline instead of one helper call. Defaults and nesting grow it further (+83 B and +224 B). Object patterns are identical; object rest is 18 B smaller. Hermes' hidden -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.
Neutral
Everything elseObject patterns without rest compile to identical bytecode (0.0% ± 1% in every case). Destructured function parameters, including 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.

PhaseslicedToArrayobjectWithoutPropertiestoArrayPredicted saving, A16Predicted saving, iPhone
Startup to first frame775613401.23 ms0.67 ms
One theme change + one remount1,2821,147802.28 ms1.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)

MetricDeviceLoweredNativeΔ (95% CI)
bundle start → first commitA16390.7389.7-1.0 (-0.3%)
-6.8 … +6.2
bundle start → first commitiPhone79.580.7+1.2 (+1.5%)
-1.1 … +3.7
bundle start → first frameA16711.1713.0+2.0 (+0.3%)
-11.5 … +11.4
bundle start → first frameiPhone105.0105.2+0.2 (+0.2%)
-4.0 … +4.4
am start TotalTimeA16295.5297.0+1.5 (+0.5%)
-4.0 … +4.0

Render (ms, median of 480 samples)

ScenarioDeviceLoweredNativeΔ (95% CI)
Theme change, 40 cardsA1683.9181.91-2.00 (-2.4%)
-2.95 … -0.38
Theme change, 40 cardsiPhone33.4632.75-0.71 (-2.1%)
-1.05 … -0.35
FlatList remount, 40 cardsA16126.12120.56-5.56 (-4.4%)
-6.70 … -4.34
FlatList remount, 40 cardsiPhone38.3936.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.

Native fasterNative slower
All 46 cases as a table
PatternA16 lowered nsA16 native nsA16 ΔiPhone lowered nsiPhone native nsiPhone Δ

Bundle size in real apps

Production bundles built both ways and compiled with the same hermesc RN 0.88 ships, using the release flags.

AppPlatformHBC loweredHBC nativeHBC ΔHBC gzip ΔJS Δ
RN 0.88 templateandroid0.97 MB0.96 MB-2.4 KiB (-0.25%)-0.7 KiB (-0.16%)-1.66%
RN 0.88 templateios0.96 MB0.96 MB-2.1 KiB (-0.22%)-0.8 KiB (-0.18%)-1.60%
RNTester (RN main)android1.75 MB1.76 MB+7.8 KiB (+0.46%)+3.6 KiB (+0.48%)-2.31%
RNTester (RN main)ios1.76 MB1.77 MB+8.4 KiB (+0.49%)+4.0 KiB (+0.54%)-2.27%
Mattermost mobileandroid25.32 MB25.34 MB+25.0 KiB (+0.10%)+37.2 KiB (+0.40%)-0.89%
Mattermost mobileios25.09 MB25.13 MB+37.6 KiB (+0.15%)+24.6 KiB (+0.27%)-0.87%
Bluesky social-appandroid31.01 MB31.02 MB+12.8 KiB (+0.04%)+12.9 KiB (+0.13%)-1.23%
Bluesky social-appios30.93 MB30.97 MB+37.9 KiB (+0.13%)+27.9 KiB (+0.28%)-1.18%
Private Expo SDK 58 game appandroid5.29 MB5.29 MB-0.6 KiB (-0.01%)+0.2 KiB (+0.01%)-1.59%
Private Expo SDK 58 game appios5.09 MB5.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 sets unstable_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-destructuring with useBuiltIns, helpers via transform-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__ and performance.now() at first commit and the following frame.
  • Transform profile. RN 0.88.0-rc.4's bundle command still defaults --unstable-transform-profile to default (main switched to hermes-stable in #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 use hermes-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 inside slicedToArray, which makes those helpers about 55% slower than their ESM copies (helpers/esm/*, resolved when code uses import). The lowered side of every comparison here uses the CommonJS helpers apps really get.

Before landing

  • Keep @babel/plugin-transform-destructuring on whenever the preset adds the regenerator transform, or the dev + enableBabelRuntime: false configuration 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:

BundleNative destructuringWith the flagΔ
RN 0.88 template963,768 B963,660 B−108 B (−0.01%)
RNTester (RN main)1,762,956 B1,762,804 B−152 B (−0.01%)
Mattermost mobile25,341,228 B25,328,212 B−13,016 B (−0.05%)
Bluesky social-app31,022,724 B31,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:

InstructionsRNTesterMattermost
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 patterns3731,663
Net per patternabout +21 Babout +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. An IteratorNext variant that yields undefined from a finished iterator, and an IteratorClose that 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, src could fill consecutive registers when the source is an array with the default iterator, which IteratorBegin already 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.