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.
Summary
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.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
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.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%.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.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):
| Plugin | Lowers | Existing opt-out |
|---|---|---|
| transform-unicode-regex | u-flag regexes, including \p{…}, to non-u regexes with explicit ranges and surrogate pairs | None |
| transform-async-to-generator | Async functions to generators driven by the asyncToGenerator helper | unstable_preserveAsync |
| transform-async-generator-functions | Async generators and for await | unstable_preserveAsync |
| transform-block-scoping | let/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 keys | unstable_preserveClassPrivate |
| transform-named-capturing-groups-regex | (?<name>…) to _wrapRegExp() | None |
| transform-destructuring | Destructuring declarations and assignments | unstable_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 removed | HBC Δ | HBC Δ % | gzip Δ | Where |
|---|---|---|---|---|
| unicode-regex | −64,012 | −0.67% | −8,046 | String storage −55,444, regex bytecode −7,994 |
block-scoping (with -Xes6-block-scoping) | −10,520 | −0.11% | −5,497 | Function bodies −8,176, function info −1,720 |
| async-to-generator | −7,220 | −0.08% | −2,314 | Function bodies −5,892, function info −1,300 |
+ async-generator-functions (with -Xasync-generators) | −7,256 | −0.08% | −2,579 | On top of the row above, mostly helper modules |
| class-static-block (app config) | −660 | −0.01% | −388 | Only possible with private members preserved |
| named-capturing-groups-regex | 0 | 0.00% | +7 | No sites in this app |
| private-methods + private-property-in-object | +2,396 | +0.03% | −970 | Function bodies +3,176 |
| destructuring | +2,572 | +0.03% | +3,204 | Function bodies +3,884, literal buffers −1,496 |
| Combined | ||||
| All without a hermesc flag | −78,344 | −0.82% | −11,699 | unicode, async, private, static block, named groups |
| + block scoping, destructuring | −84,508 | −0.89% | −12,382 | With -Xes6-block-scoping |
Mattermost mobile (25,213,060 B HBC)
| Plugin removed | HBC Δ | HBC Δ % | gzip Δ | Where |
|---|---|---|---|---|
| unicode-regex | −75,184 | −0.30% | −33,027 | String storage −66,968, regex bytecode −8,792 |
| async-to-generator | −51,216 | −0.20% | −8,861 | 2,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-regex | 0 | 0.00% | +8 | No sites |
| All three without a flag | −125,600 | −0.50% | −42,038 | unicode, 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.
| Syntax | Result |
|---|---|
| async / await | Correct. 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 groups | Correct: .groups, $<name>, function replacers, \k<name>. |
#private, #x in obj | Correct, including brand-check TypeErrors. More faithful than loose-mode lowering, which exposes private state as properties. |
static {} | Correct. |
let / const | Compiled 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
| Library | Live heap, lowered | Live heap, native | Δ | Regex bytecode Δ | Peak RSS Δ |
|---|---|---|---|---|---|
| highlight.js 11 (xml, python) | 946,896 | 374,424 | −572,472 | −203,621 | −5.6 MB |
| path-to-regexp 8 | 228,336 | 190,872 | −37,464 | −4,418 | −0.1 MB |
| fuse.js 7 (token search) | 236,472 | 225,840 | −10,632 | −1,195 | 0.0 MB |
zod 4 (unicodeEmail) | 716,600 | 716,144 | −456 | −484 | −0.1 MB |
| All four together | |||||
| Android heap layout | 2,231,160 | 1,647,536 | −583,624 | −205,304 | −2.9 MB |
| 64-bit heap layout | 2,985,200 | 2,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
| Workload | Lowered (µs) | Native (µs) | Δ, Android layout (95% CI) | Δ, 64-bit layout (95% CI) |
|---|---|---|---|---|
| fuse.js index build, 1,000 items | 21,167 | 17,833 | −15.7% −17.1 … −15.7 | −17.1% −17.4 … −16.7 |
zod unicodeEmail, 200 addresses | 181.0 | 157.6 | −12.9% −13.7 … −12.3 | −13.8% −14.1 … −13.3 |
| highlight.js, 4 KB of XML | 5,396 | 5,115 | −5.2% −6.0 … −4.3 | −6.1% −7.1 … −5.5 |
| highlight.js, 3 KB of Python | 5,302 | 5,333 | +0.6% −0.4 … +1.0 | −0.4% −1.2 … +1.2 |
| fuse.js token search | 75,500 | 76,000 | +0.7% −0.4 … +1.6 | 0.0% −0.7 … +0.9 |
| path-to-regexp, compile 30 routes | 1,000 | 1,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); });
});
}
| Workload | Lowered (µs) | Native (µs) | Δ, Android layout (95% CI) | Δ, 64-bit layout (95% CI) | Allocated per op, lowered → native |
|---|---|---|---|---|---|
| reject + catch | 2.49 | 2.81 | +12.9% +12.2 … +13.5 | +12.4% +11.0 … +13.8 | 2,040 → 2,816 B |
| await async declaration | 0.78 | 0.83 | +7.2% +6.5 … +7.9 | +7.9% +7.4 … +8.8 | 784 → 1,032 B |
| 10 awaits in one function | 8.61 | 9.11 | +5.9% +5.2 … +6.6 | +7.5% +6.4 … +8.0 | 8,696 → 11,424 B |
| 3-level chain, 7 calls | 5.69 | 5.96 | +4.8% +4.5 … +5.4 | +7.3% +6.0 … +7.7 | 5,656 → 7,392 B |
| Promise.all of 100 calls | 59.57 | 62.50 | +4.9% +4.1 … +5.5 | +5.1% +2.8 … +6.6 | 71,560 → 82,352 B |
| await async method | 0.85 | 0.85 | +0.6% −0.3 … +1.2 | +3.2% +2.5 … +4.5 | 968 → 1,040 B |
| render: create + call async arrow | 1.34 | 1.24 | −7.4% −8.3 … −7.3 | −6.0% −6.8 … −5.2 | 1,336 → 1,472 B |
| render: create async arrow only | 0.05 | 0.02 | −49.4% −49.8 … −49.0 | −50.7% −51.0 … −50.3 | |
| Retained memory | |||||
| Suspended two-deep call, Android layout | 983 → 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 layout | 1,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
- Stop running
transform-unicode-regexunder the Hermes profiles. Keep it for thedefaultprofile. It is the one change that wins on size and memory with no meaningful runtime cost: −0.3 to −0.7% HBC, hundreds of KB less heap in apps using highlight.js-style runtime regex building, and up to 17% faster regex-heavy library code. - Hold
unstable_preserveAsyncfor release until Hermes's async driver is cheaper. The size win is real (about 24 B per async function), but today it costs up to 13% per awaited call and 15–18% more retained memory per suspended call. RewritingspawnAsyncto reuse its step callbacks the way Babel's helper does looks like the fix (inference from the source, not prototyped). - Treat block scoping as a Hermes question. −0.11% everywhere, but it needs
-Xes6-block-scopingin React Native's Android and iOS compile steps, and a decision onconstreassignment being a compile error. - Leave private members, named groups and destructuring as they are, as far as size goes. Each is neutral or slightly bigger when preserved. Any case for preserving them rests on correctness or speed, not size.
- Add class static block support to the preset, so apps don't need their own
transform-class-static-blockwhen they depend on a library like three.js.
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
| Revision | Date | Change |
|---|---|---|
| 1 | 2026-10-28 | First published. |
Each revision links to a frozen copy of the report as it was published. The commit history has every edit.