@react-native/babel-preset · investigation

Which preset transforms still cost Hermes bytecode

Each transform @react-native/babel-preset still runs for release Hermes builds, removed one at a time and measured for bytecode size, then the two biggest wins checked for runtime speed and memory in Hermes V1.

@react-native/babel-preset 0.88.0-rc.4Hermes hermes-v260318099.0.4Apple M4 Pro, macOSInvestigated 2026-10-28Published 2026-10-28

Summary

VerdictDrop unicode-regexSmaller in bytecode and memory, faster or within 1.3% at runtime, and Hermes V1 needs no change to support it.
u regexes: bytecode−0.30 to −0.67%Whole-app HBC. Comes from a handful of \p{…} regexes in popular libraries.
u regexes: memory−584 KB heapLive JS heap after loading highlight.js, fuse.js, path-to-regexp and zod, plus 205 KB less regex bytecode in native memory.
Native asyncSize win, runtime cost−0.08 to −0.20% HBC, but up to 13% slower per awaited call, with 7–41% more allocation and 15–18% more retained memory per suspended call.

Of the syntax transforms still active under the hermes-stable profile, only two move release bytecode by more than 0.1%. @babel/plugin-transform-unicode-regex expands every \p{L}-style class into kilobytes of explicit ranges and surrogate pairs. Hermes keeps each regex's source text and copies its compiled bytecode into every RegExp object it creates, so the expansion costs file size, heap and native memory. Letting Hermes compile u regexes natively removes 64–75 KB of HBC per app and makes regex-bound library code up to 17% faster and never more than 1.3% slower.

@babel/plugin-transform-async-to-generator is the second-largest size win, and it scales with how much async code an app has (about 24 B per async function). But Hermes V1's native async functions are driven by a JavaScript helper that allocates three closures per await. That makes native async slower or no faster than Babel's lowering, and heavier, in every call pattern measured except closures created per render. Preserving async is worth it for size only after Hermes optimises that helper.

Tradeoffs

Up to 13% slower
Native async functions are slower per call than Babel's loweringMeasured on awaited calls of async declarations, methods, chains, loops of awaits, rejections and Promise.all, in both heap layouts. Async methods are the mildest case (+0.6% to +3.2%). Allocation per call rises 7–41%, about 270–530 B per await. Async arrows created during a render are the exception: creating one is about 50% cheaper natively, and create-plus-call is 6–7% faster.
+144 to +304 B
Each suspended native async call retains more memoryA two-deep async call blocked on a pending promise retains 1,127 B natively vs 983 B lowered with Android's heap layout (+15%), and 2,029 B vs 1,725 B with the 64-bit layout (+18%).
Needs a Hermes flag
Preserving let/const is only correct with -Xes6-block-scopingWithout the flag, hermesc compiles let and const with var semantics, so closures created in loops all see the final value. React Native's release builds don't pass the flag. With it, reassigning a const, even in dead code, is a compile error rather than the runtime TypeError the spec requires. Size gain if solved: −0.11%.
Edge case
Native u regexes follow the engine's Unicode versionBabel bakes in its own Unicode data (Unicode 18 at regenerate-unicode-properties@10.3.0). Hermes V1 and Node 26 both use Unicode 17. Across every code point, the native and lowered forms of all 9 affected regexes differ only on characters that Unicode 18 assigns and Unicode 17 doesn't.
1% slower
One u regex workload is marginally slower nativelyCompiling 30 routes with path-to-regexp 8 is 1.0–1.3% slower with native \p{ID_Start}/\p{ID_Continue} character tests. It still saves 42 KB of HBC and 37 KB of heap.

What the preset still lowers for Hermes

Under hermes-stable in release, the 0.88 preset already preserves classes, class properties, generators, for…of, optional chaining and nullish coalescing. hermes-canary is currently identical. These syntax transforms still run on every file or every matching file, besides those that are always required (JSX, Flow, TypeScript, CommonJS modules, export default from):

PluginLowersExisting opt-out
transform-unicode-regexu-flag regexes, including \p{…}, to non-u regexes with explicit ranges and surrogate pairsNone
transform-async-to-generatorAsync functions to generators driven by the asyncToGenerator helperunstable_preserveAsync
transform-async-generator-functionsAsync generators and for awaitunstable_preserveAsync
transform-block-scopinglet/const to var, with per-iteration _loop functions and _readOnlyError()unstable_preserveBlockScoping
transform-private-methods, transform-private-property-in-object#x members and #x in obj to loose-mode string keysunstable_preserveClassPrivate
transform-named-capturing-groups-regex(?<name>…) to _wrapRegExp()None
transform-destructuringDestructuring declarations and assignmentsunstable_preserveDestructuring (react/react-native#58965)

The preset has no class static block support. With classes preserved, the private-member plugins throw "Static class blocks are not enabled" on any class with a static {} block (three.js has some), so apps must add @babel/plugin-transform-class-static-block themselves.

Bytecode size of each transform

Removing unicode-regex is worth 0.3–0.7% of release HBC, and async 0.1–0.2%. Everything else is within ±0.11%, and preserving private members or destructuring makes the bytecode slightly bigger. Section-level deltas confirm each change sits where the transform acts, with no knock-on operand-width changes elsewhere.

Test app (RN 0.88, ~5,000 modules, 9,526,832 B HBC)

Plugin removedHBC ΔHBC Δ %gzip ΔWhere
unicode-regex−64,012−0.67%−8,046String storage −55,444, regex bytecode −7,994
block-scoping (with -Xes6-block-scoping)−10,520−0.11%−5,497Function bodies −8,176, function info −1,720
async-to-generator−7,220−0.08%−2,314Function bodies −5,892, function info −1,300
+ async-generator-functions (with -Xasync-generators)−7,256−0.08%−2,579On top of the row above, mostly helper modules
class-static-block (app config)−660−0.01%−388Only possible with private members preserved
named-capturing-groups-regex00.00%+7No sites in this app
private-methods + private-property-in-object+2,396+0.03%−970Function bodies +3,176
destructuring+2,572+0.03%+3,204Function bodies +3,884, literal buffers −1,496
Combined
All without a hermesc flag−78,344−0.82%−11,699unicode, async, private, static block, named groups
+ block scoping, destructuring−84,508−0.89%−12,382With -Xes6-block-scoping

Mattermost mobile (25,213,060 B HBC)

Plugin removedHBC ΔHBC Δ %gzip ΔWhere
unicode-regex−75,184−0.30%−33,027String storage −66,968, regex bytecode −8,792
async-to-generator−51,216−0.20%−8,8612,101 async functions. Function bodies −50,996, function info −7,960, string storage +10,506
block-scoping (with -Xes6-block-scoping)−27,972−0.11%−11,607
named-capturing-groups-regex00.00%+8No sites
All three without a flag−125,600−0.50%−42,038unicode, async, named groups

Bytes of release Android HBC, from hermesc -O. Mattermost bundles with its own, older preset, so it serves only as a second, async-heavy body of code: only the plugin under test differs between each pair, and private members can't be preserved because that preset still lowers classes. The u-regex saving comes from highlight.js (xml and python grammars), fuse.js and zod 4 in the test app, and from path-to-regexp 8, commonmark, zod 4 and the app's own markdown and autocomplete code in Mattermost. Named groups are roughly neutral by construction: _wrapRegExp costs about 3.3 KB once, after which each lowered site is about 90 B smaller than native.

Does Hermes V1 run the untransformed syntax?

Yes for everything except let/const and async generators, which need compiler flags React Native doesn't pass today. Each feature was compiled with hermesc -O, run in the hermes VM and compared line by line with Node 26.

SyntaxResult
async / awaitCorrect. Covers arguments, this in async arrows, for await inside async functions, and rejection propagation.
async function*"async generators are unsupported" unless compiled with -Xasync-generators. With it, correct.
u regexes, \p{…}Correct, including astral ., \p{Script=Greek}, \p{XID_Start} and Unicode case folding. Equivalent to the lowered form except for Unicode-version skew.
Named groupsCorrect: .groups, $<name>, function replacers, \k<name>.
#private, #x in objCorrect, including brand-check TypeErrors. More faithful than loose-mode lowering, which exposes private state as properties.
static {}Correct.
let / constCompiled as var without -Xes6-block-scoping. With it, loop bindings are correct, but const reassignment is a compile error, and TDZ isn't checked (Babel doesn't check it either).

Native u regexes at runtime

Native compilation makes the affected libraries smaller in memory and as fast or faster, apart from a 1% slowdown in path-to-regexp. A bundle of just the four libraries that carry such regexes is 999,805 B of HBC lowered and 894,376 B native (−10.5%). After loading them and using each once, the live JS heap is 584 KB smaller and regex bytecode in native memory is 205 KB smaller.

The mechanism is in how Hermes stores regexes. Each RegExp object keeps its source string and a private malloc'd copy of its compiled bytecode (JSRegExp::initializeBytecode), so a large expansion is paid in every live RegExp, not just in the file. highlight.js is the extreme case: it concatenates grammar regexes' .source strings and compiles the results at runtime with new RegExp. For two languages, the expanded classes cost 572 KB of heap, 204 KB of regex bytecode and 5.3–5.6 MB of peak RSS, and add about 4.5 ms to the first highlight on an M4 Pro.

Memory after load and first use

LibraryLive heap, loweredLive heap, nativeΔRegex bytecode ΔPeak RSS Δ
highlight.js 11 (xml, python)946,896374,424−572,472−203,621−5.6 MB
path-to-regexp 8228,336190,872−37,464−4,418−0.1 MB
fuse.js 7 (token search)236,472225,840−10,632−1,1950.0 MB
zod 4 (unicodeEmail)716,600716,144−456−484−0.1 MB
All four together
Android heap layout2,231,1601,647,536−583,624−205,304−2.9 MB
64-bit heap layout2,985,2002,401,560−583,640−205,304−2.6 MB

Bytes after a full GC (hermes -gc-before-stats -gc-print-stats: "Allocated bytes" and "Malloc size"), median of 10 runs per variant. Per-library rows use the Android layout for highlight.js and the 64-bit layout for the rest; string and regex bytecode savings don't depend on the layout. Cold timings (ms resolution, median of 10): module init 25 → 21 ms, first highlight of both languages 12.5 → 8 ms.

Steady-state speed

WorkloadLowered (µs)Native (µs)Δ, Android layout (95% CI)Δ, 64-bit layout (95% CI)
fuse.js index build, 1,000 items21,16717,833−15.7%
−17.1 … −15.7
−17.1%
−17.4 … −16.7
zod unicodeEmail, 200 addresses181.0157.6−12.9%
−13.7 … −12.3
−13.8%
−14.1 … −13.3
highlight.js, 4 KB of XML5,3965,115−5.2%
−6.0 … −4.3
−6.1%
−7.1 … −5.5
highlight.js, 3 KB of Python5,3025,333+0.6%
−0.4 … +1.0
−0.4%
−1.2 … +1.2
fuse.js token search75,50076,000+0.7%
−0.4 … +1.6
0.0%
−0.7 … +0.9
path-to-regexp, compile 30 routes1,0001,010+1.0%
+0.5 … +1.8
+1.3%
+1.1 … +2.1

Time per operation, Android-layout medians shown. 10 interleaved processes per variant, median of 5 samples per process. CI is a 2,000-resample bootstrap of the ratio of medians. Inputs mix ASCII with accented, Greek, Cyrillic and CJK words.

Native async functions at runtime

Native async is up to 13% slower than Babel's lowering for awaited calls (5–13% for all but method calls) and allocates 7–41% more, in both heap layouts. The cause is how Hermes V1 implements async functions. The compiler turns an async function into an internal generator and hands it to spawnAsync, a plain JavaScript function in Hermes's internal bytecode (lib/InternalJavaScript/02-AsyncFn.js). For every await, that helper creates a fresh resolve closure, a fresh reject closure and a thunk for the next step, and always wraps the awaited value in Promise.resolve. Babel's asyncToGenerator creates its _next and _throw callbacks once per call and reuses them for every await.

Async arrows created per render go the other way. Babel lowers them to an IIFE that calls asyncToGenerator, plus an inline require of the helper, each time the arrow is created. Native creation is one closure, about half the cost, and create-plus-call is 6–7% faster. React components create many such closures (useCallback and useEffect bodies), so the net effect in a real app depends on its mix. Only the per-call cost is measured here.

function spawn(genF, self, args) {
  return new HermesPromise(function (resolve, reject) {
    var gen = genF.apply(self, args);
    function step(nextF) {
      …
      HermesPromiseResolve(next.value).then(
        function (v) { step(function () { return gen.next(v); }); },
        function (e) { step(function () { return gen.throw(e); }); }
      );
    }
    step(function () { return gen.next(undefined); });
  });
}
WorkloadLowered (µs)Native (µs)Δ, Android layout (95% CI)Δ, 64-bit layout (95% CI)Allocated per op, lowered → native
reject + catch2.492.81+12.9%
+12.2 … +13.5
+12.4%
+11.0 … +13.8
2,040 → 2,816 B
await async declaration0.780.83+7.2%
+6.5 … +7.9
+7.9%
+7.4 … +8.8
784 → 1,032 B
10 awaits in one function8.619.11+5.9%
+5.2 … +6.6
+7.5%
+6.4 … +8.0
8,696 → 11,424 B
3-level chain, 7 calls5.695.96+4.8%
+4.5 … +5.4
+7.3%
+6.0 … +7.7
5,656 → 7,392 B
Promise.all of 100 calls59.5762.50+4.9%
+4.1 … +5.5
+5.1%
+2.8 … +6.6
71,560 → 82,352 B
await async method0.850.85+0.6%
−0.3 … +1.2
+3.2%
+2.5 … +4.5
968 → 1,040 B
render: create + call async arrow1.341.24−7.4%
−8.3 … −7.3
−6.0%
−6.8 … −5.2
1,336 → 1,472 B
render: create async arrow only0.050.02−49.4%
−49.8 … −49.0
−50.7%
−51.0 … −50.3
Retained memory
Suspended two-deep call, Android layout983 → 1,127 B+14.7%10,000 calls blocked on a pending promise, live heap after full GC minus a baseline
Suspended two-deep call, 64-bit layout1,725 → 2,029 B+17.6%

Android-layout medians shown, measured the same way as the regex workloads. Allocation is js_totalAllocatedBytes per operation, Android layout. The lowered side is real preset output: @babel/runtime CommonJS helpers with Metro's inline requires, so each use of the helper is a require() call, as in shipping apps. Both sides use Hermes's built-in Promise with the microtask queue on, as React Native 0.88 does. Absolute times are for an M4 Pro, and phones will be several times slower. The relative differences come from extra allocation and calls in the interpreter, so similar ratios are likely on devices (inference, not measured).

Recommendation

Method

Bytecode size. A test app on React Native 0.88.0-rc.4: about 5,000 modules, 9.5 MB of HBC, with React Navigation, Redux, React Query, Apollo Client, i18next, highlight.js, fuse.js, zod and three.js among its dependencies. The installed preset was patched to filter named plugins out of its returned config, so each variant differs from the baseline by exactly one plugin. Bundles: react-native bundle --platform android --dev false --minify false --unstable-transform-profile hermes-stable --reset-cache. Compilation: hermesc -emit-binary -O -output-source-map, as React Native's Android release build runs it. Block-scoping variants are compared against a baseline also compiled with -Xes6-block-scoping; the flag alone changes the baseline by 8 B. Every bundle was smoke-tested in the hermes VM, and section sizes come from hbcdump -show-section-ranges. Mattermost mobile was patched and measured the same way with its own preset.

Correctness. A feature snippet per transform, compiled with hermesc -O, run in hermes and diffed against Node 26.11.1. For u regexes, each of the 9 regexes the transform changes in the test app was run natively and in its Babel-lowered form over every code point, as four probe strings each, in both Hermes and Node.

Runtime. Small entry bundles (one per workload set) built through the same Metro pipeline and preset, with and without the plugin, then run in the hermes CLI at the same tag on an Apple M4 Pro. Two VM builds were used: the default 64-bit heap layout, and Android's layout (HERMESVM_HEAP_HV_MODE=HEAP_HV_PREFER32 with a contiguous heap: compressed pointers and boxed doubles). Timing uses Date.now(), so each sample runs at least 150 ms. The machine was shared with other work, which the interleaving and bootstrap CIs account for. Phone measurements are a follow-up.

Async workloads
async function leaf(x) { return x + 1; }
async function mid(x) { return (await leaf(x)) + (await leaf(x)); }
async function top(x) { return (await mid(x)) + (await mid(x)); }
async function loop10(x) { let s = 0; for (let i = 0; i < 10; i++) s += await leaf(i + x); return s; }
async function rejects() { await null; throw new Error('x'); }
async function catches() { try { await rejects(); } catch (e) { return 1; } return 0; }
async function all100() { const ps = []; for (let i = 0; i < 100; i++) ps.push(leaf(i)); return (await Promise.all(ps)).length; }
class Store { constructor() { this.v = 1; } async get(k) { return this.v + k; } }
function render(props) {
  const onPress = async () => { await null; return props.id; };
  return onPress;
}

// Retained memory: 10,000 of these, blocked on a promise that never settles.
const never = (globalThis.never = new Promise(() => {}));
async function inner(i) { const r = await never; return r + i; }
async function outer(i) { return (await inner(i)) + 1; }
Regex workloads
hljs.highlight(xml, {language: 'xml'});            // 40 elements with namespaced tags and attributes
hljs.highlight(py, {language: 'python'});          // 30 functions with non-ASCII identifiers
new Fuse(items, {keys: ['title', 'body'], useTokenSearch: true});  // 1,000 items
fuse.search(phrase(2));
routes.map(r => match(r));                         // '/org/:orgId/team-N/:teamId/channel/:channelId{/:postId}/file-N/*rest'
emails.filter(e => z.email({pattern: z.regexes.unicodeEmail}).safeParse(e).success);

Revisions

RevisionDateChange
12026-10-28First published.

Each revision links to a frozen copy of the report as it was published. The commit history has every edit.