React Native doesn't run your JavaScript on V8 or JavaScriptCore. It runs it on Hermes, an engine Meta built for phones. I'd read the claims, fast startup and low memory, but never seen numbers on code that looks like an app's, next to an engine I know. So I built the exact Hermes that React Native 0.88 ships and ran the same scripts on it and on Node.
The short version: Hermes starts in a fraction of the time and uses a fraction of the memory, it keeps up with V8 on JSON, and it's two to five times slower than V8 at most other things, with regex replacement as the worst case I found.
The setup
- Hermes 1.0.0, built from the
hermes-v260318099.0.5tag, which is thehermes-compilerversion React Native 0.88.1 depends on. Release build, bytecode version 99. - Node 24.7.0 with V8 13.6, run normally and with
--jitless. - An Apple M4 MacBook Air on macOS 26.3.1.
The --jitless runs matter. iOS doesn't let apps generate machine code at runtime, so no JavaScript engine in an iOS app gets a JIT. V8 with its JIT turned off is closer to what's actually possible on a phone than normal Node. Hermes doesn't use a JIT either. This build has a -Xjit flag, but it answers "JIT is not enabled in this build".
Hermes ran each script two ways: from source, and from bytecode compiled ahead of time with hermesc -O -emit-binary, which is what a React Native release build ships. Every test ran 7 times as a separate process. The tables show medians, and the spread was a few percent at most.
Starting up
Three scripts: one that only prints, lodash (544 KB, the full commented build), and a 5.4 MB file made of ten copies of lodash, each in its own function scope, to stand in for a large app bundle. Times are for the whole process, start to exit.
| Script | Node | Node --jitless
|
Hermes, source | Hermes, bytecode |
|---|---|---|---|---|
| print only | 24.8 ms, 42 MB | 25.7 ms, 41 MB | 5.3 ms, 9 MB | 5.2 ms, 8 MB |
| lodash | 28.4 ms, 50 MB | 31.6 ms, 48 MB | 122.8 ms, 34 MB | 5.6 ms, 9 MB |
| 5.4 MB bundle | 68.6 ms, 98 MB | 71.9 ms, 97 MB | 1,580 ms, 200 MB | 9.0 ms, 13 MB |
This is what Hermes was built for. From bytecode, the large bundle started in 9 ms and the process peaked at 13 MB. Node took 69 ms and 98 MB.
From source it's the opposite. Hermes compiled the 5.4 MB bundle at startup, which took 1.6 seconds and 200 MB. V8 is far better at that, because it parses lazily and only compiles a function when it's called. So Hermes only makes sense with bytecode compiled ahead of time, which React Native does for release builds. In development, the app runs from source, and a slow start in debug mode doesn't tell you much about release.
Running app-like code
Four workloads, written the way app code usually looks:
- JSON: parse and stringify a 5.6 MB feed response, 20,000 posts with nested users, tags and locations
- list: filter, map and sort 100,000 posts, like preparing data for a long list
-
strings: build email addresses with
toLowerCaseand a regexreplace, validate them, pull dates apart with a regex, build display labels with template literals - objects: create 300,000 small class instances and map them to props objects
Times are per iteration, measured inside the script. Bytecode and source ran at the same speed here, so I show the bytecode numbers.
| Workload | Node | Node --jitless
|
Hermes |
|---|---|---|---|
| JSON parse, 5.6 MB | 16.0 ms | 14.4 ms | 14.8 ms |
| JSON stringify | 13.4 ms | 13.4 ms | 11.6 ms |
| list | 19.6 ms | 39.0 ms | 82.4 ms |
| strings | 12.6 ms | 35.6 ms | 62.8 ms |
| objects | 26.2 ms | 49.6 ms | 56.2 ms |
JSON is the one place Hermes kept up. Parsing was level with V8, slightly faster than normal Node and slightly slower than --jitless, and stringifying was about 13% faster than either. That's not a small thing. Parsing API responses is a big part of what an app's JavaScript does.
Everywhere else, Hermes was two to five times slower than V8 with its JIT: about twice as slow creating objects, four times for the list and five for the strings. It was slower than V8 without a JIT too, by about 13% for objects and by about twice for the list and the strings. So "Hermes is fast" is true for startup and not for running code. For most screens that's fine, because 80 ms to sort 100,000 items isn't something you'd do on every frame. For anything heavy, it's a reason to keep the work small or move it to native code.
Memory
Peak resident memory for the whole process, same runs:
| Workload | Node | Node --jitless
|
Hermes |
|---|---|---|---|
| JSON | 189 MB | 214 MB | 109 MB |
| list | 219 MB | 264 MB | 122 MB |
| strings | 146 MB | 139 MB | 42 MB |
| objects | 306 MB | 355 MB | 123 MB |
Hermes used between 29% and 58% of the memory normal Node did for the same work. On a phone, where the OS kills apps that use too much, that matters more than a few milliseconds.
Where the strings time went
The strings workload was the worst ratio, so I took it apart. 50,000 short names, each operation timed separately:
| Operation | Node | Node --jitless
|
Hermes |
|---|---|---|---|
toLowerCase() |
0.6 ms | 1.8 ms | 2.4 ms |
replace(/\s+/g, ".") |
3.2 ms | 10.5 ms | 33.4 ms |
replace(/\s+/, ".") |
1.6 ms | 4.5 ms | 14.4 ms |
replaceAll(" ", ".") |
4.5 ms | 5.7 ms | 11.0 ms |
| template literal | 0.6 ms | 3.0 ms | 4.6 ms |
toLowerCase and template literals were fine. Regex replacement was the slow part: Hermes took three times as long as V8 without a JIT, and ten times as long as normal V8. A plain-string replaceAll was three times faster than the regex version on Hermes, though it only matches single spaces, so it's not a drop-in replacement. If a hot path in your app runs a regex over every item in a list, that's the first thing I'd look at.
Bytecode is bigger than your minified source
Bytecode starts fast, but it isn't small:
| JavaScript | Hermes bytecode | |
|---|---|---|
| lodash, full build with comments | 544 KB | 185 KB |
| lodash, minified | 73 KB | 186 KB |
| lodash, minified, gzipped | 26 KB | 97 KB |
Against the commented source, bytecode looks three times smaller. Against the minified file, which is the fair comparison, it's two and a half times bigger, and almost four times bigger after gzip. So shipping bytecode trades download size for startup time. For most apps that's the right trade, but it's worth knowing which side the bill lands on.
What I got wrong
My first size comparison used the full lodash.js, and bytecode came out three times smaller. Most of that file is comments. Against the minified build, bytecode is much bigger, which is the opposite conclusion.
I expected to test Hermes's JIT too. The flag exists, but this build doesn't include the JIT, and React Native doesn't turn it on, so every Hermes number here is the interpreter.
And the first time I loaded lodash in Node, my script said lodash wasn't there. It was, but lodash saw Node's module object and exported itself through that instead of setting a global. I hid module and exports inside the wrapper, the same way the big bundle already did.
Caveats
This is a laptop, not a phone. An M4 is much faster than a mid-range Android phone, so the absolute numbers will be several times higher on a real device. The ratios between engines are what I'd trust, and even those could shift on a different CPU. Node isn't an engine you'd ship in an app; it's here as a reference point for V8. I didn't test JavaScriptCore, which is what React Native used on iOS before Hermes. The 5.4 MB bundle is ten copies of one library, so its bytecode deduplicated well and the 1.0 MB bytecode size for it isn't typical. Times include starting the process, which is part of why the empty script is in the table. And the workloads are mine. Your app's code might lean on something I didn't test.
What I'd take from it
- Always ship Hermes bytecode in release builds. From source, Hermes took 1.6 s to start the large bundle. From bytecode it took 9 ms.
- Don't judge startup in a debug build, because that runs from source.
- Hermes is fast at JSON, so parsing big responses in JavaScript is fine.
- Ordinary JavaScript runs several times slower than you'd see in Chrome or Node. Benchmark heavy loops on Hermes, not in the browser.
- Watch regex in hot paths.
replacewith a regex was the slowest thing I measured. - Expect the bytecode to be bigger than your minified bundle, and budget app size for it.
Top comments (0)