Mobile Apps

Hermes Bytecode: Cold Start Wins and the Trade-Offs

How Hermes precompilation cuts React Native startup time, the bytecode format, and what you lose when you enable it.

Mohammed Saqib8 min read
Smartphone held in hand displaying a stopwatch timer app.
Photo by Castorly Stock on Pexels · Pexels License

Cold start in a React Native app is mostly JavaScript work: the device downloads the bundle, parses the source, and compiles it before a single screen renders. That parse-and-compile step sits directly on the critical path, and Hermes removes it by doing the work at build time. The payoff is a solid drop in time-to-interactive — but the bytecode format brings constraints you need to audit before you flip the flag.

What Hermes bytecode changes at the engine level

Hermes compiles your JavaScript to bytecode during the Metro bundle step, so the app never parses source on the device. The bundle you ship is a sequence of 32-bit instructions executed by Hermes's register-based virtual machine, and the format itself is documented in the Hermes repository under include/hermes/BCGen/HBC, with a fuller description in the bytecode section of the Hermes docs. Because the bytecode is already decoded into VM-level operations, startup skips the two most expensive phases of a JIT engine: the source parse and the initial compile.

Contrast that with JavaScriptCore or V8. Those engines receive minified JS text, parse it on the device, then JIT-compile hot paths after the app has been running for a while. That works well for long-lived pages where the JIT pays for itself, but a mobile cold start is a one-shot: you parse, you execute, you render. Hermes deliberately trades the runtime JIT for deterministic ahead-of-time compilation. Everything is fixed at bundle time; the engine just walks the instructions.

There is a second, subtler consequence: Hermes also replaces JavaScriptCore on iOS. The same bytecode runs on both platforms, which removes a whole class of "works on iOS but crashes on Android" JS semantic differences — but it also means the iOS runtime loses JSC's JIT. That distinction matters for the benchmark discussion below.

Measuring the startup difference in practice

Start with a repeatable measurement. Kill the app, clear the process, and measure time-to-interactive with Hermes on, then off. The CLI's profiling flags give you a rough trace, but for something closer to production you want the Hermes sampling profiler against a release build. Capture the JS evaluation interval — the segment between app start and the first frame of your root component.

On a typical RN 0.72+ app targeting mid-range Android hardware, the JS evaluation window shrinks from the low hundreds of milliseconds to roughly 50–100ms once Hermes is enabled. Phrased differently: the parse-and-compile phase, which used to dominate early startup, effectively disappears. This is the same class of win covered in Cutting cold start time in React Native apps, except Hermes attacks it at the engine level rather than the bundle level.

The price is disk space. Hermes bytecode is roughly 20–30% larger than its minified JS source equivalent because bytecode stores fully resolved operands and metadata the source simply does not have. It decodes faster, but it weighs more. Add a per-bundle size check the way you would for any regression — the approach in App size budgets: finding out what actually ships in your bundle works unchanged, just grep your bundle output for .hbc files and track them.

Dimension Hermes JavaScriptCore
Compilation Ahead-of-time at bundle time On-device parse + JIT
Startup cost One-time decode, typically 50–100ms on Android Parse + compile on every cold start
Bundle size ~20–30% larger than minified JS Same as minified source
JIT after startup None Yes, hot paths get optimized over time
Debug tooling Hermes debugger, sampling profiler Safari/Chrome DevTools
ES2020 coverage Broad but incomplete (no WeakRef) Essentially complete
WebAssembly / asm.js Not supported Supported

Enabling Hermes: the build config and gotchas

The flag lives in two places and you need both. Wire up the Metro transformer and the native build configs:

// metro.config.js
const { getDefaultConfig } = require('@react-native/metro-config');
 
const config = getDefaultConfig(__dirname);
 
config.transformer.hermes = {
  enabled: true,
  // Optionally pass flags straight to hermesc
  flags: ['-O', '-output-source-map'],
};
 
module.exports = config;

On iOS you also set it in the Podfile so the pods rebuild against Hermes instead of shipping JavaScriptCore:

# ios/Podfile
use_react_native!(
  path: config[:reactNativePath],
  hermes_enabled: true,
)

On Android, flip either project.ext.react = [ enableHermes: true ] in the app's build.gradle or the newer hermesEnabled property, depending on your RN version. If you only set the Metro side, you get a Hermes-compiled JS bundle, but the binary still embeds JavaScriptCore — check both sides before you benchmark. The official Hermes guide walks through the per-version config, which has shifted between RN releases.

Two gotchas bite most teams. First, inlineRequires still works under Hermes, but Hermes treats __DEV__ branches more aggressively: in a release build it can prune whole dev-only subtrees, so code you "know" is dead may never ship — usually a good thing, occasionally surprising when a polyfill was hanging off a dev check. Second, and more bluntly: eval(), Function(), and most proxy traps are unsupported. If your dependency graph relies on them — some older Relay versions did — you get a runtime error, not a graceful degradation. Grep your bundle for eval( before the migration.

Debugging and profiling with Hermes

The Chrome DevTools debugger stops working the moment Hermes is on, because the runtime has no JS source to step through. The replacement is the Hermes debugger via react-native debug-hermes, or the React Native DevTools integration on recent RN versions. Breakpoints, stepping, and the console still work — they just operate against source maps instead of live source.

Profiling changes too. You switch from the JSC profiler to the Hermes sampling profiler, which dumps a .cpuprofile you can open in Chrome DevTools' Performance tab:

# profile a release build on an attached Android device
npx react-native profile-hermes \
  --bundle-id com.example.app \
  --platform android
# produces a .cpuprofile file; load it in DevTools > Performance

The flame chart shows bytecode function names, not your original JS line numbers — that's normal. What you lose is per-statement attribution; what you keep is the stack structure. Source maps are still emitted with the bundle, so crash stack traces in production point back to your TypeScript or JavaScript, which matters when your only debugging tool is a stack trace from a native crash.

Failure modes: what breaks and how to catch it early

The most common failure isn't your code — it's a third-party native module that ships a pre-compiled JavaScript bundle built for JSC. Those bundles run under Hermes's interpreter only if they were compiled for Hermes in the first place; a JSC-targeted bundle can fail at load time or, worse, misbehave at runtime. Check the library's documentation for explicit Hermes support before upgrading.

Dynamic module usage is the quieter failure mode. Hermes can optimize aggressively across static module boundaries, but patterns like computed require paths or require inside loops produce bytecode that executes correctly while sidestepping most of the optimizer's work. You won't see a crash; you'll see startup that's only marginally better — which defeats the purpose of the migration.

Catch both early with a CI step that builds a release binary with Hermes enabled and runs a smoke test:

# ci/hermes-smoke.sh
./gradlew :app:assembleRelease
adb install -r app/build/outputs/apk/release/app-release.apk
adb shell am force-stop com.example.app
adb shell monkey -p com.example.app 1
# the PID must still be alive a few seconds later
adb shell pidof com.example.app

A build-time failure from unsupported syntax is cheap to fix. A runtime crash in a release build that only reproduces on the third cold start is a terrible way to discover a Hermes incompatibility.

Hermes versus JavaScriptCore: when to stay on JSC

If your app leans on JIT-dependent patterns — asm.js optimizations, WebAssembly, or code written specifically to exploit JSC's tiered compilation — JavaScriptCore can still outperform Hermes in steady state after the JIT warms up. Real-world RN apps rarely hit this case; the average screen's JavaScript is not JIT-friendly enough to notice. But if you have a hot loop doing heavy numeric work, measure it before assuming Hermes is the right call.

The spec gap is the more concrete check. Hermes does not implement the full ES2020 suite. globalThis, BigInt, and dynamic import() are supported, but WeakRef and FinalizationRegistry are not. Audit your polyfill list and anything that touches weak references before migrating — React's weak reference usage in newer architecture internals is one of the reasons Hermes and React Native's New Architecture moved in lockstep.

Finally, the bytecode advantage is smaller on iOS, because Hermes replaces JavaScriptCore there — you lose JSC's JIT and gain the AOT decode, a roughly neutral trade on modern iPhones. If your iOS cold start is already under 500ms and your team spends most of its time in the iOS runtime, the migration effort may not pay off. Run your own benchmarks on both platforms; the Android win is reliable, the iOS win is conditional.

Key takeaways

  • Hermes moves parse-and-compile off the device and into the build, which is the dominant lever for Android cold start.
  • Expect bytecode to be ~20–30% heavier on disk but decode significantly faster — track .hbc sizes like any other bundle metric.
  • eval(), Function(), and proxy traps are unsupported; computed require paths degrade rather than crash.
  • Debug via the Hermes debugger and sampling profiler, not Chrome DevTools; source maps keep release stack traces readable.
  • The Android win is consistent; on iOS it's near-neutral, so benchmark both platforms before committing.

Frequently asked questions

Does Hermes bytecode make my app bundle smaller or larger?
Larger on disk, but faster to parse. Hermes bytecode is typically 20-30% bigger than the equivalent minified JavaScript bundle because the instructions include metadata and alignment padding. The trade-off is that the device skips the entire parse phase, so the time-to-interactive improves even though the download size increases.
Can I use Hermes with Expo or bare React Native?
Yes. Expo SDK 45+ enables Hermes by default for new projects. In bare React Native, you set the flag in both Metro config and platform build files. Hermes is compatible with the New Architecture (Fabric and TurboModules) as of React Native 0.71.
Will Hermes break my existing JavaScript code?
It can. Hermes does not support `eval()`, `Function()`, or Proxy objects. If your codebase or a dependency uses these, expect runtime crashes. The Hermes migration guide in the RN docs includes a compatibility checklist. Run a release build early in your CI pipeline to catch failures.
#react-native#hermes#performance#bytecode#mobile
Share

Keep reading